mirror of
https://github.com/bitwarden/browser
synced 2026-02-23 16:13:21 +00:00
Merge branch 'main' into beeep/dev-container
This commit is contained in:
@@ -1031,6 +1031,8 @@ export default class MainBackground {
|
||||
this.keyGenerationService,
|
||||
this.sendStateProvider,
|
||||
this.encryptService,
|
||||
this.cryptoFunctionService,
|
||||
this.configService,
|
||||
);
|
||||
this.sendApiService = new SendApiService(
|
||||
this.apiService,
|
||||
|
||||
@@ -7,8 +7,6 @@ export type PhishingResource = {
|
||||
todayUrl: string;
|
||||
/** Matcher used to decide whether a given URL matches an entry from this resource */
|
||||
match: (url: URL, entry: string) => boolean;
|
||||
/** Whether to use the custom matcher. If false, only exact hasUrl lookups are used. Default: true */
|
||||
useCustomMatcher?: boolean;
|
||||
};
|
||||
|
||||
export const PhishingResourceType = Object.freeze({
|
||||
@@ -58,8 +56,6 @@ export const PHISHING_RESOURCES: Record<PhishingResourceType, PhishingResource[]
|
||||
"https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-links-ACTIVE.txt.md5",
|
||||
todayUrl:
|
||||
"https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/refs/heads/master/phishing-links-NEW-today.txt",
|
||||
// Disabled for performance - cursor search takes 6+ minutes on large databases
|
||||
useCustomMatcher: false,
|
||||
match: (url: URL, entry: string) => {
|
||||
if (!entry) {
|
||||
return false;
|
||||
|
||||
@@ -186,12 +186,74 @@ describe("PhishingDataService", () => {
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"[PhishingDataService] IndexedDB lookup via hasUrl failed",
|
||||
"[PhishingDataService] IndexedDB lookup failed",
|
||||
expect.any(Error),
|
||||
);
|
||||
// Custom matcher is disabled, so no custom matcher error is expected
|
||||
expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use cursor-based search when useCustomMatcher is enabled", async () => {
|
||||
// Temporarily enable custom matcher for this test
|
||||
const originalValue = (PhishingDataService as any).USE_CUSTOM_MATCHER;
|
||||
(PhishingDataService as any).USE_CUSTOM_MATCHER = true;
|
||||
|
||||
try {
|
||||
// Mock hasUrl to return false (no direct match)
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
// Mock findMatchingUrl to return true (custom matcher finds it)
|
||||
mockIndexedDbService.findMatchingUrl.mockResolvedValue(true);
|
||||
|
||||
const url = new URL("http://phish.com/path");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockIndexedDbService.hasUrl).toHaveBeenCalled();
|
||||
expect(mockIndexedDbService.findMatchingUrl).toHaveBeenCalled();
|
||||
} finally {
|
||||
// Restore original value
|
||||
(PhishingDataService as any).USE_CUSTOM_MATCHER = originalValue;
|
||||
}
|
||||
});
|
||||
|
||||
it("should return false when custom matcher finds no match (when enabled)", async () => {
|
||||
const originalValue = (PhishingDataService as any).USE_CUSTOM_MATCHER;
|
||||
(PhishingDataService as any).USE_CUSTOM_MATCHER = true;
|
||||
|
||||
try {
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
mockIndexedDbService.findMatchingUrl.mockResolvedValue(false);
|
||||
|
||||
const url = new URL("http://safe.com/path");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockIndexedDbService.findMatchingUrl).toHaveBeenCalled();
|
||||
} finally {
|
||||
(PhishingDataService as any).USE_CUSTOM_MATCHER = originalValue;
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle custom matcher errors gracefully (when enabled)", async () => {
|
||||
const originalValue = (PhishingDataService as any).USE_CUSTOM_MATCHER;
|
||||
(PhishingDataService as any).USE_CUSTOM_MATCHER = true;
|
||||
|
||||
try {
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
mockIndexedDbService.findMatchingUrl.mockRejectedValue(new Error("Cursor error"));
|
||||
|
||||
const url = new URL("http://error.com/path");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"[PhishingDataService] Custom matcher failed",
|
||||
expect.any(Error),
|
||||
);
|
||||
} finally {
|
||||
(PhishingDataService as any).USE_CUSTOM_MATCHER = originalValue;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("data updates", () => {
|
||||
|
||||
@@ -78,6 +78,10 @@ export const PHISHING_DOMAINS_BLOB_KEY = new KeyDefinition<string>(
|
||||
|
||||
/** Coordinates fetching, caching, and patching of known phishing web addresses */
|
||||
export class PhishingDataService {
|
||||
// Cursor-based search is disabled due to performance (6+ minutes on large databases)
|
||||
// Enable when performance is optimized via indexing or other improvements
|
||||
private static readonly USE_CUSTOM_MATCHER = false;
|
||||
|
||||
// While background scripts do not necessarily need destroying,
|
||||
// processes in PhishingDataService are memory intensive.
|
||||
// We are adding the destroy to guard against accidental leaks.
|
||||
@@ -153,12 +157,8 @@ export class PhishingDataService {
|
||||
* @returns True if the URL is a known phishing web address, false otherwise
|
||||
*/
|
||||
async isPhishingWebAddress(url: URL): Promise<boolean> {
|
||||
this.logService.debug("[PhishingDataService] isPhishingWebAddress called for: " + url.href);
|
||||
|
||||
// Skip non-http(s) protocols - phishing database only contains web URLs
|
||||
// This prevents expensive fallback checks for chrome://, about:, file://, etc.
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
this.logService.debug("[PhishingDataService] Skipping non-http(s) protocol: " + url.protocol);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -176,69 +176,37 @@ export class PhishingDataService {
|
||||
const urlHref = url.href;
|
||||
const urlWithoutTrailingSlash = urlHref.endsWith("/") ? urlHref.slice(0, -1) : null;
|
||||
|
||||
this.logService.debug("[PhishingDataService] Checking hasUrl on this string: " + urlHref);
|
||||
let hasUrl = await this.indexedDbService.hasUrl(urlHref);
|
||||
|
||||
// If not found and URL has trailing slash, try without it
|
||||
if (!hasUrl && urlWithoutTrailingSlash) {
|
||||
this.logService.debug(
|
||||
"[PhishingDataService] Checking hasUrl without trailing slash: " +
|
||||
urlWithoutTrailingSlash,
|
||||
);
|
||||
hasUrl = await this.indexedDbService.hasUrl(urlWithoutTrailingSlash);
|
||||
}
|
||||
|
||||
if (hasUrl) {
|
||||
this.logService.info(
|
||||
"[PhishingDataService] Found phishing web address through direct lookup: " + urlHref,
|
||||
);
|
||||
this.logService.info("[PhishingDataService] Found phishing URL: " + urlHref);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logService.error("[PhishingDataService] IndexedDB lookup via hasUrl failed", err);
|
||||
this.logService.error("[PhishingDataService] IndexedDB lookup failed", err);
|
||||
}
|
||||
|
||||
// If a custom matcher is provided and enabled, use cursor-based search.
|
||||
// This avoids loading all URLs into memory and allows early exit on first match.
|
||||
// Can be disabled via useCustomMatcher: false for performance reasons.
|
||||
if (resource && resource.match && resource.useCustomMatcher !== false) {
|
||||
// Custom matcher is disabled for performance (see USE_CUSTOM_MATCHER)
|
||||
if (resource && resource.match && PhishingDataService.USE_CUSTOM_MATCHER) {
|
||||
try {
|
||||
this.logService.debug(
|
||||
"[PhishingDataService] Starting cursor-based search for: " + url.href,
|
||||
);
|
||||
const startTime = performance.now();
|
||||
|
||||
const found = await this.indexedDbService.findMatchingUrl((entry) =>
|
||||
resource.match(url, entry),
|
||||
);
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = (endTime - startTime).toFixed(2);
|
||||
this.logService.debug(
|
||||
`[PhishingDataService] Cursor-based search completed in ${duration}ms for: ${url.href} (found: ${found})`,
|
||||
);
|
||||
|
||||
if (found) {
|
||||
this.logService.info(
|
||||
"[PhishingDataService] Found phishing web address through custom matcher: " + url.href,
|
||||
);
|
||||
} else {
|
||||
this.logService.debug(
|
||||
"[PhishingDataService] No match found, returning false for: " + url.href,
|
||||
);
|
||||
this.logService.info("[PhishingDataService] Found phishing URL via matcher: " + url.href);
|
||||
}
|
||||
return found;
|
||||
} catch (err) {
|
||||
this.logService.error("[PhishingDataService] Error running custom matcher", err);
|
||||
this.logService.debug(
|
||||
"[PhishingDataService] Returning false due to error for: " + url.href,
|
||||
);
|
||||
this.logService.error("[PhishingDataService] Custom matcher failed", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.logService.debug(
|
||||
"[PhishingDataService] No custom matcher, returning false for: " + url.href,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
EMPTY,
|
||||
filter,
|
||||
map,
|
||||
merge,
|
||||
mergeMap,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { distinctUntilChanged, EMPTY, filter, map, merge, Subject, switchMap, tap } from "rxjs";
|
||||
|
||||
import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -43,7 +33,6 @@ export class PhishingDetectionService {
|
||||
private static _tabUpdated$ = new Subject<PhishingDetectionNavigationEvent>();
|
||||
private static _ignoredHostnames = new Set<string>();
|
||||
private static _didInit = false;
|
||||
private static _activeSearchCount = 0;
|
||||
|
||||
static initialize(
|
||||
logService: LogService,
|
||||
@@ -64,7 +53,7 @@ export class PhishingDetectionService {
|
||||
tap((message) =>
|
||||
logService.debug(`[PhishingDetectionService] user selected continue for ${message.url}`),
|
||||
),
|
||||
mergeMap(async (message) => {
|
||||
switchMap(async (message) => {
|
||||
const url = new URL(message.url);
|
||||
this._ignoredHostnames.add(url.hostname);
|
||||
await BrowserApi.navigateTabToUrl(message.tabId, url);
|
||||
@@ -89,40 +78,25 @@ export class PhishingDetectionService {
|
||||
prev.ignored === curr.ignored,
|
||||
),
|
||||
tap((event) => logService.debug(`[PhishingDetectionService] processing event:`, event)),
|
||||
// Use mergeMap for parallel processing - each tab check runs independently
|
||||
// Concurrency limit of 5 prevents overwhelming IndexedDB
|
||||
mergeMap(async ({ tabId, url, ignored }) => {
|
||||
this._activeSearchCount++;
|
||||
const searchId = `${tabId}-${Date.now()}`;
|
||||
logService.debug(
|
||||
`[PhishingDetectionService] Search STARTED [${searchId}] for ${url.href} (active: ${this._activeSearchCount}/5)`,
|
||||
);
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
if (ignored) {
|
||||
// The next time this host is visited, block again
|
||||
this._ignoredHostnames.delete(url.hostname);
|
||||
return;
|
||||
}
|
||||
const isPhishing = await phishingDataService.isPhishingWebAddress(url);
|
||||
if (!isPhishing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const phishingWarningPage = new URL(
|
||||
BrowserApi.getRuntimeURL("popup/index.html#/security/phishing-warning") +
|
||||
`?phishingUrl=${url.toString()}`,
|
||||
);
|
||||
await BrowserApi.navigateTabToUrl(tabId, phishingWarningPage);
|
||||
} finally {
|
||||
this._activeSearchCount--;
|
||||
const duration = (performance.now() - startTime).toFixed(2);
|
||||
logService.debug(
|
||||
`[PhishingDetectionService] Search FINISHED [${searchId}] for ${url.href} in ${duration}ms (active: ${this._activeSearchCount}/5)`,
|
||||
);
|
||||
// Use switchMap to cancel any in-progress check when navigating to a new URL
|
||||
// This prevents race conditions where a stale check redirects the user incorrectly
|
||||
switchMap(async ({ tabId, url, ignored }) => {
|
||||
if (ignored) {
|
||||
// The next time this host is visited, block again
|
||||
this._ignoredHostnames.delete(url.hostname);
|
||||
return;
|
||||
}
|
||||
}, 5),
|
||||
const isPhishing = await phishingDataService.isPhishingWebAddress(url);
|
||||
if (!isPhishing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const phishingWarningPage = new URL(
|
||||
BrowserApi.getRuntimeURL("popup/index.html#/security/phishing-warning") +
|
||||
`?phishingUrl=${url.toString()}`,
|
||||
);
|
||||
await BrowserApi.navigateTabToUrl(tabId, phishingWarningPage);
|
||||
}),
|
||||
);
|
||||
|
||||
const onCancelCommand$ = messageListener
|
||||
|
||||
@@ -140,11 +140,6 @@ describe("BrowserPopupUtils", () => {
|
||||
|
||||
describe("openPopout", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(BrowserApi, "getPlatformInfo").mockResolvedValueOnce({
|
||||
os: "linux",
|
||||
arch: "x86-64",
|
||||
nacl_arch: "x86-64",
|
||||
});
|
||||
jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({
|
||||
id: 1,
|
||||
left: 100,
|
||||
@@ -155,8 +150,6 @@ describe("BrowserPopupUtils", () => {
|
||||
width: PopupWidthOptions.default,
|
||||
});
|
||||
jest.spyOn(BrowserApi, "createWindow").mockImplementation();
|
||||
jest.spyOn(BrowserApi, "updateWindowProperties").mockImplementation();
|
||||
jest.spyOn(BrowserApi, "getPlatformInfo").mockImplementation();
|
||||
});
|
||||
|
||||
it("creates a window with the default window options", async () => {
|
||||
@@ -274,63 +267,6 @@ describe("BrowserPopupUtils", () => {
|
||||
url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`,
|
||||
});
|
||||
});
|
||||
|
||||
it("exits fullscreen and focuses popout window if the current window is fullscreen and platform is mac", async () => {
|
||||
const url = "popup/index.html";
|
||||
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false);
|
||||
jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({
|
||||
os: "mac",
|
||||
arch: "x86-64",
|
||||
nacl_arch: "x86-64",
|
||||
});
|
||||
jest.spyOn(BrowserApi, "getWindow").mockReset().mockResolvedValueOnce({
|
||||
id: 1,
|
||||
left: 100,
|
||||
top: 100,
|
||||
focused: false,
|
||||
alwaysOnTop: false,
|
||||
incognito: false,
|
||||
width: PopupWidthOptions.default,
|
||||
state: "fullscreen",
|
||||
});
|
||||
jest
|
||||
.spyOn(BrowserApi, "createWindow")
|
||||
.mockResolvedValueOnce({ id: 2 } as chrome.windows.Window);
|
||||
|
||||
await BrowserPopupUtils.openPopout(url, { senderWindowId: 1 });
|
||||
expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(1, {
|
||||
state: "maximized",
|
||||
});
|
||||
expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(2, {
|
||||
focused: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("doesnt exit fullscreen if the platform is not mac", async () => {
|
||||
const url = "popup/index.html";
|
||||
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false);
|
||||
jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({
|
||||
os: "win",
|
||||
arch: "x86-64",
|
||||
nacl_arch: "x86-64",
|
||||
});
|
||||
jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({
|
||||
id: 1,
|
||||
left: 100,
|
||||
top: 100,
|
||||
focused: false,
|
||||
alwaysOnTop: false,
|
||||
incognito: false,
|
||||
width: PopupWidthOptions.default,
|
||||
state: "fullscreen",
|
||||
});
|
||||
|
||||
await BrowserPopupUtils.openPopout(url);
|
||||
|
||||
expect(BrowserApi.updateWindowProperties).not.toHaveBeenCalledWith(1, {
|
||||
state: "maximized",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("openCurrentPagePopout", () => {
|
||||
|
||||
@@ -168,29 +168,8 @@ export default class BrowserPopupUtils {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const platform = await BrowserApi.getPlatformInfo();
|
||||
const isMacOS = platform.os === "mac";
|
||||
const isFullscreen = senderWindow.state === "fullscreen";
|
||||
const isFullscreenAndMacOS = isFullscreen && isMacOS;
|
||||
//macOS specific handling for improved UX when sender in fullscreen aka green button;
|
||||
if (isFullscreenAndMacOS) {
|
||||
await BrowserApi.updateWindowProperties(senderWindow.id, {
|
||||
state: "maximized",
|
||||
});
|
||||
|
||||
//wait for macOS animation to finish
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
const newWindow = await BrowserApi.createWindow(popoutWindowOptions);
|
||||
|
||||
if (isFullscreenAndMacOS) {
|
||||
await BrowserApi.updateWindowProperties(newWindow.id, {
|
||||
focused: true,
|
||||
});
|
||||
}
|
||||
|
||||
return newWindow;
|
||||
return await BrowserApi.createWindow(popoutWindowOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,5 +18,5 @@ export async function registerOssPrograms(serviceContainer: ServiceContainer) {
|
||||
await vaultProgram.register();
|
||||
|
||||
const sendProgram = new SendProgram(serviceContainer);
|
||||
sendProgram.register();
|
||||
await sendProgram.register();
|
||||
}
|
||||
|
||||
@@ -608,6 +608,8 @@ export class ServiceContainer {
|
||||
this.keyGenerationService,
|
||||
this.sendStateProvider,
|
||||
this.encryptService,
|
||||
this.cryptoFunctionService,
|
||||
this.configService,
|
||||
);
|
||||
|
||||
this.cipherFileUploadService = new CipherFileUploadService(
|
||||
|
||||
386
apps/cli/src/tools/send/commands/create.command.spec.ts
Normal file
386
apps/cli/src/tools/send/commands/create.command.spec.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { SendCreateCommand } from "./create.command";
|
||||
|
||||
describe("SendCreateCommand", () => {
|
||||
let command: SendCreateCommand;
|
||||
|
||||
const sendService = mock<SendService>();
|
||||
const environmentService = mock<EnvironmentService>();
|
||||
const sendApiService = mock<SendApiService>();
|
||||
const accountProfileService = mock<BillingAccountProfileStateService>();
|
||||
const accountService = mock<AccountService>();
|
||||
|
||||
const activeAccount = {
|
||||
id: "user-id" as UserId,
|
||||
...mockAccountInfoWith({
|
||||
email: "user@example.com",
|
||||
name: "User",
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
accountService.activeAccount$ = of(activeAccount);
|
||||
accountProfileService.hasPremiumFromAnySource$.mockReturnValue(of(false));
|
||||
environmentService.environment$ = of({
|
||||
getWebVaultUrl: () => "https://vault.bitwarden.com",
|
||||
} as any);
|
||||
|
||||
command = new SendCreateCommand(
|
||||
sendService,
|
||||
environmentService,
|
||||
sendApiService,
|
||||
accountProfileService,
|
||||
accountService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("authType inference", () => {
|
||||
const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
describe("with CLI flags", () => {
|
||||
it("should set authType to Email when emails are provided via CLI", async () => {
|
||||
const requestJson = {
|
||||
type: SendType.Text,
|
||||
text: { text: "test content", hidden: false },
|
||||
deletionDate: futureDate,
|
||||
};
|
||||
|
||||
const cmdOptions = {
|
||||
email: ["test@example.com"],
|
||||
};
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{ id: "send-id", emails: "test@example.com", authType: AuthType.Email } as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
sendService.getFromState.mockResolvedValue({
|
||||
decrypt: jest.fn().mockResolvedValue({}),
|
||||
} as any);
|
||||
|
||||
const response = await command.run(requestJson, cmdOptions);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(sendService.encrypt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: SendType.Text,
|
||||
}),
|
||||
null,
|
||||
undefined,
|
||||
);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.Email);
|
||||
expect(savedCall[0].emails).toBe("test@example.com");
|
||||
});
|
||||
|
||||
it("should set authType to Password when password is provided via CLI", async () => {
|
||||
const requestJson = {
|
||||
type: SendType.Text,
|
||||
text: { text: "test content", hidden: false },
|
||||
deletionDate: futureDate,
|
||||
};
|
||||
|
||||
const cmdOptions = {
|
||||
password: "testPassword123",
|
||||
};
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{ id: "send-id", authType: AuthType.Password } as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
sendService.getFromState.mockResolvedValue({
|
||||
decrypt: jest.fn().mockResolvedValue({}),
|
||||
} as any);
|
||||
|
||||
const response = await command.run(requestJson, cmdOptions);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(sendService.encrypt).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
null as any,
|
||||
"testPassword123",
|
||||
);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.Password);
|
||||
});
|
||||
|
||||
it("should set authType to None when neither emails nor password provided", async () => {
|
||||
const requestJson = {
|
||||
type: SendType.Text,
|
||||
text: { text: "test content", hidden: false },
|
||||
deletionDate: futureDate,
|
||||
};
|
||||
|
||||
const cmdOptions = {};
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{ id: "send-id", authType: AuthType.None } as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
sendService.getFromState.mockResolvedValue({
|
||||
decrypt: jest.fn().mockResolvedValue({}),
|
||||
} as any);
|
||||
|
||||
const response = await command.run(requestJson, cmdOptions);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(sendService.encrypt).toHaveBeenCalledWith(expect.any(Object), null, undefined);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.None);
|
||||
});
|
||||
|
||||
it("should return error when both emails and password provided via CLI", async () => {
|
||||
const requestJson = {
|
||||
type: SendType.Text,
|
||||
text: { text: "test content", hidden: false },
|
||||
deletionDate: futureDate,
|
||||
};
|
||||
|
||||
const cmdOptions = {
|
||||
email: ["test@example.com"],
|
||||
password: "testPassword123",
|
||||
};
|
||||
|
||||
const response = await command.run(requestJson, cmdOptions);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toBe("--password and --emails are mutually exclusive.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with JSON input", () => {
|
||||
it("should set authType to Email when emails provided in JSON", async () => {
|
||||
const requestJson = {
|
||||
type: SendType.Text,
|
||||
text: { text: "test content", hidden: false },
|
||||
deletionDate: futureDate,
|
||||
emails: ["test@example.com", "another@example.com"],
|
||||
};
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{
|
||||
id: "send-id",
|
||||
emails: "test@example.com,another@example.com",
|
||||
authType: AuthType.Email,
|
||||
} as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
sendService.getFromState.mockResolvedValue({
|
||||
decrypt: jest.fn().mockResolvedValue({}),
|
||||
} as any);
|
||||
|
||||
const response = await command.run(requestJson, {});
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.Email);
|
||||
expect(savedCall[0].emails).toBe("test@example.com,another@example.com");
|
||||
});
|
||||
|
||||
it("should set authType to Password when password provided in JSON", async () => {
|
||||
const requestJson = {
|
||||
type: SendType.Text,
|
||||
text: { text: "test content", hidden: false },
|
||||
deletionDate: futureDate,
|
||||
password: "jsonPassword123",
|
||||
};
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{ id: "send-id", authType: AuthType.Password } as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
sendService.getFromState.mockResolvedValue({
|
||||
decrypt: jest.fn().mockResolvedValue({}),
|
||||
} as any);
|
||||
|
||||
const response = await command.run(requestJson, {});
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.Password);
|
||||
});
|
||||
|
||||
it("should return error when both emails and password provided in JSON", async () => {
|
||||
const requestJson = {
|
||||
type: SendType.Text,
|
||||
text: { text: "test content", hidden: false },
|
||||
deletionDate: futureDate,
|
||||
emails: ["test@example.com"],
|
||||
password: "jsonPassword123",
|
||||
};
|
||||
|
||||
const response = await command.run(requestJson, {});
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toBe("--password and --emails are mutually exclusive.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with mixed CLI and JSON input", () => {
|
||||
it("should return error when CLI emails combined with JSON password", async () => {
|
||||
const requestJson = {
|
||||
type: SendType.Text,
|
||||
text: { text: "test content", hidden: false },
|
||||
deletionDate: futureDate,
|
||||
password: "jsonPassword123",
|
||||
};
|
||||
|
||||
const cmdOptions = {
|
||||
email: ["cli@example.com"],
|
||||
};
|
||||
|
||||
const response = await command.run(requestJson, cmdOptions);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toBe("--password and --emails are mutually exclusive.");
|
||||
});
|
||||
|
||||
it("should return error when CLI password combined with JSON emails", async () => {
|
||||
const requestJson = {
|
||||
type: SendType.Text,
|
||||
text: { text: "test content", hidden: false },
|
||||
deletionDate: futureDate,
|
||||
emails: ["json@example.com"],
|
||||
};
|
||||
|
||||
const cmdOptions = {
|
||||
password: "cliPassword123",
|
||||
};
|
||||
|
||||
const response = await command.run(requestJson, cmdOptions);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toBe("--password and --emails are mutually exclusive.");
|
||||
});
|
||||
|
||||
it("should use CLI value when JSON has different value of same type", async () => {
|
||||
const requestJson = {
|
||||
type: SendType.Text,
|
||||
text: { text: "test content", hidden: false },
|
||||
deletionDate: futureDate,
|
||||
emails: ["json@example.com"],
|
||||
};
|
||||
|
||||
const cmdOptions = {
|
||||
email: ["cli@example.com"],
|
||||
};
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{ id: "send-id", emails: "cli@example.com", authType: AuthType.Email } as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
sendService.getFromState.mockResolvedValue({
|
||||
decrypt: jest.fn().mockResolvedValue({}),
|
||||
} as any);
|
||||
|
||||
const response = await command.run(requestJson, cmdOptions);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.Email);
|
||||
expect(savedCall[0].emails).toBe("cli@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should set authType to None when emails array is empty", async () => {
|
||||
const requestJson = {
|
||||
type: SendType.Text,
|
||||
text: { text: "test content", hidden: false },
|
||||
deletionDate: futureDate,
|
||||
emails: [] as string[],
|
||||
};
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{ id: "send-id", authType: AuthType.None } as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
sendService.getFromState.mockResolvedValue({
|
||||
decrypt: jest.fn().mockResolvedValue({}),
|
||||
} as any);
|
||||
|
||||
const response = await command.run(requestJson, {});
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.None);
|
||||
});
|
||||
|
||||
it("should set authType to None when password is empty string", async () => {
|
||||
const requestJson = {
|
||||
type: SendType.Text,
|
||||
text: { text: "test content", hidden: false },
|
||||
deletionDate: futureDate,
|
||||
};
|
||||
|
||||
const cmdOptions = {
|
||||
password: "",
|
||||
};
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{ id: "send-id", authType: AuthType.None } as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
sendService.getFromState.mockResolvedValue({
|
||||
decrypt: jest.fn().mockResolvedValue({}),
|
||||
} as any);
|
||||
|
||||
const response = await command.run(requestJson, cmdOptions);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.None);
|
||||
});
|
||||
|
||||
it("should set authType to None when password is whitespace only", async () => {
|
||||
const requestJson = {
|
||||
type: SendType.Text,
|
||||
text: { text: "test content", hidden: false },
|
||||
deletionDate: futureDate,
|
||||
};
|
||||
|
||||
const cmdOptions = {
|
||||
password: " ",
|
||||
};
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{ id: "send-id", authType: AuthType.None } as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
sendService.getFromState.mockResolvedValue({
|
||||
decrypt: jest.fn().mockResolvedValue({}),
|
||||
} as any);
|
||||
|
||||
const response = await command.run(requestJson, cmdOptions);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.None);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import { NodeUtils } from "@bitwarden/node/node-utils";
|
||||
|
||||
@@ -18,7 +19,6 @@ import { Response } from "../../../models/response";
|
||||
import { CliUtils } from "../../../utils";
|
||||
import { SendTextResponse } from "../models/send-text.response";
|
||||
import { SendResponse } from "../models/send.response";
|
||||
|
||||
export class SendCreateCommand {
|
||||
constructor(
|
||||
private sendService: SendService,
|
||||
@@ -81,12 +81,24 @@ export class SendCreateCommand {
|
||||
const emails = req.emails ?? options.emails ?? undefined;
|
||||
const maxAccessCount = req.maxAccessCount ?? options.maxAccessCount;
|
||||
|
||||
if (emails !== undefined && password !== undefined) {
|
||||
const hasEmails = emails != null && emails.length > 0;
|
||||
const hasPassword = password != null && password.trim().length > 0;
|
||||
|
||||
if (hasEmails && hasPassword) {
|
||||
return Response.badRequest("--password and --emails are mutually exclusive.");
|
||||
}
|
||||
|
||||
req.key = null;
|
||||
req.maxAccessCount = maxAccessCount;
|
||||
req.emails = emails;
|
||||
|
||||
if (hasEmails) {
|
||||
req.authType = AuthType.Email;
|
||||
} else if (hasPassword) {
|
||||
req.authType = AuthType.Password;
|
||||
} else {
|
||||
req.authType = AuthType.None;
|
||||
}
|
||||
|
||||
const hasPremium$ = this.accountService.activeAccount$.pipe(
|
||||
switchMap(({ id }) => this.accountProfileService.hasPremiumFromAnySource$(id)),
|
||||
@@ -136,11 +148,6 @@ export class SendCreateCommand {
|
||||
|
||||
const sendView = SendResponse.toView(req);
|
||||
const [encSend, fileData] = await this.sendService.encrypt(sendView, fileBuffer, password);
|
||||
// Add dates from template
|
||||
encSend.deletionDate = sendView.deletionDate;
|
||||
encSend.expirationDate = sendView.expirationDate;
|
||||
encSend.emails = emails && emails.join(",");
|
||||
|
||||
await this.sendApiService.save([encSend, fileData]);
|
||||
const newSend = await this.sendService.getFromState(encSend.id);
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
|
||||
400
apps/cli/src/tools/send/commands/edit.command.spec.ts
Normal file
400
apps/cli/src/tools/send/commands/edit.command.spec.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { Response } from "../../../models/response";
|
||||
import { SendResponse } from "../models/send.response";
|
||||
|
||||
import { SendEditCommand } from "./edit.command";
|
||||
import { SendGetCommand } from "./get.command";
|
||||
|
||||
describe("SendEditCommand", () => {
|
||||
let command: SendEditCommand;
|
||||
|
||||
const sendService = mock<SendService>();
|
||||
const getCommand = mock<SendGetCommand>();
|
||||
const sendApiService = mock<SendApiService>();
|
||||
const accountProfileService = mock<BillingAccountProfileStateService>();
|
||||
const accountService = mock<AccountService>();
|
||||
|
||||
const activeAccount = {
|
||||
id: "user-id" as UserId,
|
||||
...mockAccountInfoWith({
|
||||
email: "user@example.com",
|
||||
name: "User",
|
||||
}),
|
||||
};
|
||||
|
||||
const mockSendId = "send-123";
|
||||
const mockSendView = {
|
||||
id: mockSendId,
|
||||
type: SendType.Text,
|
||||
name: "Test Send",
|
||||
text: { text: "test content", hidden: false },
|
||||
deletionDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
} as SendView;
|
||||
|
||||
const mockSend = {
|
||||
id: mockSendId,
|
||||
type: SendType.Text,
|
||||
decrypt: jest.fn().mockResolvedValue(mockSendView),
|
||||
};
|
||||
|
||||
const encodeRequest = (data: any) => Buffer.from(JSON.stringify(data)).toString("base64");
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
accountService.activeAccount$ = of(activeAccount);
|
||||
accountProfileService.hasPremiumFromAnySource$.mockReturnValue(of(false));
|
||||
sendService.getFromState.mockResolvedValue(mockSend as any);
|
||||
getCommand.run.mockResolvedValue(Response.success(new SendResponse(mockSendView)) as any);
|
||||
|
||||
command = new SendEditCommand(
|
||||
sendService,
|
||||
getCommand,
|
||||
sendApiService,
|
||||
accountProfileService,
|
||||
accountService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("authType inference", () => {
|
||||
describe("with CLI flags", () => {
|
||||
it("should set authType to Email when emails are provided via CLI", async () => {
|
||||
const requestData = {
|
||||
id: mockSendId,
|
||||
type: SendType.Text,
|
||||
name: "Test Send",
|
||||
};
|
||||
const requestJson = encodeRequest(requestData);
|
||||
|
||||
const cmdOptions = {
|
||||
email: ["test@example.com"],
|
||||
};
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{ id: mockSendId, emails: "test@example.com", authType: AuthType.Email } as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
|
||||
const response = await command.run(requestJson, cmdOptions);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.Email);
|
||||
expect(savedCall[0].emails).toBe("test@example.com");
|
||||
});
|
||||
|
||||
it("should set authType to Password when password is provided via CLI", async () => {
|
||||
const requestData = {
|
||||
id: mockSendId,
|
||||
type: SendType.Text,
|
||||
name: "Test Send",
|
||||
};
|
||||
const requestJson = encodeRequest(requestData);
|
||||
|
||||
const cmdOptions = {
|
||||
password: "testPassword123",
|
||||
};
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{ id: mockSendId, authType: AuthType.Password } as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
|
||||
const response = await command.run(requestJson, cmdOptions);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.Password);
|
||||
});
|
||||
|
||||
it("should set authType to None when neither emails nor password provided", async () => {
|
||||
const requestData = {
|
||||
id: mockSendId,
|
||||
type: SendType.Text,
|
||||
name: "Test Send",
|
||||
};
|
||||
const requestJson = encodeRequest(requestData);
|
||||
|
||||
const cmdOptions = {};
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{ id: mockSendId, authType: AuthType.None } as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
|
||||
const response = await command.run(requestJson, cmdOptions);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.None);
|
||||
});
|
||||
|
||||
it("should return error when both emails and password provided via CLI", async () => {
|
||||
const requestData = {
|
||||
id: mockSendId,
|
||||
type: SendType.Text,
|
||||
name: "Test Send",
|
||||
};
|
||||
const requestJson = encodeRequest(requestData);
|
||||
|
||||
const cmdOptions = {
|
||||
email: ["test@example.com"],
|
||||
password: "testPassword123",
|
||||
};
|
||||
|
||||
const response = await command.run(requestJson, cmdOptions);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toBe("--password and --emails are mutually exclusive.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with JSON input", () => {
|
||||
it("should set authType to Email when emails provided in JSON", async () => {
|
||||
const requestData = {
|
||||
id: mockSendId,
|
||||
type: SendType.Text,
|
||||
name: "Test Send",
|
||||
emails: ["test@example.com", "another@example.com"],
|
||||
};
|
||||
const requestJson = encodeRequest(requestData);
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{ id: mockSendId, authType: AuthType.Email } as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
|
||||
const response = await command.run(requestJson, {});
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.Email);
|
||||
});
|
||||
|
||||
it("should set authType to Password when password provided in JSON", async () => {
|
||||
const requestData = {
|
||||
id: mockSendId,
|
||||
type: SendType.Text,
|
||||
name: "Test Send",
|
||||
password: "jsonPassword123",
|
||||
};
|
||||
const requestJson = encodeRequest(requestData);
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{ id: mockSendId, authType: AuthType.Password } as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
|
||||
const response = await command.run(requestJson, {});
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.Password);
|
||||
});
|
||||
|
||||
it("should return error when both emails and password provided in JSON", async () => {
|
||||
const requestData = {
|
||||
id: mockSendId,
|
||||
type: SendType.Text,
|
||||
name: "Test Send",
|
||||
emails: ["test@example.com"],
|
||||
password: "jsonPassword123",
|
||||
};
|
||||
const requestJson = encodeRequest(requestData);
|
||||
|
||||
const response = await command.run(requestJson, {});
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toBe("--password and --emails are mutually exclusive.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with mixed CLI and JSON input", () => {
|
||||
it("should return error when CLI emails combined with JSON password", async () => {
|
||||
const requestData = {
|
||||
id: mockSendId,
|
||||
type: SendType.Text,
|
||||
name: "Test Send",
|
||||
password: "jsonPassword123",
|
||||
};
|
||||
const requestJson = encodeRequest(requestData);
|
||||
|
||||
const cmdOptions = {
|
||||
email: ["cli@example.com"],
|
||||
};
|
||||
|
||||
const response = await command.run(requestJson, cmdOptions);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toBe("--password and --emails are mutually exclusive.");
|
||||
});
|
||||
|
||||
it("should return error when CLI password combined with JSON emails", async () => {
|
||||
const requestData = {
|
||||
id: mockSendId,
|
||||
type: SendType.Text,
|
||||
name: "Test Send",
|
||||
emails: ["json@example.com"],
|
||||
};
|
||||
const requestJson = encodeRequest(requestData);
|
||||
|
||||
const cmdOptions = {
|
||||
password: "cliPassword123",
|
||||
};
|
||||
|
||||
const response = await command.run(requestJson, cmdOptions);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toBe("--password and --emails are mutually exclusive.");
|
||||
});
|
||||
|
||||
it("should prioritize CLI value when JSON has different value of same type", async () => {
|
||||
const requestData = {
|
||||
id: mockSendId,
|
||||
type: SendType.Text,
|
||||
name: "Test Send",
|
||||
emails: ["json@example.com"],
|
||||
};
|
||||
const requestJson = encodeRequest(requestData);
|
||||
|
||||
const cmdOptions = {
|
||||
email: ["cli@example.com"],
|
||||
};
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{ id: mockSendId, emails: "cli@example.com", authType: AuthType.Email } as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
|
||||
const response = await command.run(requestJson, cmdOptions);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.Email);
|
||||
expect(savedCall[0].emails).toBe("cli@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should set authType to None when emails array is empty", async () => {
|
||||
const requestData = {
|
||||
id: mockSendId,
|
||||
type: SendType.Text,
|
||||
name: "Test Send",
|
||||
emails: [] as string[],
|
||||
};
|
||||
const requestJson = encodeRequest(requestData);
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{ id: mockSendId, authType: AuthType.None } as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
|
||||
const response = await command.run(requestJson, {});
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.None);
|
||||
});
|
||||
|
||||
it("should set authType to None when password is empty string", async () => {
|
||||
const requestData = {
|
||||
id: mockSendId,
|
||||
type: SendType.Text,
|
||||
name: "Test Send",
|
||||
password: "",
|
||||
};
|
||||
const requestJson = encodeRequest(requestData);
|
||||
|
||||
sendService.encrypt.mockResolvedValue([
|
||||
{ id: mockSendId, authType: AuthType.None } as any,
|
||||
null as any,
|
||||
]);
|
||||
sendApiService.save.mockResolvedValue(undefined as any);
|
||||
|
||||
const response = await command.run(requestJson, {});
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const savedCall = sendApiService.save.mock.calls[0][0];
|
||||
expect(savedCall[0].authType).toBe(AuthType.None);
|
||||
});
|
||||
|
||||
it("should handle send not found", async () => {
|
||||
sendService.getFromState.mockResolvedValue(null);
|
||||
|
||||
const requestData = {
|
||||
id: "nonexistent-id",
|
||||
type: SendType.Text,
|
||||
name: "Test Send",
|
||||
};
|
||||
const requestJson = encodeRequest(requestData);
|
||||
|
||||
const response = await command.run(requestJson, {});
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle type mismatch", async () => {
|
||||
const requestData = {
|
||||
id: mockSendId,
|
||||
type: SendType.File,
|
||||
name: "Test Send",
|
||||
};
|
||||
const requestJson = encodeRequest(requestData);
|
||||
|
||||
const response = await command.run(requestJson, {});
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toBe("Cannot change a Send's type");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation", () => {
|
||||
it("should return error when requestJson is empty", async () => {
|
||||
// Set BW_SERVE to prevent readStdin call
|
||||
process.env.BW_SERVE = "true";
|
||||
|
||||
const response = await command.run("", {});
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toBe("`requestJson` was not provided.");
|
||||
|
||||
delete process.env.BW_SERVE;
|
||||
});
|
||||
|
||||
it("should return error when id is not provided", async () => {
|
||||
const requestData = {
|
||||
type: SendType.Text,
|
||||
name: "Test Send",
|
||||
};
|
||||
const requestJson = encodeRequest(requestData);
|
||||
|
||||
const response = await command.run(requestJson, {});
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toBe("`itemid` was not provided.");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
|
||||
import { Response } from "../../../models/response";
|
||||
@@ -53,14 +54,30 @@ export class SendEditCommand {
|
||||
req.id = normalizedOptions.itemId || req.id;
|
||||
if (normalizedOptions.emails) {
|
||||
req.emails = normalizedOptions.emails;
|
||||
req.password = undefined;
|
||||
} else if (normalizedOptions.password) {
|
||||
req.emails = undefined;
|
||||
}
|
||||
if (normalizedOptions.password) {
|
||||
req.password = normalizedOptions.password;
|
||||
} else if (req.password && (typeof req.password !== "string" || req.password === "")) {
|
||||
}
|
||||
if (req.password && (typeof req.password !== "string" || req.password === "")) {
|
||||
req.password = undefined;
|
||||
}
|
||||
|
||||
// Infer authType based on emails/password (mutually exclusive)
|
||||
const hasEmails = req.emails != null && req.emails.length > 0;
|
||||
const hasPassword = req.password != null && req.password.trim() !== "";
|
||||
|
||||
if (hasEmails && hasPassword) {
|
||||
return Response.badRequest("--password and --emails are mutually exclusive.");
|
||||
}
|
||||
|
||||
if (hasEmails) {
|
||||
req.authType = AuthType.Email;
|
||||
} else if (hasPassword) {
|
||||
req.authType = AuthType.Password;
|
||||
} else {
|
||||
req.authType = AuthType.None;
|
||||
}
|
||||
|
||||
if (!req.id) {
|
||||
return Response.error("`itemid` was not provided.");
|
||||
}
|
||||
@@ -90,10 +107,6 @@ export class SendEditCommand {
|
||||
|
||||
try {
|
||||
const [encSend, encFileData] = await this.sendService.encrypt(sendView, null, req.password);
|
||||
// Add dates from template
|
||||
encSend.deletionDate = sendView.deletionDate;
|
||||
encSend.expirationDate = sendView.expirationDate;
|
||||
|
||||
await this.sendApiService.save([encSend, encFileData]);
|
||||
} catch (e) {
|
||||
return Response.error(e);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
@@ -54,6 +55,7 @@ export class SendResponse implements BaseResponse {
|
||||
view.emails = send.emails ?? [];
|
||||
view.disabled = send.disabled;
|
||||
view.hideEmail = send.hideEmail;
|
||||
view.authType = send.authType;
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -92,6 +94,7 @@ export class SendResponse implements BaseResponse {
|
||||
emails?: Array<string>;
|
||||
disabled: boolean;
|
||||
hideEmail: boolean;
|
||||
authType: AuthType;
|
||||
|
||||
constructor(o?: SendView, webVaultUrl?: string) {
|
||||
if (o == null) {
|
||||
@@ -116,8 +119,10 @@ export class SendResponse implements BaseResponse {
|
||||
this.deletionDate = o.deletionDate;
|
||||
this.expirationDate = o.expirationDate;
|
||||
this.passwordSet = o.password != null;
|
||||
this.emails = o.emails ?? [];
|
||||
this.disabled = o.disabled;
|
||||
this.hideEmail = o.hideEmail;
|
||||
this.authType = o.authType;
|
||||
|
||||
if (o.type === SendType.Text && o.text != null) {
|
||||
this.text = new SendTextResponse(o.text);
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as path from "path";
|
||||
import * as chalk from "chalk";
|
||||
import { program, Command, Option, OptionValues } from "commander";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
|
||||
@@ -31,13 +32,16 @@ import { parseEmail } from "./util";
|
||||
const writeLn = CliUtils.writeLn;
|
||||
|
||||
export class SendProgram extends BaseProgram {
|
||||
register() {
|
||||
program.addCommand(this.sendCommand());
|
||||
async register() {
|
||||
const emailAuthEnabled = await this.serviceContainer.configService.getFeatureFlag(
|
||||
FeatureFlag.SendEmailOTP,
|
||||
);
|
||||
program.addCommand(this.sendCommand(emailAuthEnabled));
|
||||
// receive is accessible both at `bw receive` and `bw send receive`
|
||||
program.addCommand(this.receiveCommand());
|
||||
}
|
||||
|
||||
private sendCommand(): Command {
|
||||
private sendCommand(emailAuthEnabled: boolean): Command {
|
||||
return new Command("send")
|
||||
.argument("<data>", "The data to Send. Specify as a filepath with the --file option")
|
||||
.description(
|
||||
@@ -59,9 +63,7 @@ export class SendProgram extends BaseProgram {
|
||||
new Option(
|
||||
"--email <email>",
|
||||
"optional emails to access this Send. Can also be specified in JSON.",
|
||||
)
|
||||
.argParser(parseEmail)
|
||||
.hideHelp(),
|
||||
).argParser(parseEmail),
|
||||
)
|
||||
.option("-a, --maxAccessCount <amount>", "The amount of max possible accesses.")
|
||||
.option("--hidden", "Hide <data> in web by default. Valid only if --file is not set.")
|
||||
@@ -78,11 +80,18 @@ export class SendProgram extends BaseProgram {
|
||||
.addCommand(this.templateCommand())
|
||||
.addCommand(this.getCommand())
|
||||
.addCommand(this.receiveCommand())
|
||||
.addCommand(this.createCommand())
|
||||
.addCommand(this.editCommand())
|
||||
.addCommand(this.createCommand(emailAuthEnabled))
|
||||
.addCommand(this.editCommand(emailAuthEnabled))
|
||||
.addCommand(this.removePasswordCommand())
|
||||
.addCommand(this.deleteCommand())
|
||||
.action(async (data: string, options: OptionValues) => {
|
||||
if (options.email) {
|
||||
if (!emailAuthEnabled) {
|
||||
this.processResponse(Response.error("The --email feature is not currently available."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const encodedJson = this.makeSendJson(data, options);
|
||||
|
||||
let response: Response;
|
||||
@@ -199,7 +208,7 @@ export class SendProgram extends BaseProgram {
|
||||
});
|
||||
}
|
||||
|
||||
private createCommand(): Command {
|
||||
private createCommand(emailAuthEnabled: any): Command {
|
||||
return new Command("create")
|
||||
.argument("[encodedJson]", "JSON object to upload. Can also be piped in through stdin.")
|
||||
.description("create a Send")
|
||||
@@ -215,6 +224,14 @@ export class SendProgram extends BaseProgram {
|
||||
.action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => {
|
||||
// subcommands inherit flags from their parent; they cannot override them
|
||||
const { fullObject = false, email = undefined, password = undefined } = args.parent.opts();
|
||||
|
||||
if (email) {
|
||||
if (!emailAuthEnabled) {
|
||||
this.processResponse(Response.error("The --email feature is not currently available."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const mergedOptions = {
|
||||
...options,
|
||||
fullObject: fullObject,
|
||||
@@ -227,7 +244,7 @@ export class SendProgram extends BaseProgram {
|
||||
});
|
||||
}
|
||||
|
||||
private editCommand(): Command {
|
||||
private editCommand(emailAuthEnabled: any): Command {
|
||||
return new Command("edit")
|
||||
.argument(
|
||||
"[encodedJson]",
|
||||
@@ -243,6 +260,14 @@ export class SendProgram extends BaseProgram {
|
||||
})
|
||||
.action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => {
|
||||
await this.exitIfLocked();
|
||||
const { email = undefined, password = undefined } = args.parent.opts();
|
||||
if (email) {
|
||||
if (!emailAuthEnabled) {
|
||||
this.processResponse(Response.error("The --email feature is not currently available."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const getCmd = new SendGetCommand(
|
||||
this.serviceContainer.sendService,
|
||||
this.serviceContainer.environmentService,
|
||||
@@ -259,8 +284,6 @@ export class SendProgram extends BaseProgram {
|
||||
this.serviceContainer.accountService,
|
||||
);
|
||||
|
||||
// subcommands inherit flags from their parent; they cannot override them
|
||||
const { email = undefined, password = undefined } = args.parent.opts();
|
||||
const mergedOptions = {
|
||||
...options,
|
||||
email,
|
||||
@@ -328,6 +351,7 @@ export class SendProgram extends BaseProgram {
|
||||
file: sendFile,
|
||||
text: sendText,
|
||||
type: type,
|
||||
emails: options.email ?? undefined,
|
||||
});
|
||||
|
||||
return Buffer.from(JSON.stringify(template), "utf8").toString("base64");
|
||||
|
||||
@@ -12,45 +12,54 @@
|
||||
{{ "checkBreaches" | i18n }}
|
||||
</button>
|
||||
</form>
|
||||
<div class="tw-mt-4" *ngIf="!loading && checkedUsername">
|
||||
<p *ngIf="error">{{ "reportError" | i18n }}...</p>
|
||||
<ng-container *ngIf="!error">
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}" *ngIf="!breachedAccounts.length">
|
||||
{{ "breachUsernameNotFound" | i18n: checkedUsername }}
|
||||
</bit-callout>
|
||||
<bit-callout type="danger" title="{{ 'breachFound' | i18n }}" *ngIf="breachedAccounts.length">
|
||||
{{ "breachUsernameFound" | i18n: checkedUsername : breachedAccounts.length }}
|
||||
</bit-callout>
|
||||
<ul
|
||||
class="tw-list-none tw-flex-col tw-divide-x-0 tw-divide-y tw-divide-solid tw-divide-secondary-300 tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-p-0"
|
||||
*ngIf="breachedAccounts.length"
|
||||
>
|
||||
<li *ngFor="let a of breachedAccounts" class="tw-flex tw-gap-4 tw-p-4">
|
||||
<div class="tw-w-32 tw-flex-none">
|
||||
<img [src]="a.logoPath" alt="" class="tw-max-w-32 tw-items-stretch" />
|
||||
</div>
|
||||
<div class="tw-flex-auto">
|
||||
<h3 class="tw-text-lg">{{ a.title }}</h3>
|
||||
<p [innerHTML]="a.description"></p>
|
||||
<p class="tw-mb-1">{{ "compromisedData" | i18n }}:</p>
|
||||
<ul>
|
||||
<li *ngFor="let d of a.dataClasses">{{ d }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tw-w-48 tw-flex-none">
|
||||
<dl>
|
||||
<dt>{{ "website" | i18n }}</dt>
|
||||
<dd>{{ a.domain }}</dd>
|
||||
<dt>{{ "affectedUsers" | i18n }}</dt>
|
||||
<dd>{{ a.pwnCount | number }}</dd>
|
||||
<dt>{{ "breachOccurred" | i18n }}</dt>
|
||||
<dd>{{ a.breachDate | date: "mediumDate" }}</dd>
|
||||
<dt>{{ "breachReported" | i18n }}</dt>
|
||||
<dd>{{ a.addedDate | date: "mediumDate" }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
</div>
|
||||
@if (!loading && checkedUsername) {
|
||||
<div class="tw-mt-4">
|
||||
@if (error) {
|
||||
<p>{{ "reportError" | i18n }}...</p>
|
||||
} @else {
|
||||
@if (!breachedAccounts.length) {
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}">
|
||||
{{ "breachUsernameNotFound" | i18n: checkedUsername }}
|
||||
</bit-callout>
|
||||
} @else {
|
||||
<bit-callout type="danger" title="{{ 'breachFound' | i18n }}">
|
||||
{{ "breachUsernameFound" | i18n: checkedUsername : breachedAccounts.length }}
|
||||
</bit-callout>
|
||||
<ul
|
||||
class="tw-list-none tw-flex-col tw-divide-x-0 tw-divide-y tw-divide-solid tw-divide-secondary-300 tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-p-0"
|
||||
>
|
||||
@for (a of breachedAccounts; track a) {
|
||||
<li class="tw-flex tw-gap-4 tw-p-4">
|
||||
<div class="tw-w-32 tw-flex-none">
|
||||
<img [src]="a.logoPath" alt="" class="tw-max-w-32 tw-items-stretch" />
|
||||
</div>
|
||||
<div class="tw-flex-auto">
|
||||
<h3 class="tw-text-lg">{{ a.title }}</h3>
|
||||
<p [innerHTML]="a.description"></p>
|
||||
<p class="tw-mb-1">{{ "compromisedData" | i18n }}:</p>
|
||||
<ul>
|
||||
@for (d of a.dataClasses; track d) {
|
||||
<li>{{ d }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tw-w-48 tw-flex-none">
|
||||
<dl>
|
||||
<dt>{{ "website" | i18n }}</dt>
|
||||
<dd>{{ a.domain }}</dd>
|
||||
<dt>{{ "affectedUsers" | i18n }}</dt>
|
||||
<dd>{{ a.pwnCount | number }}</dd>
|
||||
<dt>{{ "breachOccurred" | i18n }}</dt>
|
||||
<dd>{{ a.breachDate | date: "mediumDate" }}</dd>
|
||||
<dt>{{ "breachReported" | i18n }}</dt>
|
||||
<dd>{{ a.addedDate | date: "mediumDate" }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</bit-container>
|
||||
|
||||
@@ -5,108 +5,119 @@
|
||||
<button type="submit" buttonType="primary" bitButton [loading]="loading" (click)="load()">
|
||||
{{ "checkExposedPasswords" | i18n }}
|
||||
</button>
|
||||
<div class="tw-mt-4" *ngIf="hasLoaded">
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}" *ngIf="!ciphers.length">
|
||||
{{ "noExposedPasswords" | i18n }}
|
||||
</bit-callout>
|
||||
<ng-container *ngIf="ciphers.length">
|
||||
<bit-callout type="danger" title="{{ 'exposedPasswordsFound' | i18n }}" [useAlertRole]="true">
|
||||
{{ "exposedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
|
||||
<th bitCell bitSortable="organizationId" *ngIf="!isAdminConsoleActive">
|
||||
{{ "owner" | i18n }}
|
||||
</th>
|
||||
<th bitCell class="tw-text-right" bitSortable="exposedXTimes">
|
||||
{{ "timesExposed" | i18n }}
|
||||
</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>
|
||||
{{ row.name }}
|
||||
</a>
|
||||
</ng-container>
|
||||
<ng-template #cantManage>
|
||||
<span>{{ row.name }}</span>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="!organization && row.organizationId">
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="row.hasAttachments">
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell *ngIf="!isAdminConsoleActive">
|
||||
<app-org-badge
|
||||
*ngIf="!organization"
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
@if (hasLoaded) {
|
||||
<div class="tw-mt-4">
|
||||
@if (!ciphers.length) {
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}">
|
||||
{{ "noExposedPasswords" | i18n }}
|
||||
</bit-callout>
|
||||
} @else {
|
||||
<bit-callout
|
||||
type="danger"
|
||||
title="{{ 'exposedPasswordsFound' | i18n }}"
|
||||
[useAlertRole]="true"
|
||||
>
|
||||
{{ "exposedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
</app-org-badge>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge variant="warning">
|
||||
{{ "exposedXTimes" | i18n: (row.exposedXTimes | number) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
</div>
|
||||
@for (status of filterStatus; track status) {
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
}
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
|
||||
@if (!isAdminConsoleActive) {
|
||||
<th bitCell bitSortable="organizationId">
|
||||
{{ "owner" | i18n }}
|
||||
</th>
|
||||
}
|
||||
<th bitCell class="tw-text-right" bitSortable="exposedXTimes">
|
||||
{{ "timesExposed" | i18n }}
|
||||
</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization || canManageCipher(row)) {
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>
|
||||
{{ row.name }}
|
||||
</a>
|
||||
} @else {
|
||||
<span>{{ row.name }}</span>
|
||||
}
|
||||
@if (!organization && row.organizationId) {
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
}
|
||||
@if (row.hasAttachments) {
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
}
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
@if (!isAdminConsoleActive) {
|
||||
<td bitCell>
|
||||
@if (!organization) {
|
||||
<app-org-badge
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="
|
||||
row.organizationId | orgNameFromId: (organizations$ | async)
|
||||
"
|
||||
appStopProp
|
||||
>
|
||||
</app-org-badge>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge variant="warning">
|
||||
{{ "exposedXTimes" | i18n: (row.exposedXTimes | number) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</bit-container>
|
||||
|
||||
@@ -2,117 +2,124 @@
|
||||
|
||||
<bit-container>
|
||||
<p>{{ "inactive2faReportDesc" | i18n }}</p>
|
||||
<div *ngIf="!hasLoaded && loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
<div class="tw-mt-4" *ngIf="hasLoaded">
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}" *ngIf="!ciphers.length">
|
||||
{{ "noInactive2fa" | i18n }}
|
||||
</bit-callout>
|
||||
<ng-container *ngIf="ciphers.length">
|
||||
<bit-callout type="danger" title="{{ 'inactive2faFound' | i18n }}">
|
||||
{{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
@if (!hasLoaded && loading) {
|
||||
<div>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tw-mt-4">
|
||||
@if (!ciphers.length) {
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}">
|
||||
{{ "noInactive2fa" | i18n }}
|
||||
</bit-callout>
|
||||
} @else {
|
||||
<bit-callout type="danger" title="{{ 'inactive2faFound' | i18n }}">
|
||||
{{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
@for (status of filterStatus; track status) {
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
}
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
@if (!isAdminConsoleActive) {
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
<th bitCell></th>
|
||||
</ng-container>
|
||||
}
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization || canManageCipher(row)) {
|
||||
<ng-container>
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
</ng-container>
|
||||
} @else {
|
||||
<ng-template>
|
||||
<span>{{ row.name }}</span>
|
||||
</ng-template>
|
||||
}
|
||||
@if (!organization && row.organizationId) {
|
||||
<ng-container>
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
</ng-container>
|
||||
}
|
||||
@if (row.hasAttachments) {
|
||||
<ng-container>
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
</ng-container>
|
||||
}
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization) {
|
||||
<app-org-badge
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
/>
|
||||
}
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
@if (cipherDocs.has(row.id)) {
|
||||
<a bitBadge href="{{ cipherDocs.get(row.id) }}" target="_blank" rel="noreferrer">
|
||||
{{ "instructions" | i18n }}</a
|
||||
>
|
||||
}
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
}
|
||||
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header *ngIf="!isAdminConsoleActive">
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
<th bitCell></th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
</ng-container>
|
||||
<ng-template #cantManage>
|
||||
<span>{{ row.name }}</span>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="!organization && row.organizationId">
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="row.hasAttachments">
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<app-org-badge
|
||||
*ngIf="!organization"
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
>
|
||||
</app-org-badge>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<a
|
||||
bitBadge
|
||||
href="{{ cipherDocs.get(row.id) }}"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
*ngIf="cipherDocs.has(row.id)"
|
||||
>
|
||||
{{ "instructions" | i18n }}</a
|
||||
>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</bit-container>
|
||||
|
||||
@@ -2,111 +2,115 @@
|
||||
|
||||
<bit-container>
|
||||
<p>{{ "reusedPasswordsReportDesc" | i18n }}</p>
|
||||
<div *ngIf="!hasLoaded && loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
<div class="tw-mt-4" *ngIf="hasLoaded">
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}" *ngIf="!ciphers.length">
|
||||
{{ "noReusedPasswords" | i18n }}
|
||||
</bit-callout>
|
||||
<ng-container *ngIf="ciphers.length">
|
||||
<bit-callout type="danger" title="{{ 'reusedPasswordsFound' | i18n }}">
|
||||
{{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header *ngIf="!isAdminConsoleActive">
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
<th bitCell class="tw-text-right">{{ "timesReused" | i18n }}</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
</ng-container>
|
||||
<ng-template #cantManage>
|
||||
<span>{{ row.name }}</span>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="!organization && row.organizationId">
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="row.hasAttachments">
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<app-org-badge
|
||||
*ngIf="!organization"
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
@if (!hasLoaded && loading) {
|
||||
<div>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tw-mt-4">
|
||||
@if (!ciphers.length) {
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}">
|
||||
{{ "noReusedPasswords" | i18n }}
|
||||
</bit-callout>
|
||||
} @else {
|
||||
<bit-callout type="danger" title="{{ 'reusedPasswordsFound' | i18n }}">
|
||||
{{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
</app-org-badge>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge variant="warning">
|
||||
{{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
</div>
|
||||
@for (status of filterStatus; track status) {
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
}
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
@if (!isAdminConsoleActive) {
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
<th bitCell class="tw-text-right">{{ "timesReused" | i18n }}</th>
|
||||
</ng-container>
|
||||
}
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization || canManageCipher(row)) {
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
} @else {
|
||||
<span>{{ row.name }}</span>
|
||||
}
|
||||
@if (!organization && row.organizationId) {
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
}
|
||||
@if (row.hasAttachments) {
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
}
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization) {
|
||||
<app-org-badge
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
>
|
||||
</app-org-badge>
|
||||
}
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge variant="warning">
|
||||
{{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</bit-container>
|
||||
|
||||
@@ -2,105 +2,109 @@
|
||||
|
||||
<bit-container>
|
||||
<p>{{ "unsecuredWebsitesReportDesc" | i18n }}</p>
|
||||
<div *ngIf="!hasLoaded && loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
<div class="tw-mt-4" *ngIf="hasLoaded">
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}" *ngIf="!ciphers.length">
|
||||
{{ "noUnsecuredWebsites" | i18n }}
|
||||
</bit-callout>
|
||||
<ng-container *ngIf="ciphers.length">
|
||||
<bit-callout type="danger" title="{{ 'unsecuredWebsitesFound' | i18n }}">
|
||||
{{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header *ngIf="!isAdminConsoleActive">
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
</ng-container>
|
||||
<ng-template #cantManage>
|
||||
<span>{{ row.name }}</span>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="!organization && row.organizationId">
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="row.hasAttachments">
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<app-org-badge
|
||||
*ngIf="!organization"
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
@if (!hasLoaded && loading) {
|
||||
<div>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tw-mt-4">
|
||||
@if (!ciphers.length) {
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}">
|
||||
{{ "noUnsecuredWebsites" | i18n }}
|
||||
</bit-callout>
|
||||
} @else {
|
||||
<bit-callout type="danger" title="{{ 'unsecuredWebsitesFound' | i18n }}">
|
||||
{{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
</app-org-badge>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
</div>
|
||||
@for (status of filterStatus; track status) {
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
}
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
@if (!isAdminConsoleActive) {
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
</ng-container>
|
||||
}
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization || canManageCipher(row)) {
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
} @else {
|
||||
<span>{{ row.name }}</span>
|
||||
}
|
||||
@if (!organization && row.organizationId) {
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
}
|
||||
@if (row.hasAttachments) {
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
}
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization) {
|
||||
<app-org-badge
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
>
|
||||
</app-org-badge>
|
||||
}
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</bit-container>
|
||||
|
||||
@@ -2,115 +2,123 @@
|
||||
|
||||
<bit-container>
|
||||
<p>{{ "weakPasswordsReportDesc" | i18n }}</p>
|
||||
<div *ngIf="!hasLoaded && loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
<div class="tw-mt-4" *ngIf="hasLoaded">
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}" *ngIf="!ciphers.length">
|
||||
{{ "noWeakPasswords" | i18n }}
|
||||
</bit-callout>
|
||||
<ng-container *ngIf="ciphers.length">
|
||||
<bit-callout type="danger" title="{{ 'weakPasswordsFound' | i18n }}">
|
||||
{{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
|
||||
<th bitCell bitSortable="organizationId" *ngIf="!isAdminConsoleActive">
|
||||
{{ "owner" | i18n }}
|
||||
</th>
|
||||
<th bitCell class="tw-text-right" bitSortable="scoreKey" default>
|
||||
{{ "weakness" | i18n }}
|
||||
</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
</ng-container>
|
||||
<ng-template #cantManage>
|
||||
<span>{{ row.name }}</span>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="!organization && row.organizationId">
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="row.hasAttachments">
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell *ngIf="!isAdminConsoleActive">
|
||||
<app-org-badge
|
||||
*ngIf="!organization"
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
@if (!hasLoaded && loading) {
|
||||
<div>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tw-mt-4">
|
||||
@if (!ciphers.length) {
|
||||
<bit-callout type="success" title="{{ 'goodNews' | i18n }}">
|
||||
{{ "noWeakPasswords" | i18n }}
|
||||
</bit-callout>
|
||||
} @else {
|
||||
<bit-callout type="danger" title="{{ 'weakPasswordsFound' | i18n }}">
|
||||
{{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
</app-org-badge>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge [variant]="row.reportValue.badgeVariant">
|
||||
{{ row.reportValue.label | i18n }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
</div>
|
||||
@for (status of filterStatus; track status) {
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
}
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
|
||||
@if (!isAdminConsoleActive) {
|
||||
<th bitCell bitSortable="organizationId">
|
||||
{{ "owner" | i18n }}
|
||||
</th>
|
||||
}
|
||||
<th bitCell class="tw-text-right" bitSortable="scoreKey" default>
|
||||
{{ "weakness" | i18n }}
|
||||
</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
@if (!organization || canManageCipher(row)) {
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
} @else {
|
||||
<span>{{ row.name }}</span>
|
||||
}
|
||||
@if (!organization && row.organizationId) {
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
}
|
||||
@if (row.hasAttachments) {
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
}
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
@if (!isAdminConsoleActive) {
|
||||
<td bitCell>
|
||||
@if (!organization) {
|
||||
<app-org-badge
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="
|
||||
row.organizationId | orgNameFromId: (organizations$ | async)
|
||||
"
|
||||
appStopProp
|
||||
>
|
||||
</app-org-badge>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td bitCell class="tw-text-right">
|
||||
<span bitBadge [variant]="row.reportValue.badgeVariant">
|
||||
{{ row.reportValue.label | i18n }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</bit-container>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<div
|
||||
class="tw-inline-grid tw-place-items-stretch tw-place-content-center tw-grid-cols-1 @xl:tw-grid-cols-2 @4xl:tw-grid-cols-3 tw-gap-4 [&_a]:tw-max-w-none @5xl:[&_a]:tw-max-w-72"
|
||||
>
|
||||
<div *ngFor="let report of reports">
|
||||
<app-report-card
|
||||
[title]="report.title | i18n"
|
||||
[description]="report.description | i18n"
|
||||
[route]="report.route"
|
||||
[variant]="report.variant"
|
||||
[icon]="report.icon"
|
||||
></app-report-card>
|
||||
</div>
|
||||
@for (report of reports; track report) {
|
||||
<div>
|
||||
<app-report-card
|
||||
[title]="report.title | i18n"
|
||||
[description]="report.description | i18n"
|
||||
[route]="report.route"
|
||||
[variant]="report.variant"
|
||||
[icon]="report.icon"
|
||||
></app-report-card>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,24 @@
|
||||
"noCriticalAppsAtRisk": {
|
||||
"message": "No critical applications at risk"
|
||||
},
|
||||
"critical":{
|
||||
"message": "Critical ($COUNT$)",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"content": "$1",
|
||||
"example": "3"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notCritical": {
|
||||
"message": "Not critical ($COUNT$)",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"content": "$1",
|
||||
"example": "5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"accessIntelligence": {
|
||||
"message": "Access Intelligence"
|
||||
},
|
||||
@@ -250,6 +268,9 @@
|
||||
"application": {
|
||||
"message": "Application"
|
||||
},
|
||||
"applications": {
|
||||
"message": "Applications"
|
||||
},
|
||||
"atRiskPasswords": {
|
||||
"message": "At-risk passwords"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
|
||||
@@ -10,7 +9,7 @@ import { ButtonModule, ButtonType, LinkModule, TypographyModule } from "@bitward
|
||||
@Component({
|
||||
selector: "dirt-activity-card",
|
||||
templateUrl: "./activity-card.component.html",
|
||||
imports: [CommonModule, TypographyModule, JslibModule, LinkModule, ButtonModule],
|
||||
imports: [TypographyModule, JslibModule, LinkModule, ButtonModule],
|
||||
host: {
|
||||
class:
|
||||
"tw-box-border tw-bg-background tw-block tw-text-main tw-border-solid tw-border tw-border-secondary-300 tw-border [&:not(bit-layout_*)]:tw-rounded-lg tw-rounded-lg tw-p-6 tw-min-h-56 tw-overflow-hidden",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
@@ -44,7 +43,7 @@ export type PasswordChangeView = (typeof PasswordChangeView)[keyof typeof Passwo
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: "dirt-password-change-metric",
|
||||
imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule],
|
||||
imports: [TypographyModule, JslibModule, ProgressModule, ButtonModule],
|
||||
templateUrl: "./password-change-metric.component.html",
|
||||
})
|
||||
export class PasswordChangeMetricComponent implements OnInit {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
|
||||
|
||||
import {
|
||||
@@ -25,7 +24,6 @@ import { DarkImageSourceDirective } from "@bitwarden/vault";
|
||||
selector: "dirt-assign-tasks-view",
|
||||
templateUrl: "./assign-tasks-view.component.html",
|
||||
imports: [
|
||||
CommonModule,
|
||||
ButtonModule,
|
||||
TypographyModule,
|
||||
I18nPipe,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
@@ -79,7 +78,6 @@ export type NewApplicationsDialogResultType =
|
||||
selector: "dirt-new-applications-dialog",
|
||||
templateUrl: "./new-applications-dialog.component.html",
|
||||
imports: [
|
||||
CommonModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
TypographyModule,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
@if ((dataService.reportStatus$ | async) == ReportStatusEnum.Loading) {
|
||||
<dirt-report-loading></dirt-report-loading>
|
||||
} @else {
|
||||
@let drawerDetails = dataService.drawerDetails$ | async;
|
||||
<div class="tw-mt-4 tw-flex tw-flex-col">
|
||||
<h2 class="tw-mb-6" bitTypography="h2">{{ "allApplications" | i18n }}</h2>
|
||||
<div class="tw-flex tw-gap-6">
|
||||
<div
|
||||
role="region"
|
||||
[attr.aria-label]="'atRiskMembers' | i18n"
|
||||
class="tw-flex-1 tw-box-border tw-bg-background tw-block tw-text-main tw-border-solid tw-border tw-border-secondary-300 tw-rounded-lg tw-p-4"
|
||||
[ngClass]="{
|
||||
'tw-bg-primary-100': drawerDetails.invokerId === 'allAppsOrgAtRiskMembers',
|
||||
}"
|
||||
>
|
||||
<div class="tw-flex tw-flex-col tw-gap-1">
|
||||
<span bitTypography="h6" class="tw-flex tw-text-main" id="allAppsOrgAtRiskMembersLabel">{{
|
||||
"atRiskMembers" | i18n
|
||||
}}</span>
|
||||
<div class="tw-flex tw-items-baseline tw-gap-2" role="status" aria-live="polite">
|
||||
<span
|
||||
bitTypography="h3"
|
||||
class="!tw-mb-0"
|
||||
aria-describedby="allAppsOrgAtRiskMembersLabel"
|
||||
>{{ applicationSummary().totalAtRiskMemberCount }}</span
|
||||
>
|
||||
<span bitTypography="body2">{{
|
||||
"cardMetrics" | i18n: applicationSummary().totalMemberCount
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-items-baseline tw-mt-1 tw-gap-2">
|
||||
<p bitTypography="body1" class="tw-mb-0">
|
||||
<button
|
||||
type="button"
|
||||
bitLink
|
||||
[attr.aria-label]="('viewAtRiskMembers' | i18n) + ': ' + ('atRiskMembers' | i18n)"
|
||||
(click)="dataService.setDrawerForOrgAtRiskMembers('allAppsOrgAtRiskMembers')"
|
||||
>
|
||||
{{ "viewAtRiskMembers" | i18n }}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="region"
|
||||
[attr.aria-label]="'atRiskApplications' | i18n"
|
||||
class="tw-flex-1 tw-box-border tw-bg-background tw-block tw-text-main tw-border-solid tw-border tw-border-secondary-300 tw-rounded-lg tw-p-4"
|
||||
[ngClass]="{
|
||||
'tw-bg-primary-100': drawerDetails.invokerId === 'allAppsOrgAtRiskApplications',
|
||||
}"
|
||||
>
|
||||
<div class="tw-flex tw-flex-col tw-gap-1">
|
||||
<span
|
||||
bitTypography="h6"
|
||||
class="tw-flex tw-text-main"
|
||||
id="allAppsOrgAtRiskApplicationsLabel"
|
||||
>{{ "atRiskApplications" | i18n }}</span
|
||||
>
|
||||
<div class="tw-flex tw-items-baseline tw-gap-2" role="status" aria-live="polite">
|
||||
<span
|
||||
bitTypography="h3"
|
||||
class="!tw-mb-0"
|
||||
aria-describedby="allAppsOrgAtRiskApplicationsLabel"
|
||||
>{{ applicationSummary().totalAtRiskApplicationCount }}</span
|
||||
>
|
||||
<span bitTypography="body2">{{
|
||||
"cardMetrics" | i18n: applicationSummary().totalApplicationCount
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-items-baseline tw-mt-1 tw-gap-2">
|
||||
<p bitTypography="body1" class="tw-mb-0">
|
||||
<button
|
||||
type="button"
|
||||
bitLink
|
||||
[attr.aria-label]="
|
||||
('viewAtRiskApplications' | i18n) + ': ' + ('atRiskApplications' | i18n)
|
||||
"
|
||||
(click)="dataService.setDrawerForOrgAtRiskApps('allAppsOrgAtRiskApplications')"
|
||||
>
|
||||
{{ "viewAtRiskApplications" | i18n }}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-flex tw-mt-8 tw-mb-4 tw-gap-4 tw-items-center">
|
||||
<bit-search
|
||||
[placeholder]="'searchApps' | i18n"
|
||||
class="tw-w-1/2"
|
||||
[formControl]="searchControl"
|
||||
></bit-search>
|
||||
|
||||
<bit-chip-select
|
||||
[placeholderText]="'filter' | i18n"
|
||||
placeholderIcon="bwi-sliders"
|
||||
[options]="filterOptions()"
|
||||
[ngModel]="selectedFilter()"
|
||||
(ngModelChange)="setFilterApplicationsByStatus($event)"
|
||||
fullWidth="false"
|
||||
></bit-chip-select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
[buttonType]="'primary'"
|
||||
bitButton
|
||||
class="tw-ml-auto"
|
||||
[disabled]="!selectedUrls().size"
|
||||
[loading]="markingAsCritical()"
|
||||
(click)="markAppsAsCritical()"
|
||||
>
|
||||
<i class="bwi tw-mr-2" [ngClass]="selectedUrls().size ? 'bwi-star-f' : 'bwi-star'"></i>
|
||||
{{ "markAppAsCritical" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<app-table-row-scrollable
|
||||
[dataSource]="dataSource"
|
||||
[showRowCheckBox]="true"
|
||||
[showRowMenuForCriticalApps]="false"
|
||||
[selectedUrls]="selectedUrls()"
|
||||
[openApplication]="drawerDetails.invokerId || ''"
|
||||
[checkboxChange]="onCheckboxChange"
|
||||
[showAppAtRiskMembers]="showAppAtRiskMembers"
|
||||
></app-table-row-scrollable>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
inject,
|
||||
OnInit,
|
||||
ChangeDetectionStrategy,
|
||||
signal,
|
||||
computed,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop";
|
||||
import { FormControl, ReactiveFormsModule } from "@angular/forms";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { combineLatest, debounceTime, startWith } from "rxjs";
|
||||
|
||||
import { Security } from "@bitwarden/assets/svg";
|
||||
import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers";
|
||||
import {
|
||||
OrganizationReportSummary,
|
||||
ReportStatus,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import {
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
LinkModule,
|
||||
NoItemsModule,
|
||||
SearchModule,
|
||||
TableDataSource,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
ChipSelectComponent,
|
||||
} from "@bitwarden/components";
|
||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";
|
||||
|
||||
import {
|
||||
ApplicationTableDataSource,
|
||||
AppTableRowScrollableComponent,
|
||||
} from "../shared/app-table-row-scrollable.component";
|
||||
import { ReportLoadingComponent } from "../shared/report-loading.component";
|
||||
|
||||
export const ApplicationFilterOption = {
|
||||
All: "all",
|
||||
Critical: "critical",
|
||||
NonCritical: "nonCritical",
|
||||
} as const;
|
||||
|
||||
export type ApplicationFilterOption =
|
||||
(typeof ApplicationFilterOption)[keyof typeof ApplicationFilterOption];
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: "dirt-applications",
|
||||
templateUrl: "./applications.component.html",
|
||||
imports: [
|
||||
ReportLoadingComponent,
|
||||
HeaderModule,
|
||||
LinkModule,
|
||||
SearchModule,
|
||||
PipesModule,
|
||||
NoItemsModule,
|
||||
SharedModule,
|
||||
AppTableRowScrollableComponent,
|
||||
IconButtonModule,
|
||||
TypographyModule,
|
||||
ButtonModule,
|
||||
ReactiveFormsModule,
|
||||
ChipSelectComponent,
|
||||
],
|
||||
})
|
||||
export class ApplicationsComponent implements OnInit {
|
||||
destroyRef = inject(DestroyRef);
|
||||
|
||||
protected ReportStatusEnum = ReportStatus;
|
||||
protected noItemsIcon = Security;
|
||||
|
||||
// Standard properties
|
||||
protected readonly dataSource = new TableDataSource<ApplicationTableDataSource>();
|
||||
protected readonly searchControl = new FormControl<string>("", { nonNullable: true });
|
||||
|
||||
// Template driven properties
|
||||
protected readonly selectedUrls = signal(new Set<string>());
|
||||
protected readonly markingAsCritical = signal(false);
|
||||
protected readonly applicationSummary = signal<OrganizationReportSummary>(createNewSummaryData());
|
||||
protected readonly criticalApplicationsCount = signal(0);
|
||||
protected readonly totalApplicationsCount = signal(0);
|
||||
protected readonly nonCriticalApplicationsCount = computed(() => {
|
||||
return this.totalApplicationsCount() - this.criticalApplicationsCount();
|
||||
});
|
||||
|
||||
// filter related properties
|
||||
protected readonly selectedFilter = signal<ApplicationFilterOption>(ApplicationFilterOption.All);
|
||||
protected selectedFilterObservable = toObservable(this.selectedFilter);
|
||||
protected readonly ApplicationFilterOption = ApplicationFilterOption;
|
||||
protected readonly filterOptions = computed(() => [
|
||||
{
|
||||
label: this.i18nService.t("critical", this.criticalApplicationsCount()),
|
||||
value: ApplicationFilterOption.Critical,
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("notCritical", this.nonCriticalApplicationsCount()),
|
||||
value: ApplicationFilterOption.NonCritical,
|
||||
},
|
||||
]);
|
||||
|
||||
constructor(
|
||||
protected i18nService: I18nService,
|
||||
protected activatedRoute: ActivatedRoute,
|
||||
protected toastService: ToastService,
|
||||
protected dataService: RiskInsightsDataService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.dataService.enrichedReportData$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: (report) => {
|
||||
if (report != null) {
|
||||
this.applicationSummary.set(report.summaryData);
|
||||
|
||||
// Map the report data to include the iconCipher for each application
|
||||
const tableDataWithIcon = report.reportData.map((app) => ({
|
||||
...app,
|
||||
iconCipher:
|
||||
app.cipherIds.length > 0
|
||||
? this.dataService.getCipherIcon(app.cipherIds[0])
|
||||
: undefined,
|
||||
}));
|
||||
this.dataSource.data = tableDataWithIcon;
|
||||
this.totalApplicationsCount.set(report.reportData.length);
|
||||
} else {
|
||||
this.dataSource.data = [];
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.dataSource.data = [];
|
||||
},
|
||||
});
|
||||
|
||||
this.dataService.criticalReportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: (criticalReport) => {
|
||||
if (criticalReport != null) {
|
||||
this.criticalApplicationsCount.set(criticalReport.reportData.length);
|
||||
} else {
|
||||
this.criticalApplicationsCount.set(0);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
combineLatest([
|
||||
this.searchControl.valueChanges.pipe(startWith("")),
|
||||
this.selectedFilterObservable,
|
||||
])
|
||||
.pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(([searchText, selectedFilter]) => {
|
||||
let filterFunction = (app: ApplicationTableDataSource) => true;
|
||||
|
||||
if (selectedFilter === ApplicationFilterOption.Critical) {
|
||||
filterFunction = (app) => app.isMarkedAsCritical;
|
||||
} else if (selectedFilter === ApplicationFilterOption.NonCritical) {
|
||||
filterFunction = (app) => !app.isMarkedAsCritical;
|
||||
}
|
||||
|
||||
this.dataSource.filter = (app) =>
|
||||
filterFunction(app) &&
|
||||
app.applicationName.toLowerCase().includes(searchText.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
setFilterApplicationsByStatus(value: ApplicationFilterOption) {
|
||||
this.selectedFilter.set(value);
|
||||
}
|
||||
|
||||
isMarkedAsCriticalItem(applicationName: string) {
|
||||
return this.selectedUrls().has(applicationName);
|
||||
}
|
||||
|
||||
markAppsAsCritical = async () => {
|
||||
this.markingAsCritical.set(true);
|
||||
const count = this.selectedUrls().size;
|
||||
|
||||
this.dataService
|
||||
.saveCriticalApplications(Array.from(this.selectedUrls()))
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("criticalApplicationsMarkedSuccess", count.toString()),
|
||||
});
|
||||
this.selectedUrls.set(new Set<string>());
|
||||
this.markingAsCritical.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("applicationsMarkedAsCriticalFail"),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
showAppAtRiskMembers = async (applicationName: string) => {
|
||||
await this.dataService.setDrawerForAppAtRiskMembers(applicationName);
|
||||
};
|
||||
|
||||
onCheckboxChange = (applicationName: string, event: Event) => {
|
||||
const isChecked = (event.target as HTMLInputElement).checked;
|
||||
this.selectedUrls.update((selectedUrls) => {
|
||||
const nextSelected = new Set(selectedUrls);
|
||||
if (isChecked) {
|
||||
nextSelected.add(applicationName);
|
||||
} else {
|
||||
nextSelected.delete(applicationName);
|
||||
}
|
||||
return nextSelected;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -6,12 +6,11 @@
|
||||
{{ title() }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="tw-text-main tw-text-sm sm:tw-text-base tw-font-normal tw-leading-normal"
|
||||
*ngIf="description()"
|
||||
>
|
||||
{{ description() }}
|
||||
</div>
|
||||
@if (description()) {
|
||||
<div class="tw-text-main tw-text-sm sm:tw-text-base tw-font-normal tw-leading-normal">
|
||||
{{ description() }}
|
||||
</div>
|
||||
}
|
||||
@if (benefits().length > 0) {
|
||||
<div class="tw-flex tw-flex-col tw-gap-4 sm:tw-gap-5">
|
||||
@for (benefit of benefits(); track $index) {
|
||||
@@ -38,69 +37,74 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="tw-flex tw-justify-start" *ngIf="buttonText() && buttonAction()">
|
||||
<button
|
||||
(click)="buttonAction()()"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
type="button"
|
||||
class="tw-px-3 tw-py-1.5 sm:tw-px-4 tw-rounded-full tw-text-sm sm:tw-text-base"
|
||||
>
|
||||
<i [class]="buttonIcon() + ' tw-mr-2'" *ngIf="buttonIcon()"></i>
|
||||
{{ buttonText() }}
|
||||
</button>
|
||||
</div>
|
||||
@if (buttonText() && buttonAction()) {
|
||||
<div class="tw-flex tw-justify-start">
|
||||
<button
|
||||
(click)="buttonAction()()"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
type="button"
|
||||
class="tw-px-3 tw-py-1.5 sm:tw-px-4 tw-rounded-full tw-text-sm sm:tw-text-base"
|
||||
>
|
||||
@if (buttonIcon()) {
|
||||
<i [class]="buttonIcon() + ' tw-mr-2'"></i>
|
||||
}
|
||||
{{ buttonText() }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="tw-hidden lg:tw-block tw-flex-shrink-0" *ngIf="videoSrc() || icon()">
|
||||
<div class="tw-size-64 xl:tw-size-80 tw-relative">
|
||||
@if (videoSrc()) {
|
||||
<video
|
||||
class="tw-size-full tw-rounded-lg"
|
||||
[src]="videoSrc()"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
aria-hidden="true"
|
||||
></video>
|
||||
} @else if (icon()) {
|
||||
<div
|
||||
class="tw-size-full tw-flex tw-items-center tw-justify-center tw-bg-secondary-100 tw-rounded-lg"
|
||||
>
|
||||
<bit-svg
|
||||
[content]="icon()"
|
||||
class="tw-size-16 xl:tw-size-24 tw-text-muted"
|
||||
@if (videoSrc() || icon()) {
|
||||
<div class="tw-hidden lg:tw-block tw-flex-shrink-0">
|
||||
<div class="tw-size-64 xl:tw-size-80 tw-relative">
|
||||
@if (videoSrc()) {
|
||||
<video
|
||||
class="tw-size-full tw-rounded-lg"
|
||||
[src]="videoSrc()"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
aria-hidden="true"
|
||||
></bit-svg>
|
||||
</div>
|
||||
}
|
||||
></video>
|
||||
} @else if (icon()) {
|
||||
<div
|
||||
class="tw-size-full tw-flex tw-items-center tw-justify-center tw-bg-secondary-100 tw-rounded-lg"
|
||||
>
|
||||
<bit-svg
|
||||
[content]="icon()"
|
||||
class="tw-size-16 xl:tw-size-24 tw-text-muted"
|
||||
aria-hidden="true"
|
||||
></bit-svg>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex lg:tw-hidden tw-w-full tw-justify-center" *ngIf="videoSrc() || icon()">
|
||||
<div class="tw-size-48 sm:tw-size-64 tw-relative">
|
||||
@if (videoSrc()) {
|
||||
<video
|
||||
class="tw-size-full tw-rounded-lg"
|
||||
[src]="videoSrc()"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
aria-hidden="true"
|
||||
></video>
|
||||
} @else if (icon()) {
|
||||
<div
|
||||
class="tw-size-full tw-flex tw-items-center tw-justify-center tw-bg-secondary-100 tw-rounded-lg"
|
||||
>
|
||||
<bit-svg
|
||||
[content]="icon()"
|
||||
class="tw-size-12 sm:tw-size-16 tw-text-muted"
|
||||
<div class="tw-flex lg:tw-hidden tw-w-full tw-justify-center">
|
||||
<div class="tw-size-48 sm:tw-size-64 tw-relative">
|
||||
@if (videoSrc()) {
|
||||
<video
|
||||
class="tw-size-full tw-rounded-lg"
|
||||
[src]="videoSrc()"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
aria-hidden="true"
|
||||
></bit-svg>
|
||||
</div>
|
||||
}
|
||||
></video>
|
||||
} @else if (icon()) {
|
||||
<div
|
||||
class="tw-size-full tw-flex tw-items-center tw-justify-center tw-bg-secondary-100 tw-rounded-lg"
|
||||
>
|
||||
<bit-svg
|
||||
[content]="icon()"
|
||||
class="tw-size-12 sm:tw-size-16 tw-text-muted"
|
||||
aria-hidden="true"
|
||||
></bit-svg>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, input, isDevMode, OnInit } from "@angular/core";
|
||||
|
||||
import { BitSvg } from "@bitwarden/assets/svg";
|
||||
@@ -7,7 +6,7 @@ import { ButtonModule, SvgModule } from "@bitwarden/components";
|
||||
@Component({
|
||||
selector: "empty-state-card",
|
||||
templateUrl: "./empty-state-card.component.html",
|
||||
imports: [CommonModule, SvgModule, ButtonModule],
|
||||
imports: [SvgModule, ButtonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EmptyStateCardComponent implements OnInit {
|
||||
|
||||
@@ -44,10 +44,11 @@
|
||||
<!-- Show screen when there is report data OR when feature flag is disabled (show tabs even without data) -->
|
||||
<div @fadeIn class="tw-min-h-screen tw-flex tw-flex-col">
|
||||
<div>
|
||||
<div class="tw-text-main tw-max-w-4xl tw-mb-2" *ngIf="appsCount > 0">
|
||||
{{ "reviewAtRiskPasswords" | i18n }}
|
||||
</div>
|
||||
@let isRunningReport = dataService.isGeneratingReport$ | async;
|
||||
@if (appsCount > 0) {
|
||||
<div class="tw-text-main tw-max-w-4xl tw-mb-2">
|
||||
{{ "reviewAtRiskPasswords" | i18n }}
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
class="tw-bg-primary-100 tw-rounded-lg tw-w-full tw-px-8 tw-py-4 tw-my-4 tw-flex tw-items-center"
|
||||
>
|
||||
@@ -62,7 +63,6 @@
|
||||
}
|
||||
<span class="tw-flex tw-justify-center">
|
||||
<button
|
||||
*ngIf="!isRunningReport"
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
@@ -72,13 +72,6 @@
|
||||
>
|
||||
{{ "riskInsightsRunReport" | i18n }}
|
||||
</button>
|
||||
<span>
|
||||
<i
|
||||
*ngIf="isRunningReport"
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted tw-text-[1.2rem]"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,6 +81,11 @@
|
||||
<bit-tab label="{{ 'activity' | i18n }}">
|
||||
<dirt-all-activity [organizationId]="this.organizationId"></dirt-all-activity>
|
||||
</bit-tab>
|
||||
@if (milestone11Enabled) {
|
||||
<bit-tab label="{{ 'applications' | i18n }}">
|
||||
<dirt-applications></dirt-applications>
|
||||
</bit-tab>
|
||||
}
|
||||
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: appsCount }}">
|
||||
<dirt-all-applications></dirt-all-applications>
|
||||
</bit-tab>
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
ReportStatus,
|
||||
RiskInsightsDataService,
|
||||
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
|
||||
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";
|
||||
@@ -38,6 +40,7 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod
|
||||
|
||||
import { AllActivityComponent } from "./activity/all-activity.component";
|
||||
import { AllApplicationsComponent } from "./all-applications/all-applications.component";
|
||||
import { ApplicationsComponent } from "./all-applications/applications.component";
|
||||
import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component";
|
||||
import { EmptyStateCardComponent } from "./empty-state-card.component";
|
||||
import { RiskInsightsTabType } from "./models/risk-insights.models";
|
||||
@@ -53,6 +56,7 @@ type ProgressStep = ReportProgress | null;
|
||||
templateUrl: "./risk-insights.component.html",
|
||||
imports: [
|
||||
AllApplicationsComponent,
|
||||
ApplicationsComponent,
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
CommonModule,
|
||||
@@ -77,6 +81,7 @@ type ProgressStep = ReportProgress | null;
|
||||
export class RiskInsightsComponent implements OnInit, OnDestroy {
|
||||
private destroyRef = inject(DestroyRef);
|
||||
protected ReportStatusEnum = ReportStatus;
|
||||
protected milestone11Enabled: boolean = false;
|
||||
|
||||
tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllActivity;
|
||||
|
||||
@@ -114,6 +119,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
|
||||
protected dialogService: DialogService,
|
||||
private fileDownloadService: FileDownloadService,
|
||||
private logService: LogService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ tabIndex }) => {
|
||||
this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllActivity;
|
||||
@@ -121,6 +127,10 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.milestone11Enabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.Milestone11AppPageImprovements,
|
||||
);
|
||||
|
||||
this.route.paramMap
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
|
||||
@@ -12,28 +12,32 @@
|
||||
<th bitSortable="memberCount" bitCell tabindex="0">{{ "totalMembers" | i18n }}</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td
|
||||
bitCell
|
||||
*ngIf="showRowCheckBox"
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
appStopProp
|
||||
>
|
||||
<input
|
||||
bitCheckbox
|
||||
type="checkbox"
|
||||
*ngIf="!row.isMarkedAsCritical"
|
||||
[checked]="selectedUrls.has(row.applicationName)"
|
||||
(change)="checkboxChange(row.applicationName, $event)"
|
||||
/>
|
||||
<i class="bwi bwi-star-f" *ngIf="row.isMarkedAsCritical"></i>
|
||||
</td>
|
||||
<td
|
||||
bitCell
|
||||
*ngIf="!showRowCheckBox"
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
>
|
||||
<i class="bwi bwi-star-f" *ngIf="row.isMarkedAsCritical"></i>
|
||||
</td>
|
||||
@if (showRowCheckBox) {
|
||||
<td
|
||||
bitCell
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
appStopProp
|
||||
>
|
||||
@if (!row.isMarkedAsCritical) {
|
||||
<input
|
||||
bitCheckbox
|
||||
type="checkbox"
|
||||
[checked]="selectedUrls.has(row.applicationName)"
|
||||
(change)="checkboxChange(row.applicationName, $event)"
|
||||
/>
|
||||
}
|
||||
@if (row.isMarkedAsCritical) {
|
||||
<i class="bwi bwi-star-f"></i>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
@if (!showRowCheckBox) {
|
||||
<td bitCell [ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }">
|
||||
@if (row.isMarkedAsCritical) {
|
||||
<i class="bwi bwi-star-f"></i>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td
|
||||
bitCell
|
||||
class="tw-cursor-pointer"
|
||||
@@ -45,11 +49,9 @@
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
>
|
||||
<app-vault-icon
|
||||
*ngIf="row.iconCipher"
|
||||
[cipher]="row.iconCipher"
|
||||
[size]="24"
|
||||
></app-vault-icon>
|
||||
@if (row.iconCipher) {
|
||||
<app-vault-icon [cipher]="row.iconCipher" [size]="24"></app-vault-icon>
|
||||
}
|
||||
</td>
|
||||
<td
|
||||
class="tw-cursor-pointer"
|
||||
@@ -122,27 +124,27 @@
|
||||
>
|
||||
{{ row.memberCount }}
|
||||
</td>
|
||||
<td
|
||||
bitCell
|
||||
*ngIf="showRowMenuForCriticalApps"
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
appStopProp
|
||||
>
|
||||
<button
|
||||
[bitMenuTriggerFor]="rowMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
label="{{ 'options' | i18n }}"
|
||||
tabindex="0"
|
||||
></button>
|
||||
|
||||
<bit-menu #rowMenu>
|
||||
<button type="button" bitMenuItem (click)="unmarkAsCritical(row.applicationName)">
|
||||
{{ "unmarkAsCritical" | i18n }}
|
||||
</button>
|
||||
</bit-menu>
|
||||
</td>
|
||||
@if (showRowMenuForCriticalApps) {
|
||||
<td
|
||||
bitCell
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
appStopProp
|
||||
>
|
||||
<button
|
||||
[bitMenuTriggerFor]="rowMenu"
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
label="{{ 'options' | i18n }}"
|
||||
tabindex="0"
|
||||
></button>
|
||||
<bit-menu #rowMenu>
|
||||
<button type="button" bitMenuItem (click)="unmarkAsCritical(row.applicationName)">
|
||||
{{ "unmarkAsCritical" | i18n }}
|
||||
</button>
|
||||
</bit-menu>
|
||||
</td>
|
||||
}
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, input } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
@@ -19,7 +18,7 @@ const ProgressStepConfig = Object.freeze({
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "dirt-report-loading",
|
||||
imports: [CommonModule, JslibModule, ProgressModule],
|
||||
imports: [JslibModule, ProgressModule],
|
||||
templateUrl: "./report-loading.component.html",
|
||||
})
|
||||
export class ReportLoadingComponent {
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
<ul
|
||||
class="tw-inline-grid tw-grid-cols-3 tw-gap-6 tw-m-0 tw-p-0 tw-w-full tw-auto-cols-auto tw-list-none lg:tw-grid-cols-4 lg:tw-gap-10 lg:tw-w-auto"
|
||||
>
|
||||
<li
|
||||
*ngFor="let integration of integrations"
|
||||
[title]="tooltipI18nKey | i18n: integration.name"
|
||||
[attr.aria-label]="ariaI18nKey | i18n: integration.name"
|
||||
>
|
||||
<app-integration-card
|
||||
[name]="integration.name"
|
||||
[linkURL]="integration.linkURL"
|
||||
[image]="integration.image"
|
||||
[imageDarkMode]="integration.imageDarkMode"
|
||||
[externalURL]="integration.type === IntegrationType.SDK"
|
||||
[newBadgeExpiration]="integration.newBadgeExpiration"
|
||||
[description]="integration.description | i18n"
|
||||
[canSetupConnection]="integration.canSetupConnection"
|
||||
[integrationSettings]="integration"
|
||||
></app-integration-card>
|
||||
</li>
|
||||
@for (integration of integrations; track integration) {
|
||||
<li
|
||||
[title]="tooltipI18nKey | i18n: integration.name"
|
||||
[attr.aria-label]="ariaI18nKey | i18n: integration.name"
|
||||
>
|
||||
<app-integration-card
|
||||
[name]="integration.name"
|
||||
[linkURL]="integration.linkURL"
|
||||
[image]="integration.image"
|
||||
[imageDarkMode]="integration.imageDarkMode"
|
||||
[externalURL]="integration.type === IntegrationType.SDK"
|
||||
[newBadgeExpiration]="integration.newBadgeExpiration"
|
||||
[description]="integration.description | i18n"
|
||||
[canSetupConnection]="integration.canSetupConnection"
|
||||
[integrationSettings]="integration"
|
||||
></app-integration-card>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@@ -24,28 +24,32 @@
|
||||
|
||||
@if (organization?.useScim || organization?.useDirectory) {
|
||||
<bit-tab [label]="'userProvisioning' | i18n">
|
||||
<section class="tw-mb-9" *ngIf="organization?.useScim">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "scimIntegration" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">
|
||||
{{ "scimIntegrationDescStart" | i18n }}
|
||||
<a bitLink routerLink="../settings/scim">{{ "scimIntegration" | i18n }}</a>
|
||||
{{ "scimIntegrationDescEnd" | i18n }}
|
||||
</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.SCIM"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
<section class="tw-mb-9" *ngIf="organization?.useDirectory">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "bwdc" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "bwdcDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.BWDC"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
@if (organization?.useScim) {
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "scimIntegration" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">
|
||||
{{ "scimIntegrationDescStart" | i18n }}
|
||||
<a bitLink routerLink="../settings/scim">{{ "scimIntegration" | i18n }}</a>
|
||||
{{ "scimIntegrationDescEnd" | i18n }}
|
||||
</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.SCIM"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
}
|
||||
@if (organization?.useDirectory) {
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "bwdc" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "bwdcDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.BWDC"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
}
|
||||
</bit-tab>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
<app-header>
|
||||
<bit-search
|
||||
[formControl]="searchControl"
|
||||
[placeholder]="'searchMembers' | i18n"
|
||||
class="tw-grow"
|
||||
*ngIf="!(isLoading$ | async)"
|
||||
></bit-search>
|
||||
@let isLoading = isLoading$ | async;
|
||||
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
[bitAction]="exportReportAction"
|
||||
*ngIf="!(isLoading$ | async)"
|
||||
>
|
||||
<span>{{ "export" | i18n }}</span>
|
||||
<i class="bwi bwi-fw bwi-sign-in" aria-hidden="true"></i>
|
||||
</button>
|
||||
@if (!isLoading) {
|
||||
<bit-search
|
||||
[formControl]="searchControl"
|
||||
[placeholder]="'searchMembers' | i18n"
|
||||
class="tw-grow"
|
||||
></bit-search>
|
||||
<button type="button" bitButton buttonType="primary" [bitAction]="exportReportAction">
|
||||
<span>{{ "export" | i18n }}</span>
|
||||
<i class="bwi bwi-fw bwi-sign-in" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</app-header>
|
||||
|
||||
<div class="tw-max-w-4xl">
|
||||
@@ -24,7 +20,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="isLoading$ | async">
|
||||
@if (isLoading) {
|
||||
<div class="tw-flex-col tw-flex tw-justify-center tw-items-center tw-gap-5 tw-mt-4">
|
||||
<i
|
||||
class="bwi bwi-2x bwi-spinner bwi-spin tw-text-primary-600"
|
||||
@@ -33,31 +29,33 @@
|
||||
></i>
|
||||
<h2 bitTypography="h1">{{ "loading" | i18n }}</h2>
|
||||
</div>
|
||||
</ng-container>
|
||||
<bit-table-scroll *ngIf="!(isLoading$ | async)" [dataSource]="dataSource" [rowSize]="53">
|
||||
<ng-container header>
|
||||
<th bitCell bitSortable="email" default>{{ "members" | i18n }}</th>
|
||||
<th bitCell bitSortable="groupsCount" class="tw-w-[278px]">{{ "groups" | i18n }}</th>
|
||||
<th bitCell bitSortable="collectionsCount" class="tw-w-[278px]">{{ "collections" | i18n }}</th>
|
||||
<th bitCell bitSortable="itemsCount" class="tw-w-[278px]">{{ "items" | i18n }}</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-avatar size="small" [text]="row.name" class="tw-mr-3"></bit-avatar>
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<button type="button" bitLink (click)="edit(row)">
|
||||
{{ row.name }}
|
||||
</button>
|
||||
|
||||
<div class="tw-text-sm tw-mt-1 tw-text-muted">
|
||||
{{ row.email }}
|
||||
} @else {
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53">
|
||||
<ng-container header>
|
||||
<th bitCell bitSortable="email" default>{{ "members" | i18n }}</th>
|
||||
<th bitCell bitSortable="groupsCount" class="tw-w-[278px]">{{ "groups" | i18n }}</th>
|
||||
<th bitCell bitSortable="collectionsCount" class="tw-w-[278px]">
|
||||
{{ "collections" | i18n }}
|
||||
</th>
|
||||
<th bitCell bitSortable="itemsCount" class="tw-w-[278px]">{{ "items" | i18n }}</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-avatar size="small" [text]="row.name" class="tw-mr-3"></bit-avatar>
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<button type="button" bitLink (click)="edit(row)">
|
||||
{{ row.name }}
|
||||
</button>
|
||||
<div class="tw-text-sm tw-mt-1 tw-text-muted">
|
||||
{{ row.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td bitCell class="tw-text-muted tw-w-[278px]">{{ row.groupsCount }}</td>
|
||||
<td bitCell class="tw-text-muted tw-w-[278px]">{{ row.collectionsCount }}</td>
|
||||
<td bitCell class="tw-text-muted tw-w-[278px]">{{ row.itemsCount }}</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</td>
|
||||
<td bitCell class="tw-text-muted tw-w-[278px]">{{ row.groupsCount }}</td>
|
||||
<td bitCell class="tw-text-muted tw-w-[278px]">{{ row.collectionsCount }}</td>
|
||||
<td bitCell class="tw-text-muted tw-w-[278px]">{{ row.itemsCount }}</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
}
|
||||
|
||||
@@ -858,6 +858,8 @@ const safeProviders: SafeProvider[] = [
|
||||
KeyGenerationService,
|
||||
SendStateProviderAbstraction,
|
||||
EncryptService,
|
||||
CryptoFunctionServiceAbstraction,
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
@@ -59,6 +59,7 @@ export enum FeatureFlag {
|
||||
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
|
||||
EventManagementForHuntress = "event-management-for-huntress",
|
||||
PhishingDetection = "phishing-detection",
|
||||
Milestone11AppPageImprovements = "pm-30538-dirt-milestone-11-app-page-improvements",
|
||||
|
||||
/* Vault */
|
||||
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
|
||||
@@ -121,6 +122,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
|
||||
[FeatureFlag.EventManagementForHuntress]: FALSE,
|
||||
[FeatureFlag.PhishingDetection]: FALSE,
|
||||
[FeatureFlag.Milestone11AppPageImprovements]: FALSE,
|
||||
|
||||
/* Vault */
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
|
||||
@@ -11,7 +11,6 @@ export class SendData {
|
||||
id: string;
|
||||
accessId: string;
|
||||
type: SendType;
|
||||
authType: AuthType;
|
||||
name: string;
|
||||
notes: string;
|
||||
file: SendFileData;
|
||||
@@ -24,8 +23,10 @@ export class SendData {
|
||||
deletionDate: string;
|
||||
password: string;
|
||||
emails: string;
|
||||
emailHashes: string;
|
||||
disabled: boolean;
|
||||
hideEmail: boolean;
|
||||
authType: AuthType;
|
||||
|
||||
constructor(response?: SendResponse) {
|
||||
if (response == null) {
|
||||
@@ -46,8 +47,10 @@ export class SendData {
|
||||
this.deletionDate = response.deletionDate;
|
||||
this.password = response.password;
|
||||
this.emails = response.emails;
|
||||
this.emailHashes = "";
|
||||
this.disabled = response.disable;
|
||||
this.hideEmail = response.hideEmail;
|
||||
this.authType = response.authType;
|
||||
|
||||
switch (this.type) {
|
||||
case SendType.Text:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
|
||||
import { emptyGuid, UserId } from "@bitwarden/common/types/guid";
|
||||
// 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
|
||||
@@ -15,7 +16,6 @@ import { AuthType } from "../../types/auth-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendData } from "../data/send.data";
|
||||
|
||||
import { Send } from "./send";
|
||||
import { SendText } from "./send-text";
|
||||
|
||||
describe("Send", () => {
|
||||
@@ -26,7 +26,6 @@ describe("Send", () => {
|
||||
id: "id",
|
||||
accessId: "accessId",
|
||||
type: SendType.Text,
|
||||
authType: AuthType.None,
|
||||
name: "encName",
|
||||
notes: "encNotes",
|
||||
text: {
|
||||
@@ -41,9 +40,11 @@ describe("Send", () => {
|
||||
expirationDate: "2022-01-31T12:00:00.000Z",
|
||||
deletionDate: "2022-01-31T12:00:00.000Z",
|
||||
password: "password",
|
||||
emails: null!,
|
||||
emails: "",
|
||||
emailHashes: "",
|
||||
disabled: false,
|
||||
hideEmail: true,
|
||||
authType: AuthType.None,
|
||||
};
|
||||
|
||||
mockContainerService();
|
||||
@@ -69,6 +70,8 @@ describe("Send", () => {
|
||||
expirationDate: null,
|
||||
deletionDate: null,
|
||||
password: undefined,
|
||||
emails: null,
|
||||
emailHashes: undefined,
|
||||
disabled: undefined,
|
||||
hideEmail: undefined,
|
||||
});
|
||||
@@ -81,7 +84,6 @@ describe("Send", () => {
|
||||
id: "id",
|
||||
accessId: "accessId",
|
||||
type: SendType.Text,
|
||||
authType: AuthType.None,
|
||||
name: { encryptedString: "encName", encryptionType: 0 },
|
||||
notes: { encryptedString: "encNotes", encryptionType: 0 },
|
||||
text: {
|
||||
@@ -95,9 +97,11 @@ describe("Send", () => {
|
||||
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
|
||||
deletionDate: new Date("2022-01-31T12:00:00.000Z"),
|
||||
password: "password",
|
||||
emails: null!,
|
||||
emails: null,
|
||||
emailHashes: "",
|
||||
disabled: false,
|
||||
hideEmail: true,
|
||||
authType: AuthType.None,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,14 +125,22 @@ describe("Send", () => {
|
||||
send.expirationDate = new Date("2022-01-31T12:00:00.000Z");
|
||||
send.deletionDate = new Date("2022-01-31T12:00:00.000Z");
|
||||
send.password = "password";
|
||||
send.emails = null;
|
||||
send.disabled = false;
|
||||
send.hideEmail = true;
|
||||
send.authType = AuthType.None;
|
||||
|
||||
const encryptService = mock<EncryptService>();
|
||||
const keyService = mock<KeyService>();
|
||||
encryptService.decryptBytes
|
||||
.calledWith(send.key, userKey)
|
||||
.mockResolvedValue(makeStaticByteArray(32));
|
||||
encryptService.decryptString
|
||||
.calledWith(send.name, "cryptoKey" as any)
|
||||
.mockResolvedValue("name");
|
||||
encryptService.decryptString
|
||||
.calledWith(send.notes, "cryptoKey" as any)
|
||||
.mockResolvedValue("notes");
|
||||
keyService.makeSendKey.mockResolvedValue("cryptoKey" as any);
|
||||
keyService.userKey$.calledWith(userId).mockReturnValue(of(userKey));
|
||||
|
||||
@@ -137,12 +149,6 @@ describe("Send", () => {
|
||||
const view = await send.decrypt(userId);
|
||||
|
||||
expect(text.decrypt).toHaveBeenNthCalledWith(1, "cryptoKey");
|
||||
expect(send.name.decrypt).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
null,
|
||||
"cryptoKey",
|
||||
"Property: name; ObjectContext: No Domain Context",
|
||||
);
|
||||
|
||||
expect(view).toMatchObject({
|
||||
id: "id",
|
||||
@@ -150,7 +156,6 @@ describe("Send", () => {
|
||||
name: "name",
|
||||
notes: "notes",
|
||||
type: 0,
|
||||
authType: 2,
|
||||
key: expect.anything(),
|
||||
cryptoKey: "cryptoKey",
|
||||
file: expect.anything(),
|
||||
@@ -161,8 +166,265 @@ describe("Send", () => {
|
||||
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
|
||||
deletionDate: new Date("2022-01-31T12:00:00.000Z"),
|
||||
password: "password",
|
||||
emails: [],
|
||||
disabled: false,
|
||||
hideEmail: true,
|
||||
authType: AuthType.None,
|
||||
});
|
||||
});
|
||||
|
||||
describe("Email decryption", () => {
|
||||
let encryptService: jest.Mocked<EncryptService>;
|
||||
let keyService: jest.Mocked<KeyService>;
|
||||
const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
const userId = emptyGuid as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
encryptService = mock<EncryptService>();
|
||||
keyService = mock<KeyService>();
|
||||
encryptService.decryptBytes.mockResolvedValue(makeStaticByteArray(32));
|
||||
keyService.makeSendKey.mockResolvedValue("cryptoKey" as any);
|
||||
keyService.userKey$.mockReturnValue(of(userKey));
|
||||
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
|
||||
});
|
||||
|
||||
it("should decrypt and parse single email", async () => {
|
||||
const send = new Send();
|
||||
send.id = "id";
|
||||
send.type = SendType.Text;
|
||||
send.name = mockEnc("name");
|
||||
send.notes = mockEnc("notes");
|
||||
send.key = mockEnc("key");
|
||||
send.emails = mockEnc("test@example.com");
|
||||
send.text = mock<SendText>();
|
||||
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
|
||||
|
||||
encryptService.decryptString.mockImplementation((encString, key) => {
|
||||
if (encString === send.emails) {
|
||||
return Promise.resolve("test@example.com");
|
||||
}
|
||||
if (encString === send.name) {
|
||||
return Promise.resolve("name");
|
||||
}
|
||||
if (encString === send.notes) {
|
||||
return Promise.resolve("notes");
|
||||
}
|
||||
return Promise.resolve("");
|
||||
});
|
||||
|
||||
const view = await send.decrypt(userId);
|
||||
|
||||
expect(encryptService.decryptString).toHaveBeenCalledWith(send.emails, "cryptoKey");
|
||||
expect(view.emails).toEqual(["test@example.com"]);
|
||||
});
|
||||
|
||||
it("should decrypt and parse multiple emails", async () => {
|
||||
const send = new Send();
|
||||
send.id = "id";
|
||||
send.type = SendType.Text;
|
||||
send.name = mockEnc("name");
|
||||
send.notes = mockEnc("notes");
|
||||
send.key = mockEnc("key");
|
||||
send.emails = mockEnc("test@example.com,user@test.com,admin@domain.com");
|
||||
send.text = mock<SendText>();
|
||||
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
|
||||
|
||||
encryptService.decryptString.mockImplementation((encString, key) => {
|
||||
if (encString === send.emails) {
|
||||
return Promise.resolve("test@example.com,user@test.com,admin@domain.com");
|
||||
}
|
||||
if (encString === send.name) {
|
||||
return Promise.resolve("name");
|
||||
}
|
||||
if (encString === send.notes) {
|
||||
return Promise.resolve("notes");
|
||||
}
|
||||
return Promise.resolve("");
|
||||
});
|
||||
|
||||
const view = await send.decrypt(userId);
|
||||
|
||||
expect(view.emails).toEqual(["test@example.com", "user@test.com", "admin@domain.com"]);
|
||||
});
|
||||
|
||||
it("should trim whitespace from decrypted emails", async () => {
|
||||
const send = new Send();
|
||||
send.id = "id";
|
||||
send.type = SendType.Text;
|
||||
send.name = mockEnc("name");
|
||||
send.notes = mockEnc("notes");
|
||||
send.key = mockEnc("key");
|
||||
send.emails = mockEnc(" test@example.com , user@test.com ");
|
||||
send.text = mock<SendText>();
|
||||
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
|
||||
|
||||
encryptService.decryptString.mockImplementation((encString, key) => {
|
||||
if (encString === send.emails) {
|
||||
return Promise.resolve(" test@example.com , user@test.com ");
|
||||
}
|
||||
if (encString === send.name) {
|
||||
return Promise.resolve("name");
|
||||
}
|
||||
if (encString === send.notes) {
|
||||
return Promise.resolve("notes");
|
||||
}
|
||||
return Promise.resolve("");
|
||||
});
|
||||
|
||||
const view = await send.decrypt(userId);
|
||||
|
||||
expect(view.emails).toEqual(["test@example.com", "user@test.com"]);
|
||||
});
|
||||
|
||||
it("should return empty array when emails is null", async () => {
|
||||
const send = new Send();
|
||||
send.id = "id";
|
||||
send.type = SendType.Text;
|
||||
send.name = mockEnc("name");
|
||||
send.notes = mockEnc("notes");
|
||||
send.key = mockEnc("key");
|
||||
send.emails = null;
|
||||
send.text = mock<SendText>();
|
||||
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
|
||||
|
||||
const view = await send.decrypt(userId);
|
||||
|
||||
expect(view.emails).toEqual([]);
|
||||
expect(encryptService.decryptString).not.toHaveBeenCalledWith(expect.anything(), "cryptoKey");
|
||||
});
|
||||
|
||||
it("should return empty array when decrypted emails is empty string", async () => {
|
||||
const send = new Send();
|
||||
send.id = "id";
|
||||
send.type = SendType.Text;
|
||||
send.name = mockEnc("name");
|
||||
send.notes = mockEnc("notes");
|
||||
send.key = mockEnc("key");
|
||||
send.emails = mockEnc("");
|
||||
send.text = mock<SendText>();
|
||||
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
|
||||
|
||||
encryptService.decryptString.mockImplementation((encString, key) => {
|
||||
if (encString === send.emails) {
|
||||
return Promise.resolve("");
|
||||
}
|
||||
if (encString === send.name) {
|
||||
return Promise.resolve("name");
|
||||
}
|
||||
if (encString === send.notes) {
|
||||
return Promise.resolve("notes");
|
||||
}
|
||||
return Promise.resolve("");
|
||||
});
|
||||
|
||||
const view = await send.decrypt(userId);
|
||||
|
||||
expect(view.emails).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array when decrypted emails is null", async () => {
|
||||
const send = new Send();
|
||||
send.id = "id";
|
||||
send.type = SendType.Text;
|
||||
send.name = mockEnc("name");
|
||||
send.notes = mockEnc("notes");
|
||||
send.key = mockEnc("key");
|
||||
send.emails = mockEnc("something");
|
||||
send.text = mock<SendText>();
|
||||
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
|
||||
|
||||
encryptService.decryptString.mockImplementation((encString, key) => {
|
||||
if (encString === send.emails) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
if (encString === send.name) {
|
||||
return Promise.resolve("name");
|
||||
}
|
||||
if (encString === send.notes) {
|
||||
return Promise.resolve("notes");
|
||||
}
|
||||
return Promise.resolve("");
|
||||
});
|
||||
|
||||
const view = await send.decrypt(userId);
|
||||
|
||||
expect(view.emails).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Null handling for name and notes decryption", () => {
|
||||
let encryptService: jest.Mocked<EncryptService>;
|
||||
let keyService: jest.Mocked<KeyService>;
|
||||
const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
const userId = emptyGuid as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
encryptService = mock<EncryptService>();
|
||||
keyService = mock<KeyService>();
|
||||
encryptService.decryptBytes.mockResolvedValue(makeStaticByteArray(32));
|
||||
keyService.makeSendKey.mockResolvedValue("cryptoKey" as any);
|
||||
keyService.userKey$.mockReturnValue(of(userKey));
|
||||
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
|
||||
});
|
||||
|
||||
it("should return null for name when name is null", async () => {
|
||||
const send = new Send();
|
||||
send.id = "id";
|
||||
send.type = SendType.Text;
|
||||
send.name = null;
|
||||
send.notes = mockEnc("notes");
|
||||
send.key = mockEnc("key");
|
||||
send.emails = null;
|
||||
send.text = mock<SendText>();
|
||||
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
|
||||
|
||||
const view = await send.decrypt(userId);
|
||||
|
||||
expect(view.name).toBeNull();
|
||||
expect(encryptService.decryptString).not.toHaveBeenCalledWith(null, expect.anything());
|
||||
});
|
||||
|
||||
it("should return null for notes when notes is null", async () => {
|
||||
const send = new Send();
|
||||
send.id = "id";
|
||||
send.type = SendType.Text;
|
||||
send.name = mockEnc("name");
|
||||
send.notes = null;
|
||||
send.key = mockEnc("key");
|
||||
send.emails = null;
|
||||
send.text = mock<SendText>();
|
||||
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
|
||||
|
||||
const view = await send.decrypt(userId);
|
||||
|
||||
expect(view.notes).toBeNull();
|
||||
});
|
||||
|
||||
it("should decrypt non-null name and notes", async () => {
|
||||
const send = new Send();
|
||||
send.id = "id";
|
||||
send.type = SendType.Text;
|
||||
send.name = mockEnc("Test Name");
|
||||
send.notes = mockEnc("Test Notes");
|
||||
send.key = mockEnc("key");
|
||||
send.emails = null;
|
||||
send.text = mock<SendText>();
|
||||
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
|
||||
|
||||
encryptService.decryptString.mockImplementation((encString, key) => {
|
||||
if (encString === send.name) {
|
||||
return Promise.resolve("Test Name");
|
||||
}
|
||||
if (encString === send.notes) {
|
||||
return Promise.resolve("Test Notes");
|
||||
}
|
||||
return Promise.resolve("");
|
||||
});
|
||||
|
||||
const view = await send.decrypt(userId);
|
||||
|
||||
expect(view.name).toBe("Test Name");
|
||||
expect(view.notes).toBe("Test Notes");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ export class Send extends Domain {
|
||||
id: string;
|
||||
accessId: string;
|
||||
type: SendType;
|
||||
authType: AuthType;
|
||||
name: EncString;
|
||||
notes: EncString;
|
||||
file: SendFile;
|
||||
@@ -32,9 +31,11 @@ export class Send extends Domain {
|
||||
expirationDate: Date;
|
||||
deletionDate: Date;
|
||||
password: string;
|
||||
emails: string;
|
||||
emails: EncString;
|
||||
emailHashes: string;
|
||||
disabled: boolean;
|
||||
hideEmail: boolean;
|
||||
authType: AuthType;
|
||||
|
||||
constructor(obj?: SendData) {
|
||||
super();
|
||||
@@ -51,6 +52,7 @@ export class Send extends Domain {
|
||||
name: null,
|
||||
notes: null,
|
||||
key: null,
|
||||
emails: null,
|
||||
},
|
||||
["id", "accessId"],
|
||||
);
|
||||
@@ -60,12 +62,13 @@ export class Send extends Domain {
|
||||
this.maxAccessCount = obj.maxAccessCount;
|
||||
this.accessCount = obj.accessCount;
|
||||
this.password = obj.password;
|
||||
this.emails = obj.emails;
|
||||
this.emailHashes = obj.emailHashes;
|
||||
this.disabled = obj.disabled;
|
||||
this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null;
|
||||
this.deletionDate = obj.deletionDate != null ? new Date(obj.deletionDate) : null;
|
||||
this.expirationDate = obj.expirationDate != null ? new Date(obj.expirationDate) : null;
|
||||
this.hideEmail = obj.hideEmail;
|
||||
this.authType = obj.authType;
|
||||
|
||||
switch (this.type) {
|
||||
case SendType.Text:
|
||||
@@ -91,8 +94,17 @@ export class Send extends Domain {
|
||||
// model.key is a seed used to derive a key, not a SymmetricCryptoKey
|
||||
model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey);
|
||||
model.cryptoKey = await keyService.makeSendKey(model.key);
|
||||
model.name =
|
||||
this.name != null ? await encryptService.decryptString(this.name, model.cryptoKey) : null;
|
||||
model.notes =
|
||||
this.notes != null ? await encryptService.decryptString(this.notes, model.cryptoKey) : null;
|
||||
|
||||
await this.decryptObj<Send, SendView>(this, model, ["name", "notes"], model.cryptoKey);
|
||||
if (this.emails != null) {
|
||||
const decryptedEmails = await encryptService.decryptString(this.emails, model.cryptoKey);
|
||||
model.emails = decryptedEmails ? decryptedEmails.split(",").map((e) => e.trim()) : [];
|
||||
} else {
|
||||
model.emails = [];
|
||||
}
|
||||
|
||||
switch (this.type) {
|
||||
case SendType.File:
|
||||
@@ -121,6 +133,7 @@ export class Send extends Domain {
|
||||
key: EncString.fromJSON(obj.key),
|
||||
name: EncString.fromJSON(obj.name),
|
||||
notes: EncString.fromJSON(obj.notes),
|
||||
emails: EncString.fromJSON(obj.emails),
|
||||
text: SendText.fromJSON(obj.text),
|
||||
file: SendFile.fromJSON(obj.file),
|
||||
revisionDate,
|
||||
|
||||
192
libs/common/src/tools/send/models/request/send.request.spec.ts
Normal file
192
libs/common/src/tools/send/models/request/send.request.spec.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
|
||||
|
||||
import { EncString } from "../../../../key-management/crypto/models/enc-string";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendText } from "../domain/send-text";
|
||||
|
||||
import { SendRequest } from "./send.request";
|
||||
|
||||
describe("SendRequest", () => {
|
||||
describe("constructor", () => {
|
||||
it("should populate emails with encrypted string from Send.emails", () => {
|
||||
const send = new Send();
|
||||
send.type = SendType.Text;
|
||||
send.name = new EncString("encryptedName");
|
||||
send.notes = new EncString("encryptedNotes");
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = new EncString("encryptedEmailList");
|
||||
send.emailHashes = "HASH1,HASH2,HASH3";
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
send.text.text = new EncString("text");
|
||||
send.text.hidden = false;
|
||||
|
||||
const request = new SendRequest(send);
|
||||
|
||||
expect(request.emails).toBe("encryptedEmailList");
|
||||
});
|
||||
|
||||
it("should populate emailHashes from Send.emailHashes", () => {
|
||||
const send = new Send();
|
||||
send.type = SendType.Text;
|
||||
send.name = new EncString("encryptedName");
|
||||
send.notes = new EncString("encryptedNotes");
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = new EncString("encryptedEmailList");
|
||||
send.emailHashes = "HASH1,HASH2,HASH3";
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
send.text.text = new EncString("text");
|
||||
send.text.hidden = false;
|
||||
|
||||
const request = new SendRequest(send);
|
||||
|
||||
expect(request.emailHashes).toBe("HASH1,HASH2,HASH3");
|
||||
});
|
||||
|
||||
it("should set emails to null when Send.emails is null", () => {
|
||||
const send = new Send();
|
||||
send.type = SendType.Text;
|
||||
send.name = new EncString("encryptedName");
|
||||
send.notes = new EncString("encryptedNotes");
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = null;
|
||||
send.emailHashes = "";
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
send.text.text = new EncString("text");
|
||||
send.text.hidden = false;
|
||||
|
||||
const request = new SendRequest(send);
|
||||
|
||||
expect(request.emails).toBeNull();
|
||||
expect(request.emailHashes).toBe("");
|
||||
});
|
||||
|
||||
it("should handle empty emailHashes", () => {
|
||||
const send = new Send();
|
||||
send.type = SendType.Text;
|
||||
send.name = new EncString("encryptedName");
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = null;
|
||||
send.emailHashes = "";
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
send.text.text = new EncString("text");
|
||||
send.text.hidden = false;
|
||||
|
||||
const request = new SendRequest(send);
|
||||
|
||||
expect(request.emailHashes).toBe("");
|
||||
});
|
||||
|
||||
it("should not expose plaintext emails", () => {
|
||||
const send = new Send();
|
||||
send.type = SendType.Text;
|
||||
send.name = new EncString("encryptedName");
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = new EncString("2.encrypted|emaildata|here");
|
||||
send.emailHashes = "ABC123,DEF456";
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
send.text.text = new EncString("text");
|
||||
send.text.hidden = false;
|
||||
|
||||
const request = new SendRequest(send);
|
||||
|
||||
// Ensure the request contains the encrypted string format, not plaintext
|
||||
expect(request.emails).toBe("2.encrypted|emaildata|here");
|
||||
expect(request.emails).not.toContain("@");
|
||||
});
|
||||
|
||||
it("should handle name being null", () => {
|
||||
const send = new Send();
|
||||
send.type = SendType.Text;
|
||||
send.name = null;
|
||||
send.notes = new EncString("encryptedNotes");
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = null;
|
||||
send.emailHashes = "";
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
send.text.text = new EncString("text");
|
||||
send.text.hidden = false;
|
||||
|
||||
const request = new SendRequest(send);
|
||||
|
||||
expect(request.name).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle notes being null", () => {
|
||||
const send = new Send();
|
||||
send.type = SendType.Text;
|
||||
send.name = new EncString("encryptedName");
|
||||
send.notes = null;
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = null;
|
||||
send.emailHashes = "";
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
send.text.text = new EncString("text");
|
||||
send.text.hidden = false;
|
||||
|
||||
const request = new SendRequest(send);
|
||||
|
||||
expect(request.notes).toBeNull();
|
||||
});
|
||||
|
||||
it("should include fileLength when provided for text send", () => {
|
||||
const send = new Send();
|
||||
send.type = SendType.Text;
|
||||
send.name = new EncString("encryptedName");
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = null;
|
||||
send.emailHashes = "";
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
send.text.text = new EncString("text");
|
||||
send.text.hidden = false;
|
||||
|
||||
const request = new SendRequest(send, 1024);
|
||||
|
||||
expect(request.fileLength).toBe(1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Email auth requirements", () => {
|
||||
it("should create request with encrypted emails and plaintext emailHashes", () => {
|
||||
// Setup: A Send with encrypted emails and computed hashes
|
||||
const send = new Send();
|
||||
send.type = SendType.Text;
|
||||
send.name = new EncString("encryptedName");
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = new EncString("2.encryptedEmailString|data");
|
||||
send.emailHashes = "A1B2C3D4,E5F6G7H8"; // Plaintext hashes
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
send.text.text = new EncString("text");
|
||||
send.text.hidden = false;
|
||||
|
||||
// Act: Create the request
|
||||
const request = new SendRequest(send);
|
||||
|
||||
// emails field contains encrypted value
|
||||
expect(request.emails).toBe("2.encryptedEmailString|data");
|
||||
expect(request.emails).toContain("encrypted");
|
||||
|
||||
//emailHashes field contains plaintext comma-separated hashes
|
||||
expect(request.emailHashes).toBe("A1B2C3D4,E5F6G7H8");
|
||||
expect(request.emailHashes).not.toContain("encrypted");
|
||||
expect(request.emailHashes.split(",")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ export class SendRequest {
|
||||
file: SendFileApi;
|
||||
password: string;
|
||||
emails: string;
|
||||
emailHashes: string;
|
||||
disabled: boolean;
|
||||
hideEmail: boolean;
|
||||
|
||||
@@ -31,7 +32,8 @@ export class SendRequest {
|
||||
this.deletionDate = send.deletionDate != null ? send.deletionDate.toISOString() : null;
|
||||
this.key = send.key != null ? send.key.encryptedString : null;
|
||||
this.password = send.password;
|
||||
this.emails = send.emails;
|
||||
this.emails = send.emails ? send.emails.encryptedString : null;
|
||||
this.emailHashes = send.emailHashes;
|
||||
this.disabled = send.disabled;
|
||||
this.hideEmail = send.hideEmail;
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
import { AuthType } from "../../types/auth-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendFileApi } from "../api/send-file.api";
|
||||
import { SendTextApi } from "../api/send-text.api";
|
||||
|
||||
@@ -10,7 +11,6 @@ export class SendResponse extends BaseResponse {
|
||||
id: string;
|
||||
accessId: string;
|
||||
type: SendType;
|
||||
authType: AuthType;
|
||||
name: string;
|
||||
notes: string;
|
||||
file: SendFileApi;
|
||||
@@ -25,6 +25,7 @@ export class SendResponse extends BaseResponse {
|
||||
emails: string;
|
||||
disable: boolean;
|
||||
hideEmail: boolean;
|
||||
authType: AuthType;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -44,6 +45,7 @@ export class SendResponse extends BaseResponse {
|
||||
this.emails = this.getResponseProperty("Emails");
|
||||
this.disable = this.getResponseProperty("Disabled") || false;
|
||||
this.hideEmail = this.getResponseProperty("HideEmail") || false;
|
||||
this.authType = this.getResponseProperty("AuthType");
|
||||
|
||||
const text = this.getResponseProperty("Text");
|
||||
if (text != null) {
|
||||
|
||||
@@ -19,7 +19,6 @@ export class SendView implements View {
|
||||
key: Uint8Array;
|
||||
cryptoKey: SymmetricCryptoKey;
|
||||
type: SendType = null;
|
||||
authType: AuthType = null;
|
||||
text = new SendTextView();
|
||||
file = new SendFileView();
|
||||
maxAccessCount?: number = null;
|
||||
@@ -31,6 +30,7 @@ export class SendView implements View {
|
||||
emails: string[] = [];
|
||||
disabled = false;
|
||||
hideEmail = false;
|
||||
authType: AuthType = null;
|
||||
|
||||
constructor(s?: Send) {
|
||||
if (!s) {
|
||||
@@ -49,6 +49,7 @@ export class SendView implements View {
|
||||
this.disabled = s.disabled;
|
||||
this.password = s.password;
|
||||
this.hideEmail = s.hideEmail;
|
||||
this.authType = s.authType;
|
||||
}
|
||||
|
||||
get urlB64Key(): string {
|
||||
|
||||
@@ -189,6 +189,7 @@ export class SendApiService implements SendApiServiceAbstraction {
|
||||
|
||||
private async upload(sendData: [Send, EncArrayBuffer]): Promise<SendResponse> {
|
||||
const request = new SendRequest(sendData[0], sendData[1]?.buffer.byteLength);
|
||||
|
||||
let response: SendResponse;
|
||||
if (sendData[0].id == null) {
|
||||
if (sendData[0].type === SendType.Text) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import { KeyGenerationService } from "../../../key-management/crypto";
|
||||
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "../../../platform/abstractions/environment.service";
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
@@ -29,6 +31,7 @@ import { SendTextApi } from "../models/api/send-text.api";
|
||||
import { SendFileData } from "../models/data/send-file.data";
|
||||
import { SendTextData } from "../models/data/send-text.data";
|
||||
import { SendData } from "../models/data/send.data";
|
||||
import { SendTextView } from "../models/view/send-text.view";
|
||||
import { SendView } from "../models/view/send.view";
|
||||
import { SendType } from "../types/send-type";
|
||||
|
||||
@@ -48,7 +51,8 @@ describe("SendService", () => {
|
||||
const keyGenerationService = mock<KeyGenerationService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
const environmentService = mock<EnvironmentService>();
|
||||
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const configService = mock<ConfigService>();
|
||||
let sendStateProvider: SendStateProvider;
|
||||
let sendService: SendService;
|
||||
|
||||
@@ -94,6 +98,8 @@ describe("SendService", () => {
|
||||
keyGenerationService,
|
||||
sendStateProvider,
|
||||
encryptService,
|
||||
cryptoFunctionService,
|
||||
configService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -573,4 +579,256 @@ describe("SendService", () => {
|
||||
expect(sendsAfterDelete.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encrypt", () => {
|
||||
let sendView: SendView;
|
||||
const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
const mockCryptoKey = new SymmetricCryptoKey(new Uint8Array(32));
|
||||
|
||||
beforeEach(() => {
|
||||
sendView = new SendView();
|
||||
sendView.id = "sendId";
|
||||
sendView.type = SendType.Text;
|
||||
sendView.name = "Test Send";
|
||||
sendView.notes = "Test Notes";
|
||||
const sendTextView = new SendTextView();
|
||||
sendTextView.text = "test text";
|
||||
sendTextView.hidden = false;
|
||||
sendView.text = sendTextView;
|
||||
sendView.key = new Uint8Array(16);
|
||||
sendView.cryptoKey = mockCryptoKey;
|
||||
sendView.maxAccessCount = 5;
|
||||
sendView.disabled = false;
|
||||
sendView.hideEmail = false;
|
||||
sendView.deletionDate = new Date("2024-12-31");
|
||||
sendView.expirationDate = null;
|
||||
|
||||
keyService.userKey$.mockReturnValue(of(userKey));
|
||||
keyService.makeSendKey.mockResolvedValue(mockCryptoKey);
|
||||
encryptService.encryptBytes.mockResolvedValue({ encryptedString: "encryptedKey" } as any);
|
||||
encryptService.encryptString.mockResolvedValue({ encryptedString: "encrypted" } as any);
|
||||
});
|
||||
|
||||
describe("when SendEmailOTP feature flag is ON", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
cryptoFunctionService.hash.mockClear();
|
||||
});
|
||||
|
||||
describe("email encryption", () => {
|
||||
it("should encrypt emails when email list is provided", async () => {
|
||||
sendView.emails = ["test@example.com", "user@test.com"];
|
||||
cryptoFunctionService.hash.mockResolvedValue(new Uint8Array([0xab, 0xcd]));
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(encryptService.encryptString).toHaveBeenCalledWith(
|
||||
"test@example.com,user@test.com",
|
||||
mockCryptoKey,
|
||||
);
|
||||
expect(send.emails).toEqual({ encryptedString: "encrypted" });
|
||||
expect(send.password).toBeNull();
|
||||
});
|
||||
|
||||
it("should set emails to null when email list is empty", async () => {
|
||||
sendView.emails = [];
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(send.emails).toBeNull();
|
||||
expect(send.emailHashes).toBe("");
|
||||
});
|
||||
|
||||
it("should set emails to null when email list is null", async () => {
|
||||
sendView.emails = null;
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(send.emails).toBeNull();
|
||||
expect(send.emailHashes).toBe("");
|
||||
});
|
||||
|
||||
it("should set emails to null when email list is undefined", async () => {
|
||||
sendView.emails = undefined;
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(send.emails).toBeNull();
|
||||
expect(send.emailHashes).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("email hashing", () => {
|
||||
it("should hash emails using SHA-256 and return uppercase hex", async () => {
|
||||
sendView.emails = ["test@example.com"];
|
||||
const mockHash = new Uint8Array([0xab, 0xcd, 0xef]);
|
||||
|
||||
cryptoFunctionService.hash.mockResolvedValue(mockHash);
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256");
|
||||
expect(send.emailHashes).toBe("ABCDEF");
|
||||
});
|
||||
|
||||
it("should hash multiple emails and return comma-separated hashes", async () => {
|
||||
sendView.emails = ["test@example.com", "user@test.com"];
|
||||
const mockHash1 = new Uint8Array([0xab, 0xcd]);
|
||||
const mockHash2 = new Uint8Array([0x12, 0x34]);
|
||||
|
||||
cryptoFunctionService.hash
|
||||
.mockResolvedValueOnce(mockHash1)
|
||||
.mockResolvedValueOnce(mockHash2);
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256");
|
||||
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("user@test.com", "sha256");
|
||||
expect(send.emailHashes).toBe("ABCD,1234");
|
||||
});
|
||||
|
||||
it("should trim and lowercase emails before hashing", async () => {
|
||||
sendView.emails = [" Test@Example.COM ", "USER@test.com"];
|
||||
const mockHash = new Uint8Array([0xff]);
|
||||
|
||||
cryptoFunctionService.hash.mockResolvedValue(mockHash);
|
||||
|
||||
await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256");
|
||||
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("user@test.com", "sha256");
|
||||
});
|
||||
|
||||
it("should set emailHashes to empty string when no emails", async () => {
|
||||
sendView.emails = [];
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(send.emailHashes).toBe("");
|
||||
expect(cryptoFunctionService.hash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle single email correctly", async () => {
|
||||
sendView.emails = ["single@test.com"];
|
||||
const mockHash = new Uint8Array([0xa1, 0xb2, 0xc3]);
|
||||
|
||||
cryptoFunctionService.hash.mockResolvedValue(mockHash);
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(send.emailHashes).toBe("A1B2C3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("emails and password mutual exclusivity", () => {
|
||||
it("should set password to null when emails are provided", async () => {
|
||||
sendView.emails = ["test@example.com"];
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, "password123");
|
||||
|
||||
expect(send.emails).toBeDefined();
|
||||
expect(send.password).toBeNull();
|
||||
});
|
||||
|
||||
it("should set password when no emails are provided", async () => {
|
||||
sendView.emails = [];
|
||||
keyGenerationService.deriveKeyFromPassword.mockResolvedValue({
|
||||
keyB64: "hashedPassword",
|
||||
} as any);
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, "password123");
|
||||
|
||||
expect(send.emails).toBeNull();
|
||||
expect(send.password).toBe("hashedPassword");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when SendEmailOTP feature flag is OFF", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
cryptoFunctionService.hash.mockClear();
|
||||
});
|
||||
|
||||
it("should NOT encrypt emails even when provided", async () => {
|
||||
sendView.emails = ["test@example.com"];
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(send.emails).toBeNull();
|
||||
expect(send.emailHashes).toBe("");
|
||||
expect(cryptoFunctionService.hash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use password when provided and flag is OFF", async () => {
|
||||
sendView.emails = [];
|
||||
keyGenerationService.deriveKeyFromPassword.mockResolvedValue({
|
||||
keyB64: "hashedPassword",
|
||||
} as any);
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, "password123");
|
||||
|
||||
expect(send.emails).toBeNull();
|
||||
expect(send.emailHashes).toBe("");
|
||||
expect(send.password).toBe("hashedPassword");
|
||||
});
|
||||
|
||||
it("should ignore emails and use password when both provided", async () => {
|
||||
sendView.emails = ["test@example.com"];
|
||||
keyGenerationService.deriveKeyFromPassword.mockResolvedValue({
|
||||
keyB64: "hashedPassword",
|
||||
} as any);
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, "password123");
|
||||
|
||||
expect(send.emails).toBeNull();
|
||||
expect(send.emailHashes).toBe("");
|
||||
expect(send.password).toBe("hashedPassword");
|
||||
expect(cryptoFunctionService.hash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set emails and password to null when neither provided", async () => {
|
||||
sendView.emails = [];
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(send.emails).toBeNull();
|
||||
expect(send.emailHashes).toBe("");
|
||||
expect(send.password).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("null handling for name and notes", () => {
|
||||
it("should handle null name correctly", async () => {
|
||||
sendView.name = null;
|
||||
sendView.emails = [];
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(send.name).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle null notes correctly", async () => {
|
||||
sendView.notes = null;
|
||||
sendView.emails = [];
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(send.notes).toBeNull();
|
||||
});
|
||||
|
||||
it("should encrypt non-null name and notes", async () => {
|
||||
sendView.name = "Test Name";
|
||||
sendView.notes = "Test Notes";
|
||||
sendView.emails = [];
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(encryptService.encryptString).toHaveBeenCalledWith("Test Name", mockCryptoKey);
|
||||
expect(encryptService.encryptString).toHaveBeenCalledWith("Test Notes", mockCryptoKey);
|
||||
expect(send.name).toEqual({ encryptedString: "encrypted" });
|
||||
expect(send.notes).toEqual({ encryptedString: "encrypted" });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,12 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PBKDF2KdfConfig, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { KeyGenerationService } from "../../../key-management/crypto";
|
||||
import { CryptoFunctionService } from "../../../key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
||||
@@ -51,6 +54,8 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
private keyGenerationService: KeyGenerationService,
|
||||
private stateProvider: SendStateProvider,
|
||||
private encryptService: EncryptService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async encrypt(
|
||||
@@ -80,19 +85,30 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
model.cryptoKey = key.derivedKey;
|
||||
}
|
||||
|
||||
// Check feature flag for email OTP authentication
|
||||
const sendEmailOTPEnabled = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP);
|
||||
|
||||
const hasEmails = (model.emails?.length ?? 0) > 0;
|
||||
if (hasEmails) {
|
||||
send.emails = model.emails.join(",");
|
||||
|
||||
if (sendEmailOTPEnabled && hasEmails) {
|
||||
const plaintextEmails = model.emails.join(",");
|
||||
send.emails = await this.encryptService.encryptString(plaintextEmails, model.cryptoKey);
|
||||
send.emailHashes = await this.hashEmails(plaintextEmails);
|
||||
send.password = null;
|
||||
} else if (password != null) {
|
||||
// Note: Despite being called key, the passwordKey is not used for encryption.
|
||||
// It is used as a static proof that the client knows the password, and has the encryption key.
|
||||
const passwordKey = await this.keyGenerationService.deriveKeyFromPassword(
|
||||
password,
|
||||
model.key,
|
||||
new PBKDF2KdfConfig(SEND_KDF_ITERATIONS),
|
||||
);
|
||||
send.password = passwordKey.keyB64;
|
||||
} else {
|
||||
send.emails = null;
|
||||
send.emailHashes = "";
|
||||
|
||||
if (password != null) {
|
||||
// Note: Despite being called key, the passwordKey is not used for encryption.
|
||||
// It is used as a static proof that the client knows the password, and has the encryption key.
|
||||
const passwordKey = await this.keyGenerationService.deriveKeyFromPassword(
|
||||
password,
|
||||
model.key,
|
||||
new PBKDF2KdfConfig(SEND_KDF_ITERATIONS),
|
||||
);
|
||||
send.password = passwordKey.keyB64;
|
||||
}
|
||||
}
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
||||
if (userKey == null) {
|
||||
@@ -100,10 +116,14 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
}
|
||||
// Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey
|
||||
send.key = await this.encryptService.encryptBytes(model.key, userKey);
|
||||
// FIXME: model.name can be null. encryptString should not be called with null values.
|
||||
send.name = await this.encryptService.encryptString(model.name, model.cryptoKey);
|
||||
// FIXME: model.notes can be null. encryptString should not be called with null values.
|
||||
send.notes = await this.encryptService.encryptString(model.notes, model.cryptoKey);
|
||||
send.name =
|
||||
model.name != null
|
||||
? await this.encryptService.encryptString(model.name, model.cryptoKey)
|
||||
: null;
|
||||
send.notes =
|
||||
model.notes != null
|
||||
? await this.encryptService.encryptString(model.notes, model.cryptoKey)
|
||||
: null;
|
||||
if (send.type === SendType.Text) {
|
||||
send.text = new SendText();
|
||||
// FIXME: model.text.text can be null. encryptString should not be called with null values.
|
||||
@@ -127,6 +147,8 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
send.authType = model.authType;
|
||||
|
||||
return [send, fileData];
|
||||
}
|
||||
|
||||
@@ -371,4 +393,19 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
decryptedSends.sort(Utils.getSortFunction(this.i18nService, "name"));
|
||||
return decryptedSends;
|
||||
}
|
||||
|
||||
private async hashEmails(emails: string): Promise<string> {
|
||||
if (!emails) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const emailArray = emails.split(",").map((e) => e.trim().toLowerCase());
|
||||
const hashPromises = emailArray.map(async (email) => {
|
||||
const hash: Uint8Array = await this.cryptoFunctionService.hash(email, "sha256");
|
||||
return Utils.fromBufferToHex(hash).toUpperCase();
|
||||
});
|
||||
|
||||
const hashes = await Promise.all(hashPromises);
|
||||
return hashes.join(",");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function testSendViewData(id: string, name: string) {
|
||||
data.deletionDate = null;
|
||||
data.notes = "Notes!!";
|
||||
data.key = null;
|
||||
data.emails = [];
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -39,6 +40,8 @@ export function createSendData(value: Partial<SendData> = {}) {
|
||||
expirationDate: "2024-09-04",
|
||||
deletionDate: "2024-09-04",
|
||||
password: "password",
|
||||
emails: "",
|
||||
emailHashes: "",
|
||||
disabled: false,
|
||||
hideEmail: false,
|
||||
};
|
||||
@@ -62,6 +65,8 @@ export function testSendData(id: string, name: string) {
|
||||
data.deletionDate = null;
|
||||
data.notes = "Notes!!";
|
||||
data.key = null;
|
||||
data.emails = "";
|
||||
data.emailHashes = "";
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -77,5 +82,7 @@ export function testSend(id: string, name: string) {
|
||||
data.deletionDate = null;
|
||||
data.notes = new EncString("Notes!!");
|
||||
data.key = null;
|
||||
data.emails = null;
|
||||
data.emailHashes = "";
|
||||
return data;
|
||||
}
|
||||
|
||||
31
package-lock.json
generated
31
package-lock.json
generated
@@ -127,7 +127,7 @@
|
||||
"base64-loader": "1.0.0",
|
||||
"browserslist": "4.28.1",
|
||||
"chromatic": "13.3.4",
|
||||
"concurrently": "9.2.0",
|
||||
"concurrently": "9.2.1",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"cross-env": "10.1.0",
|
||||
"css-loader": "7.1.2",
|
||||
@@ -20558,19 +20558,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz",
|
||||
"integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==",
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"rxjs": "^7.8.1",
|
||||
"shell-quote": "^1.8.1",
|
||||
"supports-color": "^8.1.1",
|
||||
"tree-kill": "^1.2.2",
|
||||
"yargs": "^17.7.2"
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.3",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
@@ -20583,6 +20582,16 @@
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
"base64-loader": "1.0.0",
|
||||
"browserslist": "4.28.1",
|
||||
"chromatic": "13.3.4",
|
||||
"concurrently": "9.2.0",
|
||||
"concurrently": "9.2.1",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"cross-env": "10.1.0",
|
||||
"css-loader": "7.1.2",
|
||||
|
||||
Reference in New Issue
Block a user