mirror of
https://github.com/bitwarden/browser
synced 2026-02-02 17:53:41 +00:00
Merge branch 'main' into dirt/pm-26676/risk-insights-refresh-when-org-changes
This commit is contained in:
4
.github/workflows/deploy-web.yml
vendored
4
.github/workflows/deploy-web.yml
vendored
@@ -390,9 +390,9 @@ jobs:
|
||||
env:
|
||||
AZCOPY_AUTO_LOGIN_TYPE: AZCLI
|
||||
AZCOPY_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
_VAULT_NAME: ${{ steps.retrieve-secrets-azcopy.outputs.sa-bitwarden-web-vault-name }}.
|
||||
_VAULT_NAME: ${{ steps.retrieve-secrets-azcopy.outputs.sa-bitwarden-web-vault-name }}
|
||||
run: |
|
||||
azcopy sync ./build "https://$_VAULT_NAME.blob.core.windows.net/$web/" \
|
||||
azcopy sync ./build "https://$_VAULT_NAME.blob.core.windows.net/\$web/" \
|
||||
--delete-destination="${{ inputs.force-delete-destination }}" --compare-hash="MD5"
|
||||
|
||||
- name: Log out from Azure
|
||||
|
||||
2
.github/workflows/stale-bot.yml
vendored
2
.github/workflows/stale-bot.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: 'Run stale action'
|
||||
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
stale-issue-label: 'needs-reply'
|
||||
stale-pr-label: 'needs-changes'
|
||||
|
||||
@@ -315,164 +315,53 @@
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nx/webpack:webpack",
|
||||
"executor": "nx:run-commands",
|
||||
"defaultConfiguration": "chrome-dev",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/browser",
|
||||
"webpackConfig": "apps/browser/webpack.config.js",
|
||||
"tsConfig": "apps/browser/tsconfig.json",
|
||||
"main": "apps/browser/src/popup/main.ts",
|
||||
"target": "web",
|
||||
"compiler": "tsc",
|
||||
"watch": true
|
||||
"cwd": "apps/browser"
|
||||
},
|
||||
"configurations": {
|
||||
"chrome-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/chrome-dev",
|
||||
"env": {
|
||||
"BROWSER": "chrome",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
"command": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack --watch --output-path=../../dist/apps/browser/chrome-dev"
|
||||
},
|
||||
"firefox-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/firefox-dev",
|
||||
"env": {
|
||||
"BROWSER": "firefox",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
"command": "cross-env BROWSER=firefox MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack --watch --output-path=../../dist/apps/browser/firefox-dev"
|
||||
},
|
||||
"firefox-mv2-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/firefox-mv2-dev",
|
||||
"env": {
|
||||
"BROWSER": "firefox",
|
||||
"MANIFEST_VERSION": "2",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
"command": "cross-env BROWSER=firefox MANIFEST_VERSION=2 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack --watch --output-path=../../dist/apps/browser/firefox-mv2-dev"
|
||||
},
|
||||
"safari-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/safari-dev",
|
||||
"env": {
|
||||
"BROWSER": "safari",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
"command": "cross-env BROWSER=safari MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack --watch --output-path=../../dist/apps/browser/safari-dev"
|
||||
},
|
||||
"safari-mv2-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/safari-mv2-dev",
|
||||
"env": {
|
||||
"BROWSER": "safari",
|
||||
"MANIFEST_VERSION": "2",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
"command": "cross-env BROWSER=safari MANIFEST_VERSION=2 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack --watch --output-path=../../dist/apps/browser/safari-mv2-dev"
|
||||
},
|
||||
"edge-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/edge-dev",
|
||||
"env": {
|
||||
"BROWSER": "edge",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
"command": "cross-env BROWSER=edge MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack --watch --output-path=../../dist/apps/browser/edge-dev"
|
||||
},
|
||||
"opera-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/opera-dev",
|
||||
"env": {
|
||||
"BROWSER": "opera",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
"command": "cross-env BROWSER=opera MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack --watch --output-path=../../dist/apps/browser/opera-dev"
|
||||
},
|
||||
"commercial-chrome-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-chrome-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "chrome",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
"command": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js --watch --output-path=../../dist/apps/browser/commercial-chrome-dev"
|
||||
},
|
||||
"commercial-firefox-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-firefox-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "firefox",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
"command": "cross-env BROWSER=firefox MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js --watch --output-path=../../dist/apps/browser/commercial-firefox-dev"
|
||||
},
|
||||
"commercial-firefox-mv2-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-firefox-mv2-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "firefox",
|
||||
"MANIFEST_VERSION": "2",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
"command": "cross-env BROWSER=firefox MANIFEST_VERSION=2 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js --watch --output-path=../../dist/apps/browser/commercial-firefox-mv2-dev"
|
||||
},
|
||||
"commercial-safari-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-safari-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "safari",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
"command": "cross-env BROWSER=safari MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js --watch --output-path=../../dist/apps/browser/commercial-safari-dev"
|
||||
},
|
||||
"commercial-safari-mv2-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-safari-mv2-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "safari",
|
||||
"MANIFEST_VERSION": "2",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
"command": "cross-env BROWSER=safari MANIFEST_VERSION=2 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js --watch --output-path=../../dist/apps/browser/commercial-safari-mv2-dev"
|
||||
},
|
||||
"commercial-edge-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-edge-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "edge",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
"command": "cross-env BROWSER=edge MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js --watch --output-path=../../dist/apps/browser/commercial-edge-dev"
|
||||
},
|
||||
"commercial-opera-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-opera-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "opera",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
"command": "cross-env BROWSER=opera MANIFEST_VERSION=3 NODE_ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js --watch --output-path=../../dist/apps/browser/commercial-opera-dev"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5715,6 +5715,9 @@
|
||||
"confirmKeyConnectorDomain": {
|
||||
"message": "Confirm Key Connector domain"
|
||||
},
|
||||
"atRiskLoginsSecured": {
|
||||
"message": "Great job securing your at-risk logins!"
|
||||
},
|
||||
"settingDisabledByPolicy": {
|
||||
"message": "This setting is disabled by your organization's policy.",
|
||||
"description": "This hint text is displayed when a user setting is disabled due to an organization policy."
|
||||
|
||||
@@ -319,6 +319,7 @@ import I18nService from "../platform/services/i18n.service";
|
||||
import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service";
|
||||
import { BackgroundPlatformUtilsService } from "../platform/services/platform-utils/background-platform-utils.service";
|
||||
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
|
||||
import { PopupRouterCacheBackgroundService } from "../platform/services/popup-router-cache-background.service";
|
||||
import { PopupViewCacheBackgroundService } from "../platform/services/popup-view-cache-background.service";
|
||||
import { BrowserSdkLoadService } from "../platform/services/sdk/browser-sdk-load.service";
|
||||
import { BackgroundTaskSchedulerService } from "../platform/services/task-scheduler/background-task-scheduler.service";
|
||||
@@ -488,6 +489,7 @@ export default class MainBackground {
|
||||
private nativeMessagingBackground: NativeMessagingBackground;
|
||||
|
||||
private popupViewCacheBackgroundService: PopupViewCacheBackgroundService;
|
||||
private popupRouterCacheBackgroundService: PopupRouterCacheBackgroundService;
|
||||
|
||||
constructor() {
|
||||
// Services
|
||||
@@ -684,6 +686,9 @@ export default class MainBackground {
|
||||
this.globalStateProvider,
|
||||
this.taskSchedulerService,
|
||||
);
|
||||
this.popupRouterCacheBackgroundService = new PopupRouterCacheBackgroundService(
|
||||
this.globalStateProvider,
|
||||
);
|
||||
|
||||
this.migrationRunner = new MigrationRunner(
|
||||
this.storageService,
|
||||
@@ -1514,6 +1519,7 @@ export default class MainBackground {
|
||||
(this.eventUploadService as EventUploadService).init(true);
|
||||
|
||||
this.popupViewCacheBackgroundService.startObservingMessages();
|
||||
this.popupRouterCacheBackgroundService.init();
|
||||
|
||||
await this.vaultTimeoutService.init(true);
|
||||
this.fido2Background.init();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Injectable, inject } from "@angular/core";
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
CanActivateFn,
|
||||
Data,
|
||||
NavigationEnd,
|
||||
Router,
|
||||
UrlSerializer,
|
||||
@@ -14,7 +15,10 @@ import { filter, first, firstValueFrom, map, Observable, of, switchMap, tap } fr
|
||||
|
||||
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { POPUP_ROUTE_HISTORY_KEY } from "../../../platform/services/popup-view-cache-background.service";
|
||||
import {
|
||||
POPUP_ROUTE_HISTORY_KEY,
|
||||
RouteHistoryCacheState,
|
||||
} from "../../../platform/services/popup-view-cache-background.service";
|
||||
import BrowserPopupUtils from "../../browser/browser-popup-utils";
|
||||
|
||||
/**
|
||||
@@ -42,8 +46,7 @@ export class PopupRouterCacheService {
|
||||
this.history$()
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(history) =>
|
||||
Array.isArray(history) && history.forEach((location) => this.location.go(location)),
|
||||
(history) => Array.isArray(history) && history.forEach(({ url }) => this.location.go(url)),
|
||||
);
|
||||
|
||||
// update state when route change occurs
|
||||
@@ -54,31 +57,33 @@ export class PopupRouterCacheService {
|
||||
// `Location.back()` can now be called successfully
|
||||
this.hasNavigated = true;
|
||||
}),
|
||||
filter((_event: NavigationEnd) => {
|
||||
map((event) => {
|
||||
const state: ActivatedRouteSnapshot = this.router.routerState.snapshot.root;
|
||||
|
||||
let child = state.firstChild;
|
||||
while (child.firstChild) {
|
||||
child = child.firstChild;
|
||||
}
|
||||
|
||||
return !child?.data?.doNotSaveUrl;
|
||||
return { event, data: child.data };
|
||||
}),
|
||||
switchMap((event) => this.push(event.url)),
|
||||
filter(({ data }) => {
|
||||
return !data?.doNotSaveUrl;
|
||||
}),
|
||||
switchMap(({ event, data }) => this.push(event.url, data)),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
history$(): Observable<string[]> {
|
||||
history$(): Observable<RouteHistoryCacheState[]> {
|
||||
return this.state.state$;
|
||||
}
|
||||
|
||||
async setHistory(state: string[]): Promise<string[]> {
|
||||
async setHistory(state: RouteHistoryCacheState[]): Promise<RouteHistoryCacheState[]> {
|
||||
return this.state.update(() => state);
|
||||
}
|
||||
|
||||
/** Get the last item from the history stack, or `null` if empty */
|
||||
last$(): Observable<string | null> {
|
||||
last$(): Observable<RouteHistoryCacheState | null> {
|
||||
return this.history$().pipe(
|
||||
map((history) => {
|
||||
if (!history || history.length === 0) {
|
||||
@@ -92,11 +97,24 @@ export class PopupRouterCacheService {
|
||||
/**
|
||||
* If in browser popup, push new route onto history stack
|
||||
*/
|
||||
private async push(url: string) {
|
||||
if (!BrowserPopupUtils.inPopup(window) || url === (await firstValueFrom(this.last$()))) {
|
||||
private async push(url: string, data: Data) {
|
||||
if (
|
||||
!BrowserPopupUtils.inPopup(window) ||
|
||||
url === (await firstValueFrom(this.last$().pipe(map((h) => h?.url))))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.state.update((prevState) => (prevState == null ? [url] : prevState.concat(url)));
|
||||
|
||||
const routeEntry: RouteHistoryCacheState = {
|
||||
url,
|
||||
options: {
|
||||
resetRouterCacheOnTabChange: data?.resetRouterCacheOnTabChange ?? false,
|
||||
},
|
||||
};
|
||||
|
||||
await this.state.update((prevState) =>
|
||||
prevState == null ? [routeEntry] : prevState.concat(routeEntry),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,13 +160,13 @@ export const popupRouterCacheGuard = ((): Observable<true | UrlTree> => {
|
||||
}
|
||||
|
||||
return popupHistoryService.last$().pipe(
|
||||
map((url: string) => {
|
||||
if (!url) {
|
||||
map((entry) => {
|
||||
if (!entry) {
|
||||
return true;
|
||||
}
|
||||
|
||||
popupHistoryService.markCacheRestored();
|
||||
return urlSerializer.parse(url);
|
||||
return urlSerializer.parse(entry.url);
|
||||
}),
|
||||
);
|
||||
}) satisfies CanActivateFn;
|
||||
|
||||
@@ -96,11 +96,31 @@ describe("Popup router cache guard", () => {
|
||||
// wait for router events subscription
|
||||
await flushPromises();
|
||||
|
||||
expect(await firstValueFrom(service.history$())).toEqual(["/a", "/b"]);
|
||||
expect(await firstValueFrom(service.history$())).toEqual([
|
||||
{
|
||||
options: {
|
||||
resetRouterCacheOnTabChange: false,
|
||||
},
|
||||
url: "/a",
|
||||
},
|
||||
{
|
||||
options: {
|
||||
resetRouterCacheOnTabChange: false,
|
||||
},
|
||||
url: "/b",
|
||||
},
|
||||
]);
|
||||
|
||||
await service.back();
|
||||
|
||||
expect(await firstValueFrom(service.history$())).toEqual(["/a"]);
|
||||
expect(await firstValueFrom(service.history$())).toEqual([
|
||||
{
|
||||
options: {
|
||||
resetRouterCacheOnTabChange: false,
|
||||
},
|
||||
url: "/a",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not save ignored routes", async () => {
|
||||
@@ -121,6 +141,13 @@ describe("Popup router cache guard", () => {
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(await firstValueFrom(service.history$())).toEqual(["/a"]);
|
||||
expect(await firstValueFrom(service.history$())).toEqual([
|
||||
{
|
||||
options: {
|
||||
resetRouterCacheOnTabChange: false,
|
||||
},
|
||||
url: "/a",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { switchMap, filter, map, first, of } from "rxjs";
|
||||
|
||||
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { BrowserApi } from "../browser/browser-api";
|
||||
import { fromChromeEvent } from "../browser/from-chrome-event";
|
||||
|
||||
import { POPUP_ROUTE_HISTORY_KEY } from "./popup-view-cache-background.service";
|
||||
|
||||
export class PopupRouterCacheBackgroundService {
|
||||
private popupRouteHistoryState = this.globalStateProvider.get(POPUP_ROUTE_HISTORY_KEY);
|
||||
|
||||
constructor(private globalStateProvider: GlobalStateProvider) {}
|
||||
|
||||
init() {
|
||||
fromChromeEvent(chrome.tabs.onActivated)
|
||||
.pipe(
|
||||
switchMap((tabs) => BrowserApi.getTab(tabs[0].tabId)!),
|
||||
switchMap((tab) => {
|
||||
// FireFox sets the `url` to "about:blank" and won't populate the `url` until the `onUpdated` event
|
||||
if (tab.url !== "about:blank") {
|
||||
return of(tab);
|
||||
}
|
||||
|
||||
return fromChromeEvent(chrome.tabs.onUpdated).pipe(
|
||||
first(),
|
||||
switchMap(([tabId]) => BrowserApi.getTab(tabId)!),
|
||||
);
|
||||
}),
|
||||
map((tab) => tab.url || tab.pendingUrl),
|
||||
filter((url) => !url?.startsWith(chrome.runtime.getURL(""))),
|
||||
switchMap(() =>
|
||||
this.popupRouteHistoryState.update((state) => {
|
||||
if (!state || state.length === 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const lastRoute = state.at(-1);
|
||||
if (!lastRoute) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// When the last route has resetRouterCacheOnTabChange set
|
||||
// Reset the route history to empty to force the user to the default route
|
||||
if (lastRoute.options?.resetRouterCacheOnTabChange) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return state;
|
||||
}),
|
||||
),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,22 @@ export type ViewCacheState = {
|
||||
options?: ViewCacheOptions;
|
||||
};
|
||||
|
||||
export type RouteCacheOptions = {
|
||||
/**
|
||||
* When true, the route history will be reset on tab change and respective route was the last visited route.
|
||||
* i.e. Upon the user re-opening the extension the route history will be empty and the user will be taken to the default route.
|
||||
*/
|
||||
resetRouterCacheOnTabChange?: boolean;
|
||||
};
|
||||
|
||||
export type RouteHistoryCacheState = {
|
||||
/** Route URL */
|
||||
url: string;
|
||||
|
||||
/** Options for managing the route history cache */
|
||||
options?: RouteCacheOptions;
|
||||
};
|
||||
|
||||
/** We cannot use `UserKeyDefinition` because we must be able to store state when there is no active user. */
|
||||
export const POPUP_VIEW_CACHE_KEY = KeyDefinition.record<ViewCacheState>(
|
||||
POPUP_VIEW_MEMORY,
|
||||
@@ -51,9 +67,9 @@ export const POPUP_VIEW_CACHE_KEY = KeyDefinition.record<ViewCacheState>(
|
||||
},
|
||||
);
|
||||
|
||||
export const POPUP_ROUTE_HISTORY_KEY = new KeyDefinition<string[]>(
|
||||
export const POPUP_ROUTE_HISTORY_KEY = new KeyDefinition<RouteHistoryCacheState[]>(
|
||||
POPUP_VIEW_MEMORY,
|
||||
"popup-route-history",
|
||||
"popup-route-history-details",
|
||||
{
|
||||
deserializer: (jsonValue) => jsonValue,
|
||||
},
|
||||
|
||||
@@ -59,6 +59,7 @@ import { ProtectedByComponent } from "../dirt/phishing-detection/pages/protected
|
||||
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
|
||||
import BrowserPopupUtils from "../platform/browser/browser-popup-utils";
|
||||
import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service";
|
||||
import { RouteCacheOptions } from "../platform/services/popup-view-cache-background.service";
|
||||
import { CredentialGeneratorHistoryComponent } from "../tools/popup/generator/credential-generator-history.component";
|
||||
import { CredentialGeneratorComponent } from "../tools/popup/generator/credential-generator.component";
|
||||
import { SendAddEditComponent as SendAddEditV2Component } from "../tools/popup/send-v2/add-edit/send-add-edit.component";
|
||||
@@ -76,7 +77,10 @@ import { IntroCarouselComponent } from "../vault/popup/components/vault-v2/intro
|
||||
import { PasswordHistoryV2Component } from "../vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component";
|
||||
import { VaultV2Component } from "../vault/popup/components/vault-v2/vault-v2.component";
|
||||
import { ViewV2Component } from "../vault/popup/components/vault-v2/view-v2/view-v2.component";
|
||||
import { canAccessAtRiskPasswords } from "../vault/popup/guards/at-risk-passwords.guard";
|
||||
import {
|
||||
canAccessAtRiskPasswords,
|
||||
hasAtRiskPasswords,
|
||||
} from "../vault/popup/guards/at-risk-passwords.guard";
|
||||
import { clearVaultStateGuard } from "../vault/popup/guards/clear-vault-state.guard";
|
||||
import { IntroCarouselGuard } from "../vault/popup/guards/intro-carousel.guard";
|
||||
import { AppearanceV2Component } from "../vault/popup/settings/appearance-v2.component";
|
||||
@@ -98,7 +102,7 @@ import { TabsV2Component } from "./tabs-v2.component";
|
||||
/**
|
||||
* Data properties acceptable for use in extension route objects
|
||||
*/
|
||||
export interface RouteDataProperties {
|
||||
export interface RouteDataProperties extends RouteCacheOptions {
|
||||
elevation: RouteElevation;
|
||||
|
||||
/**
|
||||
@@ -204,7 +208,7 @@ const routes: Routes = [
|
||||
path: "add-cipher",
|
||||
component: AddEditV2Component,
|
||||
canActivate: [authGuard, debounceNavigationGuard()],
|
||||
data: { elevation: 1 } satisfies RouteDataProperties,
|
||||
data: { elevation: 1, resetRouterCacheOnTabChange: true } satisfies RouteDataProperties,
|
||||
runGuardsAndResolvers: "always",
|
||||
},
|
||||
{
|
||||
@@ -214,6 +218,7 @@ const routes: Routes = [
|
||||
data: {
|
||||
// Above "trash"
|
||||
elevation: 3,
|
||||
resetRouterCacheOnTabChange: true,
|
||||
} satisfies RouteDataProperties,
|
||||
runGuardsAndResolvers: "always",
|
||||
},
|
||||
@@ -690,7 +695,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: "at-risk-passwords",
|
||||
component: AtRiskPasswordsComponent,
|
||||
canActivate: [authGuard, canAccessAtRiskPasswords],
|
||||
canActivate: [authGuard, canAccessAtRiskPasswords, hasAtRiskPasswords],
|
||||
},
|
||||
{
|
||||
path: "account-switcher",
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
<bit-callout *ngIf="(pendingTasks$ | async)?.length as taskCount" type="warning" [title]="''">
|
||||
<a bitLink [routerLink]="'/at-risk-passwords'">
|
||||
{{
|
||||
(taskCount === 1 ? "reviewAndChangeAtRiskPassword" : "reviewAndChangeAtRiskPasswordsPlural")
|
||||
| i18n: taskCount.toString()
|
||||
}}
|
||||
</a>
|
||||
</bit-callout>
|
||||
@if ((currentPendingTasks$ | async)?.length > 0) {
|
||||
<bit-banner
|
||||
class="-tw-m-5 tw-flex tw-flex-col tw-pt-2 tw-px-2 tw-mb-3"
|
||||
bannerType="warning"
|
||||
[showClose]="false"
|
||||
>
|
||||
<a bitLink linkType="secondary" [routerLink]="'/at-risk-passwords'">
|
||||
{{
|
||||
((currentPendingTasks$ | async)?.length === 1
|
||||
? "reviewAndChangeAtRiskPassword"
|
||||
: "reviewAndChangeAtRiskPasswordsPlural"
|
||||
) | i18n: (currentPendingTasks$ | async)?.length.toString()
|
||||
}}
|
||||
</a>
|
||||
</bit-banner>
|
||||
}
|
||||
|
||||
@if (showCompletedTasksBanner$ | async) {
|
||||
<bit-banner
|
||||
class="-tw-m-5 tw-flex tw-flex-col tw-pt-2 tw-px-2 tw-mb-3"
|
||||
bannerType="info"
|
||||
[icon]="null"
|
||||
[showClose]="true"
|
||||
(onClose)="successBannerDismissed()"
|
||||
>
|
||||
{{ "atRiskLoginsSecured" | i18n }}
|
||||
</bit-banner>
|
||||
}
|
||||
|
||||
@@ -1,42 +1,47 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, inject } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { combineLatest, map, switchMap } from "rxjs";
|
||||
import { firstValueFrom, switchMap } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { AnchorLinkDirective, CalloutModule } from "@bitwarden/components";
|
||||
import { AnchorLinkDirective, CalloutModule, BannerModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
import { AtRiskPasswordCalloutData, AtRiskPasswordCalloutService } from "@bitwarden/vault";
|
||||
|
||||
@Component({
|
||||
selector: "vault-at-risk-password-callout",
|
||||
imports: [CommonModule, AnchorLinkDirective, RouterModule, CalloutModule, I18nPipe],
|
||||
imports: [
|
||||
AnchorLinkDirective,
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
CalloutModule,
|
||||
I18nPipe,
|
||||
BannerModule,
|
||||
JslibModule,
|
||||
],
|
||||
providers: [AtRiskPasswordCalloutService],
|
||||
templateUrl: "./at-risk-password-callout.component.html",
|
||||
})
|
||||
export class AtRiskPasswordCalloutComponent {
|
||||
private taskService = inject(TaskService);
|
||||
private cipherService = inject(CipherService);
|
||||
private activeAccount$ = inject(AccountService).activeAccount$.pipe(getUserId);
|
||||
private atRiskPasswordCalloutService = inject(AtRiskPasswordCalloutService);
|
||||
|
||||
protected pendingTasks$ = this.activeAccount$.pipe(
|
||||
switchMap((userId) =>
|
||||
combineLatest([
|
||||
this.taskService.pendingTasks$(userId),
|
||||
this.cipherService.cipherViews$(userId),
|
||||
]),
|
||||
),
|
||||
map(([tasks, ciphers]) =>
|
||||
tasks.filter((t) => {
|
||||
const associatedCipher = ciphers.find((c) => c.id === t.cipherId);
|
||||
|
||||
return (
|
||||
t.type === SecurityTaskType.UpdateAtRiskCredential &&
|
||||
associatedCipher &&
|
||||
!associatedCipher.isDeleted
|
||||
);
|
||||
}),
|
||||
),
|
||||
showCompletedTasksBanner$ = this.activeAccount$.pipe(
|
||||
switchMap((userId) => this.atRiskPasswordCalloutService.showCompletedTasksBanner$(userId)),
|
||||
);
|
||||
|
||||
currentPendingTasks$ = this.activeAccount$.pipe(
|
||||
switchMap((userId) => this.atRiskPasswordCalloutService.pendingTasks$(userId)),
|
||||
);
|
||||
|
||||
async successBannerDismissed() {
|
||||
const updateObject: AtRiskPasswordCalloutData = {
|
||||
hasInteractedWithTasks: true,
|
||||
tasksBannerDismissed: true,
|
||||
};
|
||||
const userId = await firstValueFrom(this.activeAccount$);
|
||||
this.atRiskPasswordCalloutService.updateAtRiskPasswordState(userId, updateObject);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s
|
||||
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { FakeAccountService, FakeStateProvider } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { EndUserNotificationService } from "@bitwarden/common/vault/notifications";
|
||||
@@ -24,6 +27,7 @@ import {
|
||||
ChangeLoginPasswordService,
|
||||
DefaultChangeLoginPasswordService,
|
||||
PasswordRepromptService,
|
||||
AtRiskPasswordCalloutService,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
|
||||
@@ -68,6 +72,9 @@ describe("AtRiskPasswordsComponent", () => {
|
||||
let mockNotifications$: BehaviorSubject<NotificationView[]>;
|
||||
let mockInlineMenuVisibility$: BehaviorSubject<InlineMenuVisibilitySetting>;
|
||||
let calloutDismissed$: BehaviorSubject<boolean>;
|
||||
let mockAtRiskPasswordCalloutService: any;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let mockAccountService: FakeAccountService;
|
||||
const setInlineMenuVisibility = jest.fn();
|
||||
const mockToastService = mock<ToastService>();
|
||||
const mockAtRiskPasswordPageService = mock<AtRiskPasswordPageService>();
|
||||
@@ -112,6 +119,11 @@ describe("AtRiskPasswordsComponent", () => {
|
||||
mockToastService.showToast.mockClear();
|
||||
mockDialogService.open.mockClear();
|
||||
mockAtRiskPasswordPageService.isCalloutDismissed.mockReturnValue(calloutDismissed$);
|
||||
mockAccountService = {
|
||||
activeAccount$: of({ id: "user" as UserId }),
|
||||
activeUserId: "user" as UserId,
|
||||
} as unknown as FakeAccountService;
|
||||
stateProvider = new FakeStateProvider(mockAccountService);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AtRiskPasswordsComponent],
|
||||
@@ -141,7 +153,7 @@ describe("AtRiskPasswordsComponent", () => {
|
||||
},
|
||||
},
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: AccountService, useValue: { activeAccount$: of({ id: "user" }) } },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||
{ provide: PasswordRepromptService, useValue: mock<PasswordRepromptService>() },
|
||||
{
|
||||
@@ -152,6 +164,8 @@ describe("AtRiskPasswordsComponent", () => {
|
||||
},
|
||||
},
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: StateProvider, useValue: stateProvider },
|
||||
{ provide: AtRiskPasswordCalloutService, useValue: mockAtRiskPasswordCalloutService },
|
||||
],
|
||||
})
|
||||
.overrideModule(JslibModule, {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import {
|
||||
AtRiskPasswordCalloutService,
|
||||
ChangeLoginPasswordService,
|
||||
DefaultChangeLoginPasswordService,
|
||||
PasswordRepromptService,
|
||||
@@ -75,6 +76,7 @@ import { AtRiskPasswordPageService } from "./at-risk-password-page.service";
|
||||
providers: [
|
||||
AtRiskPasswordPageService,
|
||||
{ provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService },
|
||||
AtRiskPasswordCalloutService,
|
||||
],
|
||||
selector: "vault-at-risk-passwords",
|
||||
templateUrl: "./at-risk-passwords.component.html",
|
||||
@@ -95,13 +97,14 @@ export class AtRiskPasswordsComponent implements OnInit {
|
||||
private dialogService = inject(DialogService);
|
||||
private endUserNotificationService = inject(EndUserNotificationService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private atRiskPasswordCalloutService = inject(AtRiskPasswordCalloutService);
|
||||
|
||||
/**
|
||||
* The cipher that is currently being launched. Used to show a loading spinner on the badge button.
|
||||
* The UI utilize a bitBadge which does not support async actions (like bitButton does).
|
||||
* @protected
|
||||
*/
|
||||
protected launchingCipher = signal<CipherView | null>(null);
|
||||
protected readonly launchingCipher = signal<CipherView | null>(null);
|
||||
|
||||
private activeUserData$ = this.accountService.activeAccount$.pipe(
|
||||
filterOutNullish(),
|
||||
@@ -199,6 +202,11 @@ export class AtRiskPasswordsComponent implements OnInit {
|
||||
}
|
||||
|
||||
this.markTaskNotificationsAsRead();
|
||||
|
||||
this.atRiskPasswordCalloutService.updateAtRiskPasswordState(userId, {
|
||||
hasInteractedWithTasks: true,
|
||||
tasksBannerDismissed: false,
|
||||
});
|
||||
}
|
||||
|
||||
private markTaskNotificationsAsRead() {
|
||||
|
||||
@@ -41,6 +41,9 @@
|
||||
</bit-spotlight>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="vaultState !== VaultStateEnum.Empty">
|
||||
<!-- At-Risk callout displays as a banner and should always remain visually above all other callouts -->
|
||||
<vault-at-risk-password-callout></vault-at-risk-password-callout>
|
||||
|
||||
<div class="tw-mb-4" *ngIf="showHasItemsVaultSpotlight$ | async">
|
||||
<bit-spotlight
|
||||
[title]="'hasItemsVaultNudgeTitle' | i18n"
|
||||
@@ -53,7 +56,6 @@
|
||||
</ul>
|
||||
</bit-spotlight>
|
||||
</div>
|
||||
<vault-at-risk-password-callout></vault-at-risk-password-callout>
|
||||
<app-vault-header-v2></app-vault-header-v2>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { CanActivateFn, Router } from "@angular/router";
|
||||
import { map, switchMap } from "rxjs";
|
||||
import { combineLatest, map, switchMap } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
@@ -32,3 +33,38 @@ export const canAccessAtRiskPasswords: CanActivateFn = () => {
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const hasAtRiskPasswords: CanActivateFn = () => {
|
||||
const accountService = inject(AccountService);
|
||||
const taskService = inject(TaskService);
|
||||
const cipherService = inject(CipherService);
|
||||
const router = inject(Router);
|
||||
|
||||
return accountService.activeAccount$.pipe(
|
||||
filterOutNullish(),
|
||||
switchMap((user) =>
|
||||
combineLatest([
|
||||
taskService.pendingTasks$(user.id),
|
||||
cipherService.cipherViews$(user.id).pipe(
|
||||
filterOutNullish(),
|
||||
map((ciphers) => Object.fromEntries(ciphers.map((c) => [c.id, c]))),
|
||||
),
|
||||
]).pipe(
|
||||
map(([tasks, ciphers]) => {
|
||||
const hasAtRiskCiphers = tasks.some(
|
||||
(t) =>
|
||||
t.type === SecurityTaskType.UpdateAtRiskCredential &&
|
||||
t.cipherId != null &&
|
||||
ciphers[t.cipherId] != null &&
|
||||
!ciphers[t.cipherId].isDeleted,
|
||||
);
|
||||
|
||||
if (!hasAtRiskCiphers) {
|
||||
return router.createUrlTree(["/tabs/vault"]);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -105,14 +105,14 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
memberTab = MemberDialogTab;
|
||||
protected dataSource = new MembersTableDataSource();
|
||||
|
||||
organization: Signal<Organization | undefined>;
|
||||
readonly organization: Signal<Organization | undefined>;
|
||||
status: OrganizationUserStatusType | undefined;
|
||||
orgResetPasswordPolicyEnabled = false;
|
||||
|
||||
protected canUseSecretsManager: Signal<boolean> = computed(
|
||||
protected readonly canUseSecretsManager: Signal<boolean> = computed(
|
||||
() => this.organization()?.useSecretsManager ?? false,
|
||||
);
|
||||
protected showUserManagementControls: Signal<boolean> = computed(
|
||||
protected readonly showUserManagementControls: Signal<boolean> = computed(
|
||||
() => this.organization()?.canManageUsers ?? false,
|
||||
);
|
||||
private refreshBillingMetadata$: BehaviorSubject<null> = new BehaviorSubject(null);
|
||||
|
||||
@@ -32,8 +32,8 @@ import {
|
||||
standalone: true,
|
||||
})
|
||||
class MockUpgradeAccountComponent {
|
||||
dialogTitleMessageOverride = input<string | null>(null);
|
||||
hideContinueWithoutUpgradingButton = input<boolean>(false);
|
||||
readonly dialogTitleMessageOverride = input<string | null>(null);
|
||||
readonly hideContinueWithoutUpgradingButton = input<boolean>(false);
|
||||
planSelected = output<PersonalSubscriptionPricingTierId>();
|
||||
closeClicked = output<UpgradeAccountStatus>();
|
||||
}
|
||||
@@ -44,8 +44,8 @@ class MockUpgradeAccountComponent {
|
||||
standalone: true,
|
||||
})
|
||||
class MockUpgradePaymentComponent {
|
||||
selectedPlanId = input<PersonalSubscriptionPricingTierId | null>(null);
|
||||
account = input<Account | null>(null);
|
||||
readonly selectedPlanId = input<PersonalSubscriptionPricingTierId | null>(null);
|
||||
readonly account = input<Account | null>(null);
|
||||
goBack = output<void>();
|
||||
complete = output<UpgradePaymentResult>();
|
||||
}
|
||||
|
||||
@@ -77,11 +77,13 @@ export type UnifiedUpgradeDialogParams = {
|
||||
})
|
||||
export class UnifiedUpgradeDialogComponent implements OnInit {
|
||||
// Use signals for dialog state because inputs depend on parent component
|
||||
protected step = signal<UnifiedUpgradeDialogStep>(UnifiedUpgradeDialogStep.PlanSelection);
|
||||
protected selectedPlan = signal<PersonalSubscriptionPricingTierId | null>(null);
|
||||
protected account = signal<Account | null>(null);
|
||||
protected planSelectionStepTitleOverride = signal<string | null>(null);
|
||||
protected hideContinueWithoutUpgradingButton = signal<boolean>(false);
|
||||
protected readonly step = signal<UnifiedUpgradeDialogStep>(
|
||||
UnifiedUpgradeDialogStep.PlanSelection,
|
||||
);
|
||||
protected readonly selectedPlan = signal<PersonalSubscriptionPricingTierId | null>(null);
|
||||
protected readonly account = signal<Account | null>(null);
|
||||
protected readonly planSelectionStepTitleOverride = signal<string | null>(null);
|
||||
protected readonly hideContinueWithoutUpgradingButton = signal<boolean>(false);
|
||||
|
||||
protected readonly PaymentStep = UnifiedUpgradeDialogStep.Payment;
|
||||
protected readonly PlanSelectionStep = UnifiedUpgradeDialogStep.PlanSelection;
|
||||
|
||||
@@ -52,11 +52,11 @@ type CardDetails = {
|
||||
templateUrl: "./upgrade-account.component.html",
|
||||
})
|
||||
export class UpgradeAccountComponent implements OnInit {
|
||||
dialogTitleMessageOverride = input<string | null>(null);
|
||||
hideContinueWithoutUpgradingButton = input<boolean>(false);
|
||||
readonly dialogTitleMessageOverride = input<string | null>(null);
|
||||
readonly hideContinueWithoutUpgradingButton = input<boolean>(false);
|
||||
planSelected = output<PersonalSubscriptionPricingTierId>();
|
||||
closeClicked = output<UpgradeAccountStatus>();
|
||||
protected loading = signal(true);
|
||||
protected readonly loading = signal(true);
|
||||
protected premiumCardDetails!: CardDetails;
|
||||
protected familiesCardDetails!: CardDetails;
|
||||
|
||||
@@ -64,7 +64,7 @@ export class UpgradeAccountComponent implements OnInit {
|
||||
protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium;
|
||||
protected closeStatus = UpgradeAccountStatus.Closed;
|
||||
|
||||
protected dialogTitle = computed(() => {
|
||||
protected readonly dialogTitle = computed(() => {
|
||||
return this.dialogTitleMessageOverride() || "individualUpgradeWelcomeMessage";
|
||||
});
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ export type UpgradePaymentParams = {
|
||||
templateUrl: "./upgrade-payment.component.html",
|
||||
})
|
||||
export class UpgradePaymentComponent implements OnInit, AfterViewInit {
|
||||
protected selectedPlanId = input.required<PersonalSubscriptionPricingTierId>();
|
||||
protected account = input.required<Account>();
|
||||
protected readonly selectedPlanId = input.required<PersonalSubscriptionPricingTierId>();
|
||||
protected readonly account = input.required<Account>();
|
||||
protected goBack = output<void>();
|
||||
protected complete = output<UpgradePaymentResult>();
|
||||
protected selectedPlan: PlanDetails | null = null;
|
||||
@@ -90,7 +90,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
|
||||
billingAddress: EnterBillingAddressComponent.getFormGroup(),
|
||||
});
|
||||
|
||||
protected loading = signal(true);
|
||||
protected readonly loading = signal(true);
|
||||
private pricingTiers$!: Observable<PersonalSubscriptionPricingTier[]>;
|
||||
|
||||
// Cart Summary data
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<form [formGroup]="form" [bitSubmit]="submit" autocomplete="off">
|
||||
<bit-dialog>
|
||||
<span bitDialogTitle>
|
||||
{{ "changeKdf" | i18n }}
|
||||
{{ "updateYourEncryptionSettings" | i18n }}
|
||||
</span>
|
||||
|
||||
<span bitDialogContent>
|
||||
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
|
||||
<bit-form-field>
|
||||
@if (!(noLogoutOnKdfChangeFeatureFlag$ | async)) {
|
||||
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
|
||||
}
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label>{{ "masterPass" | i18n }}</bit-label>
|
||||
<input bitInput type="password" formControlName="masterPassword" appAutofocus />
|
||||
<button
|
||||
@@ -18,12 +20,12 @@
|
||||
></button>
|
||||
<bit-hint>
|
||||
{{ "confirmIdentity" | i18n }}
|
||||
</bit-hint></bit-form-field
|
||||
>
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
</span>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton buttonType="primary" type="submit" bitFormButton>
|
||||
<span>{{ "changeKdf" | i18n }}</span>
|
||||
<span>{{ "updateSettings" | i18n }}</span>
|
||||
</button>
|
||||
<button bitButton buttonType="secondary" type="button" bitFormButton bitDialogClose>
|
||||
{{ "cancel" | i18n }}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { FormControl } from "@angular/forms";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components";
|
||||
import { KdfType, PBKDF2KdfConfig, Argon2KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
import { ChangeKdfConfirmationComponent } from "./change-kdf-confirmation.component";
|
||||
|
||||
describe("ChangeKdfConfirmationComponent", () => {
|
||||
let component: ChangeKdfConfirmationComponent;
|
||||
let fixture: ComponentFixture<ChangeKdfConfirmationComponent>;
|
||||
|
||||
// Mock Services
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockMessagingService: MockProxy<MessagingService>;
|
||||
let mockToastService: MockProxy<ToastService>;
|
||||
let mockDialogRef: MockProxy<DialogRef<ChangeKdfConfirmationComponent>>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let accountService: FakeAccountService;
|
||||
let mockChangeKdfService: MockProxy<ChangeKdfService>;
|
||||
|
||||
const mockUserId = "user-id" as UserId;
|
||||
const mockEmail = "email";
|
||||
const mockMasterPassword = "master-password";
|
||||
const mockDialogData = jest.fn();
|
||||
const kdfConfig = new PBKDF2KdfConfig(600_001);
|
||||
|
||||
beforeEach(() => {
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockMessagingService = mock<MessagingService>();
|
||||
mockToastService = mock<ToastService>();
|
||||
mockDialogRef = mock<DialogRef<ChangeKdfConfirmationComponent>>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
accountService = mockAccountServiceWith(mockUserId, { email: mockEmail });
|
||||
mockChangeKdfService = mock<ChangeKdfService>();
|
||||
|
||||
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
|
||||
|
||||
// Mock config service feature flag
|
||||
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
|
||||
mockDialogData.mockReturnValue({
|
||||
kdf: KdfType.PBKDF2_SHA256,
|
||||
kdfConfig,
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ChangeKdfConfirmationComponent],
|
||||
imports: [SharedModule],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: MessagingService, useValue: mockMessagingService },
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: ChangeKdfService, useValue: mockChangeKdfService },
|
||||
{
|
||||
provide: DIALOG_DATA,
|
||||
useFactory: mockDialogData,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("Component Initialization", () => {
|
||||
it("should create component with PBKDF2 config", () => {
|
||||
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
expect(component).toBeTruthy();
|
||||
expect(component.kdfConfig).toBeInstanceOf(PBKDF2KdfConfig);
|
||||
expect(component.kdfConfig.iterations).toBe(600_001);
|
||||
});
|
||||
|
||||
it("should create component with Argon2id config", () => {
|
||||
mockDialogData.mockReturnValue({
|
||||
kdf: KdfType.Argon2id,
|
||||
kdfConfig: new Argon2KdfConfig(4, 65, 5),
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
expect(component).toBeTruthy();
|
||||
expect(component.kdfConfig).toBeInstanceOf(Argon2KdfConfig);
|
||||
const kdfConfig = component.kdfConfig as Argon2KdfConfig;
|
||||
expect(kdfConfig.iterations).toBe(4);
|
||||
expect(kdfConfig.memory).toBe(65);
|
||||
expect(kdfConfig.parallelism).toBe(5);
|
||||
});
|
||||
|
||||
it("should initialize form with required master password field", () => {
|
||||
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
expect(component.form.controls.masterPassword).toBeInstanceOf(FormControl);
|
||||
expect(component.form.controls.masterPassword.value).toEqual(null);
|
||||
expect(component.form.controls.masterPassword.hasError("required")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form Validation", () => {
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it("should be invalid when master password is empty", () => {
|
||||
component.form.controls.masterPassword.setValue("");
|
||||
expect(component.form.invalid).toBe(true);
|
||||
});
|
||||
|
||||
it("should be valid when master password is provided", () => {
|
||||
component.form.controls.masterPassword.setValue(mockMasterPassword);
|
||||
expect(component.form.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("submit method", () => {
|
||||
describe("should not update kdf and not show success toast", () => {
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
component.form.controls.masterPassword.setValue(mockMasterPassword);
|
||||
});
|
||||
|
||||
it("when form is invalid", async () => {
|
||||
// Arrange
|
||||
component.form.controls.masterPassword.setValue("");
|
||||
expect(component.form.invalid).toBe(true);
|
||||
|
||||
// Act
|
||||
await component.submit();
|
||||
|
||||
// Assert
|
||||
expect(mockChangeKdfService.updateUserKdfParams).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("when no active account", async () => {
|
||||
accountService.activeAccount$ = of(null);
|
||||
|
||||
await expect(component.submit()).rejects.toThrow("Null or undefined account");
|
||||
|
||||
expect(mockChangeKdfService.updateUserKdfParams).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("when kdf is invalid", async () => {
|
||||
// Arrange
|
||||
component.kdfConfig = new PBKDF2KdfConfig(1);
|
||||
|
||||
// Act
|
||||
await expect(component.submit()).rejects.toThrow();
|
||||
|
||||
expect(mockChangeKdfService.updateUserKdfParams).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("should update kdf and show success toast", () => {
|
||||
it("should set loading to true during submission", async () => {
|
||||
// Arrange
|
||||
let loadingDuringExecution = false;
|
||||
mockChangeKdfService.updateUserKdfParams.mockImplementation(async () => {
|
||||
loadingDuringExecution = component.loading;
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component.form.controls.masterPassword.setValue(mockMasterPassword);
|
||||
|
||||
// Act
|
||||
await component.submit();
|
||||
|
||||
expect(loadingDuringExecution).toBe(true);
|
||||
expect(component.loading).toBe(false);
|
||||
});
|
||||
|
||||
it("doesn't logout and closes the dialog when feature flag is enabled", async () => {
|
||||
// Arrange
|
||||
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
|
||||
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component.form.controls.masterPassword.setValue(mockMasterPassword);
|
||||
|
||||
// Act
|
||||
await component.submit();
|
||||
|
||||
// Assert
|
||||
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
kdfConfig,
|
||||
mockUserId,
|
||||
);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
message: "encKeySettingsChanged-used-i18n",
|
||||
});
|
||||
expect(mockDialogRef.close).toHaveBeenCalled();
|
||||
expect(mockMessagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a logout and displays a log back in toast when feature flag is disabled", async () => {
|
||||
// Arrange
|
||||
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component.form.controls.masterPassword.setValue(mockMasterPassword);
|
||||
|
||||
// Act
|
||||
await component.submit();
|
||||
|
||||
// Assert
|
||||
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
kdfConfig,
|
||||
mockUserId,
|
||||
);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
title: "encKeySettingsChanged-used-i18n",
|
||||
message: "logBackIn-used-i18n",
|
||||
});
|
||||
expect(mockMessagingService.send).toHaveBeenCalledWith("logout");
|
||||
expect(mockDialogRef.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { FormGroup, FormControl, Validators } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, Observable } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { DIALOG_DATA, ToastService } from "@bitwarden/components";
|
||||
import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components";
|
||||
import { KdfConfig, KdfType } from "@bitwarden/key-management";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
@@ -23,12 +23,13 @@ export class ChangeKdfConfirmationComponent {
|
||||
kdfConfig: KdfConfig;
|
||||
|
||||
form = new FormGroup({
|
||||
masterPassword: new FormControl(null, Validators.required),
|
||||
masterPassword: new FormControl<string | null>(null, Validators.required),
|
||||
});
|
||||
showPassword = false;
|
||||
masterPassword: string;
|
||||
loading = false;
|
||||
|
||||
noLogoutOnKdfChangeFeatureFlag$: Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private messagingService: MessagingService,
|
||||
@@ -36,9 +37,13 @@ export class ChangeKdfConfirmationComponent {
|
||||
private accountService: AccountService,
|
||||
private toastService: ToastService,
|
||||
private changeKdfService: ChangeKdfService,
|
||||
private dialogRef: DialogRef<ChangeKdfConfirmationComponent>,
|
||||
configService: ConfigService,
|
||||
) {
|
||||
this.kdfConfig = params.kdfConfig;
|
||||
this.masterPassword = null;
|
||||
this.noLogoutOnKdfChangeFeatureFlag$ = configService.getFeatureFlag$(
|
||||
FeatureFlag.NoLogoutOnKdfChange,
|
||||
);
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
@@ -46,24 +51,32 @@ export class ChangeKdfConfirmationComponent {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
await this.makeKeyAndSaveAsync();
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: this.i18nService.t("encKeySettingsChanged"),
|
||||
message: this.i18nService.t("logBackIn"),
|
||||
});
|
||||
this.messagingService.send("logout");
|
||||
await this.makeKeyAndSave();
|
||||
if (await firstValueFrom(this.noLogoutOnKdfChangeFeatureFlag$)) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("encKeySettingsChanged"),
|
||||
});
|
||||
this.dialogRef.close();
|
||||
} else {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: this.i18nService.t("encKeySettingsChanged"),
|
||||
message: this.i18nService.t("logBackIn"),
|
||||
});
|
||||
this.messagingService.send("logout");
|
||||
}
|
||||
this.loading = false;
|
||||
};
|
||||
|
||||
private async makeKeyAndSaveAsync() {
|
||||
const masterPassword = this.form.value.masterPassword;
|
||||
private async makeKeyAndSave() {
|
||||
const activeAccountId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
|
||||
const masterPassword = this.form.value.masterPassword!;
|
||||
|
||||
// Ensure the KDF config is valid.
|
||||
this.kdfConfig.validateKdfConfigForSetting();
|
||||
|
||||
const activeAccountId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
|
||||
await this.changeKdfService.updateUserKdfParams(
|
||||
masterPassword,
|
||||
this.kdfConfig,
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
<h2 bitTypography="h2">{{ "encKeySettings" | i18n }}</h2>
|
||||
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
|
||||
<p bitTypography="body1">
|
||||
{{ "higherKDFIterations" | i18n }}
|
||||
<h2 bitTypography="h2" class="tw-mt-6">
|
||||
{{ "encKeySettings" | i18n }}
|
||||
</h2>
|
||||
@if (!(noLogoutOnKdfChangeFeatureFlag$ | async)) {
|
||||
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
|
||||
}
|
||||
<p bitTypography="body1" class="tw-mt-4">
|
||||
{{ "encryptionKeySettingsHowShouldWeEncryptYourData" | i18n }}
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
{{
|
||||
"kdfToHighWarningIncreaseInIncrements"
|
||||
| i18n: (isPBKDF2(kdfConfig) ? ("incrementsOf100,000" | i18n) : ("smallIncrements" | i18n))
|
||||
}}
|
||||
{{ "encryptionKeySettingsIncreaseImproveSecurity" | i18n }}
|
||||
</p>
|
||||
<form [formGroup]="formGroup" autocomplete="off">
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-x-4">
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field>
|
||||
<bit-label
|
||||
>{{ "kdfAlgorithm" | i18n }}
|
||||
<a
|
||||
class="tw-ml-auto"
|
||||
<bit-label>
|
||||
{{ "algorithm" | i18n }}
|
||||
<button
|
||||
type="button"
|
||||
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-p-0"
|
||||
[bitPopoverTriggerFor]="algorithmPopover"
|
||||
appA11yTitle="{{ 'encryptionKeySettingsAlgorithmPopoverTitle' | i18n }}"
|
||||
bitLink
|
||||
href="https://bitwarden.com/help/kdf-algorithms"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
appA11yTitle="{{ 'learnMoreAboutEncryptionAlgorithms' | i18n }}"
|
||||
slot="end"
|
||||
>
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
</a>
|
||||
</button>
|
||||
</bit-label>
|
||||
<bit-select formControlName="kdf">
|
||||
<bit-option
|
||||
@@ -35,33 +34,12 @@
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
<bit-form-field formGroupName="kdfConfig" *ngIf="isArgon2(kdfConfig)">
|
||||
<bit-label>{{ "kdfMemory" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
formControlName="memory"
|
||||
type="number"
|
||||
[min]="ARGON2_MEMORY.min"
|
||||
[max]="ARGON2_MEMORY.max"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6">
|
||||
<div class="tw-mb-0">
|
||||
<bit-form-field formGroupName="kdfConfig" *ngIf="isPBKDF2(kdfConfig)">
|
||||
@if (isPBKDF2(kdfConfig)) {
|
||||
<bit-form-field formGroupName="kdfConfig">
|
||||
<bit-label>
|
||||
{{ "kdfIterations" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
class="tw-ml-auto"
|
||||
href="https://bitwarden.com/help/what-encryption-is-used/#changing-kdf-iterations"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
appA11yTitle="{{ 'learnMoreAboutKDFIterations' | i18n }}"
|
||||
slot="end"
|
||||
>
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
@@ -72,34 +50,49 @@
|
||||
/>
|
||||
<bit-hint>{{ "kdfIterationRecommends" | i18n }}</bit-hint>
|
||||
</bit-form-field>
|
||||
<ng-container *ngIf="isArgon2(kdfConfig)">
|
||||
<bit-form-field formGroupName="kdfConfig">
|
||||
<bit-label>
|
||||
{{ "kdfIterations" | i18n }}
|
||||
</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
formControlName="iterations"
|
||||
[min]="ARGON2_ITERATIONS.min"
|
||||
[max]="ARGON2_ITERATIONS.max"
|
||||
/>
|
||||
</bit-form-field>
|
||||
<bit-form-field formGroupName="kdfConfig">
|
||||
<bit-label>
|
||||
{{ "kdfParallelism" | i18n }}
|
||||
</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
formControlName="parallelism"
|
||||
[min]="ARGON2_PARALLELISM.min"
|
||||
[max]="ARGON2_PARALLELISM.max"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</ng-container>
|
||||
</div>
|
||||
} @else if (isArgon2(kdfConfig)) {
|
||||
<bit-form-field formGroupName="kdfConfig">
|
||||
<bit-label>{{ "kdfMemory" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
formControlName="memory"
|
||||
type="number"
|
||||
[min]="ARGON2_MEMORY.min"
|
||||
[max]="ARGON2_MEMORY.max"
|
||||
/>
|
||||
</bit-form-field>
|
||||
}
|
||||
</div>
|
||||
@if (isArgon2(kdfConfig)) {
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field formGroupName="kdfConfig">
|
||||
<bit-label>
|
||||
{{ "kdfIterations" | i18n }}
|
||||
</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
formControlName="iterations"
|
||||
[min]="ARGON2_ITERATIONS.min"
|
||||
[max]="ARGON2_ITERATIONS.max"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div class="tw-col-span-6">
|
||||
<bit-form-field formGroupName="kdfConfig">
|
||||
<bit-label>
|
||||
{{ "kdfParallelism" | i18n }}
|
||||
</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
formControlName="parallelism"
|
||||
[min]="ARGON2_PARALLELISM.min"
|
||||
[max]="ARGON2_PARALLELISM.max"
|
||||
/>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
(click)="openConfirmationModal()"
|
||||
@@ -107,7 +100,27 @@
|
||||
buttonType="primary"
|
||||
bitButton
|
||||
bitFormButton
|
||||
class="tw-mt-2"
|
||||
>
|
||||
{{ "changeKdf" | i18n }}
|
||||
{{ "updateEncryptionSettings" | i18n }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<bit-popover [title]="'encryptionKeySettingsAlgorithmPopoverTitle' | i18n" #algorithmPopover>
|
||||
<ul class="tw-mt-2 tw-mb-0 tw-ps-4">
|
||||
<li class="tw-mb-2">{{ "encryptionKeySettingsAlgorithmPopoverPBKDF2" | i18n }}</li>
|
||||
<li>{{ "encryptionKeySettingsAlgorithmPopoverArgon2Id" | i18n }}</li>
|
||||
</ul>
|
||||
<div class="tw-mt-4 tw-mb-1">
|
||||
<a
|
||||
href="https://bitwarden.com/help/kdf-algorithms/"
|
||||
bitLink
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
appA11yTitle="{{ 'learnMoreAboutEncryptionAlgorithms' | i18n }}"
|
||||
>
|
||||
{{ "learnMore" | i18n }}
|
||||
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
</bit-popover>
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { FormBuilder, FormControl } from "@angular/forms";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService, PopoverModule, CalloutModule } from "@bitwarden/components";
|
||||
import {
|
||||
KdfConfigService,
|
||||
Argon2KdfConfig,
|
||||
PBKDF2KdfConfig,
|
||||
KdfType,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
import { ChangeKdfComponent } from "./change-kdf.component";
|
||||
|
||||
describe("ChangeKdfComponent", () => {
|
||||
let component: ChangeKdfComponent;
|
||||
let fixture: ComponentFixture<ChangeKdfComponent>;
|
||||
|
||||
// Mock Services
|
||||
let mockDialogService: MockProxy<DialogService>;
|
||||
let mockKdfConfigService: MockProxy<KdfConfigService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let accountService: FakeAccountService;
|
||||
let formBuilder: FormBuilder;
|
||||
|
||||
const mockUserId = "user-id" as UserId;
|
||||
|
||||
// Helper functions for validation testing
|
||||
function expectPBKDF2Validation(
|
||||
iterationsControl: FormControl<number | null>,
|
||||
memoryControl: FormControl<number | null>,
|
||||
parallelismControl: FormControl<number | null>,
|
||||
) {
|
||||
// Assert current validators state
|
||||
expect(iterationsControl.hasError("required")).toBe(false);
|
||||
expect(iterationsControl.hasError("min")).toBe(false);
|
||||
expect(iterationsControl.hasError("max")).toBe(false);
|
||||
expect(memoryControl.validator).toBeNull();
|
||||
expect(parallelismControl.validator).toBeNull();
|
||||
|
||||
// Test validation boundaries
|
||||
iterationsControl.setValue(PBKDF2KdfConfig.ITERATIONS.min - 1);
|
||||
expect(iterationsControl.hasError("min")).toBe(true);
|
||||
|
||||
iterationsControl.setValue(PBKDF2KdfConfig.ITERATIONS.max + 1);
|
||||
expect(iterationsControl.hasError("max")).toBe(true);
|
||||
}
|
||||
|
||||
function expectArgon2Validation(
|
||||
iterationsControl: FormControl<number | null>,
|
||||
memoryControl: FormControl<number | null>,
|
||||
parallelismControl: FormControl<number | null>,
|
||||
) {
|
||||
// Assert current validators state
|
||||
expect(iterationsControl.hasError("required")).toBe(false);
|
||||
expect(memoryControl.hasError("required")).toBe(false);
|
||||
expect(parallelismControl.hasError("required")).toBe(false);
|
||||
|
||||
// Test validation boundaries - min values
|
||||
iterationsControl.setValue(Argon2KdfConfig.ITERATIONS.min - 1);
|
||||
expect(iterationsControl.hasError("min")).toBe(true);
|
||||
|
||||
memoryControl.setValue(Argon2KdfConfig.MEMORY.min - 1);
|
||||
expect(memoryControl.hasError("min")).toBe(true);
|
||||
|
||||
parallelismControl.setValue(Argon2KdfConfig.PARALLELISM.min - 1);
|
||||
expect(parallelismControl.hasError("min")).toBe(true);
|
||||
|
||||
// Test validation boundaries - max values
|
||||
iterationsControl.setValue(Argon2KdfConfig.ITERATIONS.max + 1);
|
||||
expect(iterationsControl.hasError("max")).toBe(true);
|
||||
|
||||
memoryControl.setValue(Argon2KdfConfig.MEMORY.max + 1);
|
||||
expect(memoryControl.hasError("max")).toBe(true);
|
||||
|
||||
parallelismControl.setValue(Argon2KdfConfig.PARALLELISM.max + 1);
|
||||
expect(parallelismControl.hasError("max")).toBe(true);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockDialogService = mock<DialogService>();
|
||||
mockKdfConfigService = mock<KdfConfigService>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
formBuilder = new FormBuilder();
|
||||
|
||||
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
|
||||
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ChangeKdfComponent],
|
||||
imports: [SharedModule, PopoverModule, CalloutModule],
|
||||
providers: [
|
||||
{ provide: DialogService, useValue: mockDialogService },
|
||||
{ provide: KdfConfigService, useValue: mockKdfConfigService },
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: FormBuilder, useValue: formBuilder },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("Component Initialization", () => {
|
||||
describe("given PBKDF2 configuration", () => {
|
||||
it("should initialize form with PBKDF2 values and validators when component loads", async () => {
|
||||
// Arrange
|
||||
const mockPBKDF2Config = new PBKDF2KdfConfig(600_000);
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
|
||||
|
||||
// Act
|
||||
fixture = TestBed.createComponent(ChangeKdfComponent);
|
||||
component = fixture.componentInstance;
|
||||
await component.ngOnInit();
|
||||
|
||||
// Extract form controls
|
||||
const formGroup = component["formGroup"];
|
||||
|
||||
// Assert form values
|
||||
expect(formGroup.controls.kdf.value).toBe(KdfType.PBKDF2_SHA256);
|
||||
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
|
||||
expect(kdfConfigFormGroup.controls.iterations.value).toBe(600_000);
|
||||
expect(kdfConfigFormGroup.controls.memory.value).toBeNull();
|
||||
expect(kdfConfigFormGroup.controls.parallelism.value).toBeNull();
|
||||
expect(component.kdfConfig).toEqual(mockPBKDF2Config);
|
||||
|
||||
// Assert validators
|
||||
expectPBKDF2Validation(
|
||||
kdfConfigFormGroup.controls.iterations,
|
||||
kdfConfigFormGroup.controls.memory,
|
||||
kdfConfigFormGroup.controls.parallelism,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given Argon2id configuration", () => {
|
||||
it("should initialize form with Argon2id values and validators when component loads", async () => {
|
||||
// Arrange
|
||||
const mockArgon2Config = new Argon2KdfConfig(3, 64, 4);
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2Config);
|
||||
|
||||
// Act
|
||||
fixture = TestBed.createComponent(ChangeKdfComponent);
|
||||
component = fixture.componentInstance;
|
||||
await component.ngOnInit();
|
||||
|
||||
// Extract form controls
|
||||
const formGroup = component["formGroup"];
|
||||
|
||||
// Assert form values
|
||||
expect(formGroup.controls.kdf.value).toBe(KdfType.Argon2id);
|
||||
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
|
||||
expect(kdfConfigFormGroup.controls.iterations.value).toBe(3);
|
||||
expect(kdfConfigFormGroup.controls.memory.value).toBe(64);
|
||||
expect(kdfConfigFormGroup.controls.parallelism.value).toBe(4);
|
||||
expect(component.kdfConfig).toEqual(mockArgon2Config);
|
||||
|
||||
// Assert validators
|
||||
expectArgon2Validation(
|
||||
kdfConfigFormGroup.controls.iterations,
|
||||
kdfConfigFormGroup.controls.memory,
|
||||
kdfConfigFormGroup.controls.parallelism,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, false],
|
||||
[false, true],
|
||||
])(
|
||||
"should show log out banner = %s when feature flag observable is %s",
|
||||
async (showLogOutBanner, forceUpgradeKdfFeatureFlag) => {
|
||||
// Arrange
|
||||
const mockPBKDF2Config = new PBKDF2KdfConfig(600_000);
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
|
||||
mockConfigService.getFeatureFlag$.mockReturnValue(of(forceUpgradeKdfFeatureFlag));
|
||||
|
||||
// Act
|
||||
fixture = TestBed.createComponent(ChangeKdfComponent);
|
||||
component = fixture.componentInstance;
|
||||
await component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const calloutElement = fixture.debugElement.query((el) =>
|
||||
el.nativeElement.textContent?.includes("kdfSettingsChangeLogoutWarning"),
|
||||
);
|
||||
|
||||
if (showLogOutBanner) {
|
||||
expect(calloutElement).not.toBeNull();
|
||||
expect(calloutElement.nativeElement.textContent).toContain(
|
||||
"kdfSettingsChangeLogoutWarning-used-i18n",
|
||||
);
|
||||
} else {
|
||||
expect(calloutElement).toBeNull();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("KDF Type Switching", () => {
|
||||
describe("switching from PBKDF2 to Argon2id", () => {
|
||||
beforeEach(async () => {
|
||||
// Setup component with initial PBKDF2 configuration
|
||||
const mockPBKDF2Config = new PBKDF2KdfConfig(600_001);
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
|
||||
|
||||
fixture = TestBed.createComponent(ChangeKdfComponent);
|
||||
component = fixture.componentInstance;
|
||||
await component.ngOnInit();
|
||||
});
|
||||
|
||||
it("should update form structure and default values when KDF type changes to Argon2id", () => {
|
||||
// Arrange
|
||||
const formGroup = component["formGroup"];
|
||||
|
||||
// Act - change KDF type to Argon2id
|
||||
formGroup.controls.kdf.setValue(KdfType.Argon2id);
|
||||
|
||||
// Assert form values update to Argon2id defaults
|
||||
expect(formGroup.controls.kdf.value).toBe(KdfType.Argon2id);
|
||||
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
|
||||
expect(kdfConfigFormGroup.controls.iterations.value).toBe(3); // Argon2id default
|
||||
expect(kdfConfigFormGroup.controls.memory.value).toBe(64); // Argon2id default
|
||||
expect(kdfConfigFormGroup.controls.parallelism.value).toBe(4); // Argon2id default
|
||||
});
|
||||
|
||||
it("should update validators when KDF type changes to Argon2id", () => {
|
||||
// Arrange
|
||||
const formGroup = component["formGroup"];
|
||||
|
||||
// Act - change KDF type to Argon2id
|
||||
formGroup.controls.kdf.setValue(KdfType.Argon2id);
|
||||
|
||||
// Assert validators update to Argon2id validation rules
|
||||
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
|
||||
expectArgon2Validation(
|
||||
kdfConfigFormGroup.controls.iterations,
|
||||
kdfConfigFormGroup.controls.memory,
|
||||
kdfConfigFormGroup.controls.parallelism,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("switching from Argon2id to PBKDF2", () => {
|
||||
beforeEach(async () => {
|
||||
// Setup component with initial Argon2id configuration
|
||||
const mockArgon2IdConfig = new Argon2KdfConfig(4, 65, 5);
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2IdConfig);
|
||||
|
||||
fixture = TestBed.createComponent(ChangeKdfComponent);
|
||||
component = fixture.componentInstance;
|
||||
await component.ngOnInit();
|
||||
});
|
||||
|
||||
it("should update form structure and default values when KDF type changes to PBKDF2", () => {
|
||||
// Arrange
|
||||
const formGroup = component["formGroup"];
|
||||
|
||||
// Act - change KDF type back to PBKDF2
|
||||
formGroup.controls.kdf.setValue(KdfType.PBKDF2_SHA256);
|
||||
|
||||
// Assert form values update to PBKDF2 defaults
|
||||
expect(formGroup.controls.kdf.value).toBe(KdfType.PBKDF2_SHA256);
|
||||
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
|
||||
expect(kdfConfigFormGroup.controls.iterations.value).toBe(600_000); // PBKDF2 default
|
||||
expect(kdfConfigFormGroup.controls.memory.value).toBeNull(); // PBKDF2 doesn't use memory
|
||||
expect(kdfConfigFormGroup.controls.parallelism.value).toBeNull(); // PBKDF2 doesn't use parallelism
|
||||
});
|
||||
|
||||
it("should update validators when KDF type changes to PBKDF2", () => {
|
||||
// Arrange
|
||||
const formGroup = component["formGroup"];
|
||||
|
||||
// Act - change KDF type back to PBKDF2
|
||||
formGroup.controls.kdf.setValue(KdfType.PBKDF2_SHA256);
|
||||
|
||||
// Assert validators update to PBKDF2 validation rules
|
||||
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
|
||||
expectPBKDF2Validation(
|
||||
kdfConfigFormGroup.controls.iterations,
|
||||
kdfConfigFormGroup.controls.memory,
|
||||
kdfConfigFormGroup.controls.parallelism,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("openConfirmationModal", () => {
|
||||
describe("when form is valid", () => {
|
||||
it("should open confirmation modal with PBKDF2 config when form is submitted", async () => {
|
||||
// Arrange
|
||||
const mockPBKDF2Config = new PBKDF2KdfConfig(600_001);
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
|
||||
|
||||
fixture = TestBed.createComponent(ChangeKdfComponent);
|
||||
component = fixture.componentInstance;
|
||||
await component.ngOnInit();
|
||||
|
||||
// Act
|
||||
await component.openConfirmationModal();
|
||||
|
||||
// Assert
|
||||
expect(mockDialogService.open).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
kdfConfig: mockPBKDF2Config,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should open confirmation modal with Argon2id config when form is submitted", async () => {
|
||||
// Arrange
|
||||
const mockArgon2Config = new Argon2KdfConfig(4, 65, 5);
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2Config);
|
||||
|
||||
fixture = TestBed.createComponent(ChangeKdfComponent);
|
||||
component = fixture.componentInstance;
|
||||
await component.ngOnInit();
|
||||
|
||||
// Act
|
||||
await component.openConfirmationModal();
|
||||
|
||||
// Assert
|
||||
expect(mockDialogService.open).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
kdfConfig: mockArgon2Config,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not open modal when form is invalid", async () => {
|
||||
// Arrange
|
||||
const mockPBKDF2Config = new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.min - 1);
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
|
||||
|
||||
fixture = TestBed.createComponent(ChangeKdfComponent);
|
||||
component = fixture.componentInstance;
|
||||
await component.ngOnInit();
|
||||
|
||||
// Act
|
||||
await component.openConfirmationModal();
|
||||
|
||||
// Assert
|
||||
expect(mockDialogService.open).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormBuilder, FormControl, ValidatorFn, Validators } from "@angular/forms";
|
||||
import { Subject, firstValueFrom, takeUntil } from "rxjs";
|
||||
import { FormBuilder, FormControl, Validators } from "@angular/forms";
|
||||
import { Subject, firstValueFrom, takeUntil, Observable } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
KdfConfigService,
|
||||
@@ -31,11 +31,11 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
protected formGroup = this.formBuilder.group({
|
||||
kdf: new FormControl(KdfType.PBKDF2_SHA256, [Validators.required]),
|
||||
kdf: new FormControl<KdfType>(KdfType.PBKDF2_SHA256, [Validators.required]),
|
||||
kdfConfig: this.formBuilder.group({
|
||||
iterations: [this.kdfConfig.iterations],
|
||||
memory: [null as number],
|
||||
parallelism: [null as number],
|
||||
iterations: new FormControl<number | null>(null),
|
||||
memory: new FormControl<number | null>(null),
|
||||
parallelism: new FormControl<number | null>(null),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -45,95 +45,102 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
|
||||
protected ARGON2_MEMORY = Argon2KdfConfig.MEMORY;
|
||||
protected ARGON2_PARALLELISM = Argon2KdfConfig.PARALLELISM;
|
||||
|
||||
noLogoutOnKdfChangeFeatureFlag$: Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
private dialogService: DialogService,
|
||||
private kdfConfigService: KdfConfigService,
|
||||
private accountService: AccountService,
|
||||
private formBuilder: FormBuilder,
|
||||
configService: ConfigService,
|
||||
) {
|
||||
this.kdfOptions = [
|
||||
{ name: "PBKDF2 SHA-256", value: KdfType.PBKDF2_SHA256 },
|
||||
{ name: "Argon2id", value: KdfType.Argon2id },
|
||||
];
|
||||
this.noLogoutOnKdfChangeFeatureFlag$ = configService.getFeatureFlag$(
|
||||
FeatureFlag.NoLogoutOnKdfChange,
|
||||
);
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
|
||||
this.formGroup.get("kdf").setValue(this.kdfConfig.kdfType);
|
||||
this.formGroup.controls.kdf.setValue(this.kdfConfig.kdfType);
|
||||
this.setFormControlValues(this.kdfConfig);
|
||||
this.setFormValidators(this.kdfConfig.kdfType);
|
||||
|
||||
this.formGroup
|
||||
.get("kdf")
|
||||
.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
this.formGroup.controls.kdf.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((newValue) => {
|
||||
this.updateKdfConfig(newValue);
|
||||
this.updateKdfConfig(newValue!);
|
||||
});
|
||||
}
|
||||
private updateKdfConfig(newValue: KdfType) {
|
||||
let config: KdfConfig;
|
||||
const validators: { [key: string]: ValidatorFn[] } = {
|
||||
iterations: [],
|
||||
memory: [],
|
||||
parallelism: [],
|
||||
};
|
||||
|
||||
switch (newValue) {
|
||||
case KdfType.PBKDF2_SHA256:
|
||||
config = new PBKDF2KdfConfig();
|
||||
validators.iterations = [
|
||||
Validators.required,
|
||||
Validators.min(PBKDF2KdfConfig.ITERATIONS.min),
|
||||
Validators.max(PBKDF2KdfConfig.ITERATIONS.max),
|
||||
];
|
||||
break;
|
||||
case KdfType.Argon2id:
|
||||
config = new Argon2KdfConfig();
|
||||
validators.iterations = [
|
||||
Validators.required,
|
||||
Validators.min(Argon2KdfConfig.ITERATIONS.min),
|
||||
Validators.max(Argon2KdfConfig.ITERATIONS.max),
|
||||
];
|
||||
validators.memory = [
|
||||
Validators.required,
|
||||
Validators.min(Argon2KdfConfig.MEMORY.min),
|
||||
Validators.max(Argon2KdfConfig.MEMORY.max),
|
||||
];
|
||||
validators.parallelism = [
|
||||
Validators.required,
|
||||
Validators.min(Argon2KdfConfig.PARALLELISM.min),
|
||||
Validators.max(Argon2KdfConfig.PARALLELISM.max),
|
||||
];
|
||||
break;
|
||||
default:
|
||||
throw new Error("Unknown KDF type.");
|
||||
}
|
||||
|
||||
this.kdfConfig = config;
|
||||
this.setFormValidators(validators);
|
||||
this.setFormValidators(newValue);
|
||||
this.setFormControlValues(this.kdfConfig);
|
||||
}
|
||||
|
||||
private setFormValidators(validators: { [key: string]: ValidatorFn[] }) {
|
||||
this.setValidators("kdfConfig.iterations", validators.iterations);
|
||||
this.setValidators("kdfConfig.memory", validators.memory);
|
||||
this.setValidators("kdfConfig.parallelism", validators.parallelism);
|
||||
}
|
||||
private setValidators(controlName: string, validators: ValidatorFn[]) {
|
||||
const control = this.formGroup.get(controlName);
|
||||
if (control) {
|
||||
control.setValidators(validators);
|
||||
control.updateValueAndValidity();
|
||||
private setFormValidators(kdfType: KdfType) {
|
||||
const kdfConfigFormGroup = this.formGroup.controls.kdfConfig;
|
||||
switch (kdfType) {
|
||||
case KdfType.PBKDF2_SHA256:
|
||||
kdfConfigFormGroup.controls.iterations.setValidators([
|
||||
Validators.required,
|
||||
Validators.min(PBKDF2KdfConfig.ITERATIONS.min),
|
||||
Validators.max(PBKDF2KdfConfig.ITERATIONS.max),
|
||||
]);
|
||||
kdfConfigFormGroup.controls.memory.setValidators([]);
|
||||
kdfConfigFormGroup.controls.parallelism.setValidators([]);
|
||||
break;
|
||||
case KdfType.Argon2id:
|
||||
kdfConfigFormGroup.controls.iterations.setValidators([
|
||||
Validators.required,
|
||||
Validators.min(Argon2KdfConfig.ITERATIONS.min),
|
||||
Validators.max(Argon2KdfConfig.ITERATIONS.max),
|
||||
]);
|
||||
kdfConfigFormGroup.controls.memory.setValidators([
|
||||
Validators.required,
|
||||
Validators.min(Argon2KdfConfig.MEMORY.min),
|
||||
Validators.max(Argon2KdfConfig.MEMORY.max),
|
||||
]);
|
||||
kdfConfigFormGroup.controls.parallelism.setValidators([
|
||||
Validators.required,
|
||||
Validators.min(Argon2KdfConfig.PARALLELISM.min),
|
||||
Validators.max(Argon2KdfConfig.PARALLELISM.max),
|
||||
]);
|
||||
break;
|
||||
default:
|
||||
throw new Error("Unknown KDF type.");
|
||||
}
|
||||
kdfConfigFormGroup.controls.iterations.updateValueAndValidity();
|
||||
kdfConfigFormGroup.controls.memory.updateValueAndValidity();
|
||||
kdfConfigFormGroup.controls.parallelism.updateValueAndValidity();
|
||||
}
|
||||
|
||||
private setFormControlValues(kdfConfig: KdfConfig) {
|
||||
this.formGroup.get("kdfConfig").reset();
|
||||
const kdfConfigFormGroup = this.formGroup.controls.kdfConfig;
|
||||
kdfConfigFormGroup.reset();
|
||||
if (kdfConfig.kdfType === KdfType.PBKDF2_SHA256) {
|
||||
this.formGroup.get("kdfConfig.iterations").setValue(kdfConfig.iterations);
|
||||
kdfConfigFormGroup.controls.iterations.setValue(kdfConfig.iterations);
|
||||
} else if (kdfConfig.kdfType === KdfType.Argon2id) {
|
||||
this.formGroup.get("kdfConfig.iterations").setValue(kdfConfig.iterations);
|
||||
this.formGroup.get("kdfConfig.memory").setValue(kdfConfig.memory);
|
||||
this.formGroup.get("kdfConfig.parallelism").setValue(kdfConfig.parallelism);
|
||||
kdfConfigFormGroup.controls.iterations.setValue(kdfConfig.iterations);
|
||||
kdfConfigFormGroup.controls.memory.setValue(kdfConfig.memory);
|
||||
kdfConfigFormGroup.controls.parallelism.setValue(kdfConfig.parallelism);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,12 +162,14 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
|
||||
if (this.formGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const kdfConfigFormGroup = this.formGroup.controls.kdfConfig;
|
||||
if (this.kdfConfig.kdfType === KdfType.PBKDF2_SHA256) {
|
||||
this.kdfConfig.iterations = this.formGroup.get("kdfConfig.iterations").value;
|
||||
this.kdfConfig.iterations = kdfConfigFormGroup.controls.iterations.value!;
|
||||
} else if (this.kdfConfig.kdfType === KdfType.Argon2id) {
|
||||
this.kdfConfig.iterations = this.formGroup.get("kdfConfig.iterations").value;
|
||||
this.kdfConfig.memory = this.formGroup.get("kdfConfig.memory").value;
|
||||
this.kdfConfig.parallelism = this.formGroup.get("kdfConfig.parallelism").value;
|
||||
this.kdfConfig.iterations = kdfConfigFormGroup.controls.iterations.value!;
|
||||
this.kdfConfig.memory = kdfConfigFormGroup.controls.memory.value!;
|
||||
this.kdfConfig.parallelism = kdfConfigFormGroup.controls.parallelism.value!;
|
||||
}
|
||||
this.dialogService.open(ChangeKdfConfirmationComponent, {
|
||||
data: {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { PopoverModule } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
import { ChangeKdfConfirmationComponent } from "./change-kdf-confirmation.component";
|
||||
import { ChangeKdfComponent } from "./change-kdf.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, SharedModule],
|
||||
imports: [CommonModule, SharedModule, PopoverModule],
|
||||
declarations: [ChangeKdfComponent, ChangeKdfConfirmationComponent],
|
||||
exports: [ChangeKdfComponent, ChangeKdfConfirmationComponent],
|
||||
})
|
||||
|
||||
@@ -67,7 +67,7 @@ export class VaultItemsComponent<C extends CipherViewLike> {
|
||||
@Input() userCanArchive: boolean;
|
||||
@Input() enforceOrgDataOwnershipPolicy: boolean;
|
||||
|
||||
private restrictedPolicies = toSignal(this.restrictedItemTypesService.restricted$);
|
||||
private readonly restrictedPolicies = toSignal(this.restrictedItemTypesService.restricted$);
|
||||
|
||||
private _ciphers?: C[] = [];
|
||||
@Input() get ciphers(): C[] {
|
||||
|
||||
@@ -692,6 +692,12 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
}
|
||||
|
||||
async archive(cipher: C) {
|
||||
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
|
||||
|
||||
if (!repromptPassed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "archiveItem" },
|
||||
content: { key: "archiveItemConfirmDesc" },
|
||||
@@ -702,10 +708,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
return;
|
||||
}
|
||||
|
||||
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
|
||||
if (!repromptPassed) {
|
||||
return;
|
||||
}
|
||||
const activeUserId = await firstValueFrom(this.userId$);
|
||||
try {
|
||||
await this.cipherArchiveService.archiveWithServer(cipher.id as CipherId, activeUserId);
|
||||
@@ -724,6 +726,10 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
}
|
||||
|
||||
async bulkArchive(ciphers: C[]) {
|
||||
if (!(await this.repromptCipher(ciphers))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "archiveBulkItems" },
|
||||
content: { key: "archiveBulkItemsConfirmDesc" },
|
||||
@@ -734,10 +740,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await this.repromptCipher(ciphers))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeUserId = await firstValueFrom(this.userId$);
|
||||
const cipherIds = ciphers.map((c) => c.id as CipherId);
|
||||
try {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"criticalApplications": {
|
||||
"message": "Critical applications"
|
||||
},
|
||||
"noCriticalAppsAtRisk":{
|
||||
"noCriticalAppsAtRisk": {
|
||||
"message": "No critical applications at risk"
|
||||
},
|
||||
"accessIntelligence": {
|
||||
@@ -1719,7 +1719,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"dontAskAgainOnThisDeviceFor30Days": {
|
||||
"message": "Don't ask again on this device for 30 days"
|
||||
},
|
||||
@@ -2090,9 +2089,6 @@
|
||||
"encKeySettings": {
|
||||
"message": "Encryption key settings"
|
||||
},
|
||||
"kdfAlgorithm": {
|
||||
"message": "KDF algorithm"
|
||||
},
|
||||
"kdfIterations": {
|
||||
"message": "KDF iterations"
|
||||
},
|
||||
@@ -2127,9 +2123,6 @@
|
||||
"argon2Desc": {
|
||||
"message": "Higher KDF iterations, memory, and parallelism can help protect your master password from being brute forced by an attacker."
|
||||
},
|
||||
"changeKdf": {
|
||||
"message": "Change KDF"
|
||||
},
|
||||
"encKeySettingsChanged": {
|
||||
"message": "Encryption key settings saved"
|
||||
},
|
||||
@@ -2146,22 +2139,22 @@
|
||||
"message": "Proceeding will also log you out of your current session, requiring you to log back in. You will also be prompted for two-step login again, if set up. Active sessions on other devices may continue to remain active for up to one hour."
|
||||
},
|
||||
"newDeviceLoginProtection": {
|
||||
"message":"New device login"
|
||||
"message": "New device login"
|
||||
},
|
||||
"turnOffNewDeviceLoginProtection": {
|
||||
"message":"Turn off new device login protection"
|
||||
"message": "Turn off new device login protection"
|
||||
},
|
||||
"turnOnNewDeviceLoginProtection": {
|
||||
"message":"Turn on new device login protection"
|
||||
"message": "Turn on new device login protection"
|
||||
},
|
||||
"turnOffNewDeviceLoginProtectionModalDesc": {
|
||||
"message":"Proceed below to turn off the verification emails bitwarden sends when you login from a new device."
|
||||
"message": "Proceed below to turn off the verification emails bitwarden sends when you login from a new device."
|
||||
},
|
||||
"turnOnNewDeviceLoginProtectionModalDesc": {
|
||||
"message":"Proceed below to have bitwarden send you verification emails when you login from a new device."
|
||||
"message": "Proceed below to have bitwarden send you verification emails when you login from a new device."
|
||||
},
|
||||
"turnOffNewDeviceLoginProtectionWarning": {
|
||||
"message":"With new device login protection turned off, anyone with your master password can access your account from any device. To protect your account without verification emails, set up two-step login."
|
||||
"message": "With new device login protection turned off, anyone with your master password can access your account from any device. To protect your account without verification emails, set up two-step login."
|
||||
},
|
||||
"accountNewDeviceLoginProtectionSaved": {
|
||||
"message": "New device login protection changes saved"
|
||||
@@ -2297,7 +2290,7 @@
|
||||
"selectImportCollection": {
|
||||
"message": "Select a collection"
|
||||
},
|
||||
"importTargetHintCollection": {
|
||||
"importTargetHintCollection": {
|
||||
"message": "Select this option if you want the imported file contents moved to a collection"
|
||||
},
|
||||
"importTargetHintFolder": {
|
||||
@@ -5700,7 +5693,7 @@
|
||||
"message": "All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the ",
|
||||
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the credential lifecycle.'"
|
||||
},
|
||||
"organizationDataOwnershipContentAnchor":{
|
||||
"organizationDataOwnershipContentAnchor": {
|
||||
"message": "credential lifecycle",
|
||||
"description": "This will be used as a hyperlink"
|
||||
},
|
||||
@@ -10374,27 +10367,9 @@
|
||||
"memberAccessReportAuthenticationEnabledFalse": {
|
||||
"message": "Off"
|
||||
},
|
||||
"higherKDFIterations": {
|
||||
"message": "Higher KDF iterations can help protect your master password from being brute forced by an attacker."
|
||||
},
|
||||
"incrementsOf100,000": {
|
||||
"message": "increments of 100,000"
|
||||
},
|
||||
"smallIncrements": {
|
||||
"message": "small increments"
|
||||
},
|
||||
"kdfIterationRecommends": {
|
||||
"message": "We recommend 600,000 or more"
|
||||
},
|
||||
"kdfToHighWarningIncreaseInIncrements": {
|
||||
"message": "For older devices, setting your KDF too high may lead to performance issues. Increase the value in $VALUE$ and test your devices.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"content": "$1",
|
||||
"example": "increments of 100,000"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providerReinstate": {
|
||||
"message": " Contact Customer Support to reinstate your subscription."
|
||||
},
|
||||
@@ -11079,7 +11054,7 @@
|
||||
"orgTrustWarning1": {
|
||||
"message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint."
|
||||
},
|
||||
"trustUser":{
|
||||
"trustUser": {
|
||||
"message": "Trust user"
|
||||
},
|
||||
"sshKeyWrongPassword": {
|
||||
@@ -11115,7 +11090,7 @@
|
||||
"openingExtension": {
|
||||
"message": "Opening the Bitwarden browser extension"
|
||||
},
|
||||
"somethingWentWrong":{
|
||||
"somethingWentWrong": {
|
||||
"message": "Something went wrong..."
|
||||
},
|
||||
"openingExtensionError": {
|
||||
@@ -11202,7 +11177,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"accountDeprovisioningNotification" : {
|
||||
"accountDeprovisioningNotification": {
|
||||
"message": "Administrators now have the ability to delete member accounts that belong to a claimed domain."
|
||||
},
|
||||
"deleteManagedUserWarningDesc": {
|
||||
@@ -11293,14 +11268,14 @@
|
||||
"upgradeForFullEventsMessage": {
|
||||
"message": "Event logs are not stored for your organization. Upgrade to a Teams or Enterprise plan to get full access to organization event logs."
|
||||
},
|
||||
"upgradeEventLogTitleMessage" : {
|
||||
"message" : "Upgrade to see event logs from your organization."
|
||||
"upgradeEventLogTitleMessage": {
|
||||
"message": "Upgrade to see event logs from your organization."
|
||||
},
|
||||
"upgradeEventLogMessage":{
|
||||
"message" : "These events are examples only and do not reflect real events within your Bitwarden organization."
|
||||
"upgradeEventLogMessage": {
|
||||
"message": "These events are examples only and do not reflect real events within your Bitwarden organization."
|
||||
},
|
||||
"viewEvents":{
|
||||
"message" : "View Events"
|
||||
"viewEvents": {
|
||||
"message": "View Events"
|
||||
},
|
||||
"cannotCreateCollection": {
|
||||
"message": "Free organizations may have up to 2 collections. Upgrade to a paid plan to add more collections."
|
||||
@@ -11619,14 +11594,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"unlimitedSecretsAndProjects": {
|
||||
"unlimitedSecretsAndProjects": {
|
||||
"message": "Unlimited secrets and projects"
|
||||
},
|
||||
"providersubscriptionCanceled": {
|
||||
"providersubscriptionCanceled": {
|
||||
"message": "Subscription canceled"
|
||||
},
|
||||
"providersubCanceledmessage": {
|
||||
"message" : "To resubscribe, contact Bitwarden Customer Support."
|
||||
"message": "To resubscribe, contact Bitwarden Customer Support."
|
||||
},
|
||||
"showMore": {
|
||||
"message": "Show more"
|
||||
@@ -11878,5 +11853,32 @@
|
||||
},
|
||||
"viewbusinessplans": {
|
||||
"message": "View business plans"
|
||||
},
|
||||
"updateEncryptionSettings": {
|
||||
"message": "Update encryption settings"
|
||||
},
|
||||
"updateYourEncryptionSettings": {
|
||||
"message": "Update your encryption settings"
|
||||
},
|
||||
"updateSettings": {
|
||||
"message": "Update settings"
|
||||
},
|
||||
"algorithm": {
|
||||
"message": "Algorithm"
|
||||
},
|
||||
"encryptionKeySettingsHowShouldWeEncryptYourData": {
|
||||
"message": "Choose how Bitwarden should encrypt your vault data. All options are secure, but stronger methods offer better protection - especially against brute-force attacks. Bitwarden recommends the default setting for most users."
|
||||
},
|
||||
"encryptionKeySettingsIncreaseImproveSecurity": {
|
||||
"message": "Increasing the values above the default will improve security, but your vault may take longer to unlock as a result."
|
||||
},
|
||||
"encryptionKeySettingsAlgorithmPopoverTitle": {
|
||||
"message": "About encryption algorithms"
|
||||
},
|
||||
"encryptionKeySettingsAlgorithmPopoverPBKDF2": {
|
||||
"message": "PBKDF2-SHA256 is a well-tested encryption method that balances security and performance. Good for all users."
|
||||
},
|
||||
"encryptionKeySettingsAlgorithmPopoverArgon2Id": {
|
||||
"message": "Argon2id offers stronger protection against modern attacks. Best for advanced users with powerful devices."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,10 @@ export class HecOrganizationIntegrationService {
|
||||
);
|
||||
|
||||
if (updatedIntegration !== null) {
|
||||
this._integrations$.next([...this._integrations$.getValue(), updatedIntegration]);
|
||||
const unchangedIntegrations = this._integrations$
|
||||
.getValue()
|
||||
.filter((i) => i.id !== OrganizationIntegrationId);
|
||||
this._integrations$.next([...unchangedIntegrations, updatedIntegration]);
|
||||
}
|
||||
return { mustBeOwner: false, success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
|
||||
import { WebProviderService } from "../services/web-provider.service";
|
||||
|
||||
export type AddExistingOrganizationDialogParams = {
|
||||
provider: Provider;
|
||||
@@ -55,7 +55,7 @@ export class AddExistingOrganizationDialogComponent implements OnInit {
|
||||
|
||||
addExistingOrganization = async (): Promise<void> => {
|
||||
if (this.selectedOrganization) {
|
||||
await this.webProviderService.addOrganizationToProviderVNext(
|
||||
await this.webProviderService.addOrganizationToProvider(
|
||||
this.dialogParams.provider.id,
|
||||
this.selectedOrganization.id,
|
||||
);
|
||||
@@ -1,32 +0,0 @@
|
||||
<bit-dialog [loading]="loading">
|
||||
<span bitDialogTitle>{{ "addExistingOrganization" | i18n }}</span>
|
||||
<ng-container bitDialogContent>
|
||||
<bit-table>
|
||||
<ng-template body>
|
||||
<tr bitRow *ngFor="let o of data.organizations">
|
||||
<td bitCell width="30">
|
||||
<bit-avatar [text]="o.name" [id]="o.id" size="small"></bit-avatar>
|
||||
</td>
|
||||
<td bitCell>
|
||||
{{ o.name }}
|
||||
</td>
|
||||
<td bitCell>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
[bitAction]="add(o, provider$ | async)"
|
||||
class="tw-float-right"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="button" bitButton bitDialogClose>
|
||||
{{ "close" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
@@ -1,101 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, Inject, OnInit } from "@angular/core";
|
||||
import { Observable, switchMap } from "rxjs";
|
||||
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { DIALOG_DATA, DialogRef, DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { WebProviderService } from "../services/web-provider.service";
|
||||
|
||||
interface AddOrganizationDialogData {
|
||||
providerId: string;
|
||||
organizations: Organization[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
templateUrl: "add-organization.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class AddOrganizationComponent implements OnInit {
|
||||
protected provider$: Observable<Provider>;
|
||||
protected loading = true;
|
||||
|
||||
constructor(
|
||||
private dialogRef: DialogRef,
|
||||
@Inject(DIALOG_DATA) protected data: AddOrganizationDialogData,
|
||||
private providerService: ProviderService,
|
||||
private webProviderService: WebProviderService,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private validationService: ValidationService,
|
||||
private dialogService: DialogService,
|
||||
private toastService: ToastService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
if (this.data.providerId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.provider$ = this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => this.providerService.get$(this.data.providerId, userId)),
|
||||
);
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
add(organization: Organization, provider: Provider) {
|
||||
return async () => {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: organization.name,
|
||||
content: {
|
||||
key: "addOrganizationConfirmation",
|
||||
placeholders: [organization.name, provider.name],
|
||||
},
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.webProviderService.addOrganizationToProvider(
|
||||
this.data.providerId,
|
||||
organization.id,
|
||||
);
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
return;
|
||||
}
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("organizationJoinedProvider"),
|
||||
});
|
||||
|
||||
this.dialogRef.close(true);
|
||||
};
|
||||
}
|
||||
|
||||
static open(dialogService: DialogService, data: AddOrganizationDialogData) {
|
||||
return dialogService.open<boolean, AddOrganizationDialogData>(AddOrganizationComponent, {
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
@let isAdminOrServiceUser = isAdminOrServiceUser$ | async;
|
||||
<app-header>
|
||||
<bit-search
|
||||
class="tw-grow"
|
||||
[formControl]="searchControl"
|
||||
[placeholder]="'search' | i18n"
|
||||
></bit-search>
|
||||
<a bitButton routerLink="create" *ngIf="isAdminOrServiceUser" buttonType="primary">
|
||||
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
||||
{{ "newClient" | i18n }}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
(click)="addExistingOrganization()"
|
||||
*ngIf="isAdminOrServiceUser && showAddExisting"
|
||||
>
|
||||
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
|
||||
{{ "addExistingOrganization" | i18n }}
|
||||
</button>
|
||||
</app-header>
|
||||
|
||||
<ng-container *ngIf="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>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!loading">
|
||||
<p *ngIf="dataSource.data.length < 1">{{ "noClientsInList" | i18n }}</p>
|
||||
<ng-container *ngIf="dataSource.data.length >= 1">
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53" class="tw-table tw-w-full">
|
||||
<ng-container header>
|
||||
<th bitCell colspan="2" bitSortable="organizationName">{{ "name" | i18n }}</th>
|
||||
<th bitCell bitSortable="seats">{{ "numberOfUsers" | i18n }}</th>
|
||||
<th bitCell bitSortable="plan">{{ "billingPlan" | i18n }}</th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell width="30">
|
||||
<bit-avatar [text]="row.organizationName" [id]="row.id" size="small"></bit-avatar>
|
||||
</td>
|
||||
<td bitCell width="320">
|
||||
<a [routerLink]="['/organizations', row.organizationId]">{{ row.organizationName }}</a>
|
||||
</td>
|
||||
<td bitCell width="700">
|
||||
<span>{{ row.userCount }}</span>
|
||||
<span *ngIf="row.seats != null"> / {{ row.seats }}</span>
|
||||
</td>
|
||||
<td bitCell width="250" class="tw-flex tw-flex-row tw-items-center">
|
||||
<span>{{ row.plan }}</span>
|
||||
<div appListDropdown>
|
||||
<button
|
||||
*ngIf="isAdminOrServiceUser"
|
||||
[bitMenuTriggerFor]="removeMenu"
|
||||
bitMenuItem
|
||||
buttonType="secondary"
|
||||
type="button"
|
||||
appA11yTitle="{{ 'options' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
<bit-menu #removeMenu>
|
||||
<button bitMenuItem type="button" appStopClick (click)="remove(row)">
|
||||
<span class="tw-text-danger">
|
||||
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
|
||||
{{ "remove" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
</bit-menu>
|
||||
</div>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
@@ -1,179 +0,0 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormControl } from "@angular/forms";
|
||||
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
|
||||
import { combineLatest, firstValueFrom, from, map, Observable, switchMap } from "rxjs";
|
||||
import { debounceTime, first } from "rxjs/operators";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import { ProviderStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import {
|
||||
AvatarModule,
|
||||
DialogService,
|
||||
TableDataSource,
|
||||
TableModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { SharedOrganizationModule } from "@bitwarden/web-vault/app/admin-console/organizations/shared";
|
||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||
|
||||
import { WebProviderService } from "../services/web-provider.service";
|
||||
|
||||
import { AddOrganizationComponent } from "./add-organization.component";
|
||||
|
||||
const DisallowedPlanTypes = [
|
||||
PlanType.Free,
|
||||
PlanType.FamiliesAnnually2019,
|
||||
PlanType.FamiliesAnnually,
|
||||
PlanType.TeamsStarter2023,
|
||||
PlanType.TeamsStarter,
|
||||
];
|
||||
|
||||
@Component({
|
||||
templateUrl: "clients.component.html",
|
||||
imports: [
|
||||
SharedOrganizationModule,
|
||||
HeaderModule,
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
AvatarModule,
|
||||
RouterModule,
|
||||
TableModule,
|
||||
],
|
||||
})
|
||||
export class ClientsComponent {
|
||||
addableOrganizations: Organization[] = [];
|
||||
loading = true;
|
||||
showAddExisting = false;
|
||||
dataSource: TableDataSource<ProviderOrganizationOrganizationDetailsResponse> =
|
||||
new TableDataSource();
|
||||
protected searchControl = new FormControl("", { nonNullable: true });
|
||||
|
||||
protected providerId$: Observable<string> =
|
||||
this.activatedRoute.parent?.params.pipe(map((params) => params.providerId as string)) ??
|
||||
new Observable();
|
||||
|
||||
protected provider$ = combineLatest([
|
||||
this.providerId$,
|
||||
this.accountService.activeAccount$.pipe(getUserId),
|
||||
]).pipe(switchMap(([providerId, userId]) => this.providerService.get$(providerId, userId)));
|
||||
|
||||
protected isAdminOrServiceUser$ = this.provider$.pipe(
|
||||
map(
|
||||
(provider) =>
|
||||
provider?.type === ProviderUserType.ProviderAdmin ||
|
||||
provider?.type === ProviderUserType.ServiceUser,
|
||||
),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private providerService: ProviderService,
|
||||
private apiService: ApiService,
|
||||
private organizationService: OrganizationService,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
private validationService: ValidationService,
|
||||
private webProviderService: WebProviderService,
|
||||
) {
|
||||
this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => {
|
||||
this.searchControl.setValue(queryParams.search);
|
||||
});
|
||||
|
||||
this.provider$
|
||||
.pipe(
|
||||
map((provider) => {
|
||||
if (provider?.providerStatus === ProviderStatusType.Billable) {
|
||||
return from(
|
||||
this.router.navigate(["../manage-client-organizations"], {
|
||||
relativeTo: this.activatedRoute,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return from(this.load());
|
||||
}),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.searchControl.valueChanges
|
||||
.pipe(debounceTime(200), takeUntilDestroyed())
|
||||
.subscribe((searchText) => {
|
||||
this.dataSource.filter = (data) =>
|
||||
data.organizationName.toLowerCase().indexOf(searchText.toLowerCase()) > -1;
|
||||
});
|
||||
}
|
||||
|
||||
async remove(organization: ProviderOrganizationOrganizationDetailsResponse) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: organization.organizationName,
|
||||
content: { key: "detachOrganizationConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const providerId = await firstValueFrom(this.providerId$);
|
||||
await this.webProviderService.detachOrganization(providerId, organization.id);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("detachedOrganization", organization.organizationName),
|
||||
});
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
const providerId = await firstValueFrom(this.providerId$);
|
||||
const response = await this.apiService.getProviderClients(providerId);
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const clients = response.data != null && response.data.length > 0 ? response.data : [];
|
||||
this.dataSource.data = clients;
|
||||
const candidateOrgs = (
|
||||
await firstValueFrom(this.organizationService.organizations$(userId))
|
||||
).filter((o) => o.isOwner && o.providerId == null);
|
||||
const allowedOrgsIds = await Promise.all(
|
||||
candidateOrgs.map((o) => this.organizationApiService.get(o.id)),
|
||||
).then((orgs) =>
|
||||
orgs.filter((o) => !DisallowedPlanTypes.includes(o.planType)).map((o) => o.id),
|
||||
);
|
||||
this.addableOrganizations = candidateOrgs.filter((o) => allowedOrgsIds.includes(o.id));
|
||||
|
||||
this.showAddExisting = this.addableOrganizations.length !== 0;
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
async addExistingOrganization() {
|
||||
const providerId = await firstValueFrom(this.providerId$);
|
||||
const dialogRef = AddOrganizationComponent.open(this.dialogService, {
|
||||
providerId: providerId,
|
||||
organizations: this.addableOrganizations,
|
||||
});
|
||||
|
||||
if (await firstValueFrom(dialogRef.closed)) {
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
|
||||
import { WebProviderService } from "../services/web-provider.service";
|
||||
|
||||
type CreateClientDialogParams = {
|
||||
providerId: string;
|
||||
@@ -1,3 +0,0 @@
|
||||
<app-header [title]="'newClientOrganization' | i18n"></app-header>
|
||||
<p>{{ "newClientOrganizationDesc" | i18n }}</p>
|
||||
<app-organization-plans [providerId]="providerId"></app-organization-plans>
|
||||
@@ -1,27 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnInit, ViewChild } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
|
||||
import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing";
|
||||
|
||||
@Component({
|
||||
selector: "app-create-organization",
|
||||
templateUrl: "create-organization.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class CreateOrganizationComponent implements OnInit {
|
||||
@ViewChild(OrganizationPlansComponent, { static: true })
|
||||
orgPlansComponent: OrganizationPlansComponent;
|
||||
|
||||
providerId: string;
|
||||
|
||||
constructor(private route: ActivatedRoute) {}
|
||||
|
||||
ngOnInit() {
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
this.route.parent.params.subscribe(async (params) => {
|
||||
this.providerId = params.providerId;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
|
||||
import { UpdateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/update-client-organization.request";
|
||||
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
|
||||
import { UpdateProviderOrganizationRequest } from "@bitwarden/common/admin-console/models/request/update-provider-organization.request";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import {
|
||||
DIALOG_DATA,
|
||||
@@ -51,7 +51,7 @@ export class ManageClientNameDialogComponent {
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected dialogParams: ManageClientNameDialogParams,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private providerApiService: ProviderApiServiceAbstraction,
|
||||
private dialogRef: DialogRef<ManageClientNameDialogResultType>,
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
@@ -65,11 +65,11 @@ export class ManageClientNameDialogComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new UpdateClientOrganizationRequest();
|
||||
const request = new UpdateProviderOrganizationRequest();
|
||||
request.assignedSeats = this.dialogParams.organization.seats;
|
||||
request.name = this.formGroup.value.name;
|
||||
|
||||
await this.billingApiService.updateProviderClientOrganization(
|
||||
await this.providerApiService.updateProviderOrganization(
|
||||
this.dialogParams.providerId,
|
||||
this.dialogParams.organization.id,
|
||||
request,
|
||||
@@ -3,11 +3,12 @@
|
||||
import { Component, Inject, OnInit } from "@angular/core";
|
||||
import { FormControl, FormGroup, ValidationErrors, ValidatorFn, Validators } from "@angular/forms";
|
||||
|
||||
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
|
||||
import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { UpdateProviderOrganizationRequest } from "@bitwarden/common/admin-console/models/request/update-provider-organization.request";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { UpdateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/update-client-organization.request";
|
||||
import { ProviderPlanResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
|
||||
@@ -56,6 +57,7 @@ export class ManageClientSubscriptionDialogComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private providerApiService: ProviderApiServiceAbstraction,
|
||||
@Inject(DIALOG_DATA) protected dialogParams: ManageClientSubscriptionDialogParams,
|
||||
private dialogRef: DialogRef<ManageClientSubscriptionDialogResultType>,
|
||||
private i18nService: I18nService,
|
||||
@@ -99,11 +101,11 @@ export class ManageClientSubscriptionDialogComponent implements OnInit {
|
||||
}
|
||||
|
||||
try {
|
||||
const request = new UpdateClientOrganizationRequest();
|
||||
const request = new UpdateProviderOrganizationRequest();
|
||||
request.assignedSeats = this.formGroup.value.assignedSeats;
|
||||
request.name = this.dialogParams.organization.organizationName;
|
||||
|
||||
await this.billingApiService.updateProviderClientOrganization(
|
||||
await this.providerApiService.updateProviderOrganization(
|
||||
this.dialogParams.provider.id,
|
||||
this.dialogParams.organization.id,
|
||||
request,
|
||||
@@ -1,25 +1,21 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormControl } from "@angular/forms";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import {
|
||||
firstValueFrom,
|
||||
from,
|
||||
lastValueFrom,
|
||||
map,
|
||||
combineLatest,
|
||||
switchMap,
|
||||
Observable,
|
||||
Subject,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
import { debounceTime, first } from "rxjs/operators";
|
||||
|
||||
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import {
|
||||
ProviderStatusType,
|
||||
ProviderType,
|
||||
ProviderUserType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { ProviderType, ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
@@ -40,7 +36,7 @@ import { SharedOrganizationModule } from "@bitwarden/web-vault/app/admin-console
|
||||
import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.service";
|
||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||
|
||||
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
|
||||
import { WebProviderService } from "../services/web-provider.service";
|
||||
|
||||
import {
|
||||
AddExistingOrganizationDialogComponent,
|
||||
@@ -72,7 +68,7 @@ import { ReplacePipe } from "./replace.pipe";
|
||||
ReplacePipe,
|
||||
],
|
||||
})
|
||||
export class ManageClientsComponent {
|
||||
export class ManageClientsComponent implements OnInit, OnDestroy {
|
||||
loading = true;
|
||||
dataSource: TableDataSource<ProviderOrganizationOrganizationDetailsResponse> =
|
||||
new TableDataSource();
|
||||
@@ -117,10 +113,11 @@ export class ManageClientsComponent {
|
||||
),
|
||||
);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private providerService: ProviderService,
|
||||
private router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
@@ -130,35 +127,31 @@ export class ManageClientsComponent {
|
||||
private billingNotificationService: BillingNotificationService,
|
||||
private configService: ConfigService,
|
||||
private accountService: AccountService,
|
||||
) {
|
||||
this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => {
|
||||
this.searchControl.setValue(queryParams.search);
|
||||
});
|
||||
private providerApiService: ProviderApiServiceAbstraction,
|
||||
) {}
|
||||
|
||||
this.provider$
|
||||
.pipe(
|
||||
map((provider: Provider | undefined) => {
|
||||
if (provider?.providerStatus !== ProviderStatusType.Billable) {
|
||||
return from(
|
||||
this.router.navigate(["../clients"], {
|
||||
relativeTo: this.activatedRoute,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return from(this.load());
|
||||
}),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe();
|
||||
async ngOnInit() {
|
||||
this.activatedRoute.queryParams
|
||||
.pipe(first(), takeUntil(this.destroy$))
|
||||
.subscribe((queryParams) => {
|
||||
this.searchControl.setValue(queryParams.search);
|
||||
});
|
||||
|
||||
await this.load();
|
||||
|
||||
this.searchControl.valueChanges
|
||||
.pipe(debounceTime(200), takeUntilDestroyed())
|
||||
.pipe(debounceTime(200), takeUntil(this.destroy$))
|
||||
.subscribe((searchText) => {
|
||||
this.dataSource.filter = (data) =>
|
||||
data.organizationName.toLowerCase().indexOf(searchText.toLowerCase()) > -1;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const providerId = await firstValueFrom(this.providerId$);
|
||||
@@ -170,7 +163,7 @@ export class ManageClientsComponent {
|
||||
this.newClientButtonLabel = this.i18nService.t("newBusinessUnit");
|
||||
}
|
||||
this.dataSource.data = (
|
||||
await this.billingApiService.getProviderClientOrganizations(providerId)
|
||||
await this.providerApiService.getProviderOrganizations(providerId)
|
||||
).data;
|
||||
this.plans = (await this.billingApiService.getPlans()).data;
|
||||
this.loading = false;
|
||||
@@ -9,7 +9,7 @@
|
||||
<bit-nav-item
|
||||
icon="bwi-provider"
|
||||
[text]="clientsTranslationKey$ | async | i18n"
|
||||
[route]="(isBillable | async) ? 'manage-client-organizations' : 'clients'"
|
||||
route="clients"
|
||||
>
|
||||
<i
|
||||
*ngIf="!provider.enabled && (providerPortalTakeover$ | async)"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { takeUntil } from "rxjs/operators";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { BusinessUnitPortalLogo, Icon, ProviderPortalLogo } from "@bitwarden/assets/svg";
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import { ProviderStatusType, ProviderType } from "@bitwarden/common/admin-console/enums";
|
||||
import { ProviderType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
@@ -43,7 +43,6 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
||||
|
||||
protected logo$: Observable<Icon>;
|
||||
|
||||
protected isBillable: Observable<boolean>;
|
||||
protected canAccessBilling$: Observable<boolean>;
|
||||
|
||||
protected clientsTranslationKey$: Observable<string>;
|
||||
@@ -83,15 +82,7 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
||||
),
|
||||
);
|
||||
|
||||
this.isBillable = this.provider$.pipe(
|
||||
map((provider) => provider?.providerStatus === ProviderStatusType.Billable),
|
||||
);
|
||||
|
||||
this.canAccessBilling$ = combineLatest([this.isBillable, this.provider$]).pipe(
|
||||
map(
|
||||
([hasConsolidatedBilling, provider]) => hasConsolidatedBilling && provider.isProviderAdmin,
|
||||
),
|
||||
);
|
||||
this.canAccessBilling$ = this.provider$.pipe(map((provider) => provider.isProviderAdmin));
|
||||
|
||||
this.clientsTranslationKey$ = this.provider$.pipe(
|
||||
map((provider) =>
|
||||
|
||||
@@ -7,17 +7,12 @@ import { AnonLayoutWrapperComponent } from "@bitwarden/components";
|
||||
import { FrontendLayoutComponent } from "@bitwarden/web-vault/app/layouts/frontend-layout.component";
|
||||
import { UserLayoutComponent } from "@bitwarden/web-vault/app/layouts/user-layout.component";
|
||||
|
||||
import {
|
||||
ManageClientsComponent,
|
||||
ProviderSubscriptionComponent,
|
||||
hasConsolidatedBilling,
|
||||
ProviderBillingHistoryComponent,
|
||||
} from "../../billing/providers";
|
||||
import { ProviderBillingHistoryComponent } from "../../billing/providers/billing-history/provider-billing-history.component";
|
||||
import { ProviderPaymentDetailsComponent } from "../../billing/providers/payment-details/provider-payment-details.component";
|
||||
import { SetupBusinessUnitComponent } from "../../billing/providers/setup/setup-business-unit.component";
|
||||
import { ProviderSubscriptionComponent } from "../../billing/providers/subscription/provider-subscription.component";
|
||||
|
||||
import { ClientsComponent } from "./clients/clients.component";
|
||||
import { CreateOrganizationComponent } from "./clients/create-organization.component";
|
||||
import { ManageClientsComponent } from "./clients/manage-clients.component";
|
||||
import { providerPermissionsGuard } from "./guards/provider-permissions.guard";
|
||||
import { AcceptProviderComponent } from "./manage/accept-provider.component";
|
||||
import { EventsComponent } from "./manage/events.component";
|
||||
@@ -88,14 +83,7 @@ const routes: Routes = [
|
||||
canActivate: [providerPermissionsGuard()],
|
||||
children: [
|
||||
{ path: "", pathMatch: "full", redirectTo: "clients" },
|
||||
{ path: "clients/create", component: CreateOrganizationComponent },
|
||||
{ path: "clients", component: ClientsComponent, data: { titleId: "clients" } },
|
||||
{
|
||||
path: "manage-client-organizations",
|
||||
canActivate: [hasConsolidatedBilling],
|
||||
component: ManageClientsComponent,
|
||||
data: { titleId: "clients" },
|
||||
},
|
||||
{ path: "clients", component: ManageClientsComponent, data: { titleId: "clients" } },
|
||||
{
|
||||
path: "manage",
|
||||
children: [
|
||||
@@ -128,7 +116,7 @@ const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: "billing",
|
||||
canActivate: [hasConsolidatedBilling],
|
||||
canActivate: [providerPermissionsGuard()],
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
@@ -138,7 +126,6 @@ const routes: Routes = [
|
||||
{
|
||||
path: "subscription",
|
||||
component: ProviderSubscriptionComponent,
|
||||
canActivate: [providerPermissionsGuard()],
|
||||
data: {
|
||||
titleId: "subscription",
|
||||
},
|
||||
@@ -146,7 +133,6 @@ const routes: Routes = [
|
||||
{
|
||||
path: "payment-details",
|
||||
component: ProviderPaymentDetailsComponent,
|
||||
canActivate: [providerPermissionsGuard()],
|
||||
data: {
|
||||
titleId: "paymentDetails",
|
||||
},
|
||||
@@ -154,7 +140,6 @@ const routes: Routes = [
|
||||
{
|
||||
path: "history",
|
||||
component: ProviderBillingHistoryComponent,
|
||||
canActivate: [providerPermissionsGuard()],
|
||||
data: {
|
||||
titleId: "billingHistory",
|
||||
},
|
||||
|
||||
@@ -13,22 +13,18 @@ import {
|
||||
} from "@bitwarden/web-vault/app/billing/payment/components";
|
||||
import { OssModule } from "@bitwarden/web-vault/app/oss.module";
|
||||
|
||||
import {
|
||||
CreateClientDialogComponent,
|
||||
InvoicesComponent,
|
||||
ManageClientNameDialogComponent,
|
||||
ManageClientSubscriptionDialogComponent,
|
||||
NoInvoicesComponent,
|
||||
ProviderBillingHistoryComponent,
|
||||
ProviderSubscriptionComponent,
|
||||
ProviderSubscriptionStatusComponent,
|
||||
} from "../../billing/providers";
|
||||
import { AddExistingOrganizationDialogComponent } from "../../billing/providers/clients/add-existing-organization-dialog.component";
|
||||
import { InvoicesComponent } from "../../billing/providers/billing-history/invoices.component";
|
||||
import { NoInvoicesComponent } from "../../billing/providers/billing-history/no-invoices.component";
|
||||
import { ProviderBillingHistoryComponent } from "../../billing/providers/billing-history/provider-billing-history.component";
|
||||
import { SetupBusinessUnitComponent } from "../../billing/providers/setup/setup-business-unit.component";
|
||||
import { ProviderSubscriptionStatusComponent } from "../../billing/providers/subscription/provider-subscription-status.component";
|
||||
import { ProviderSubscriptionComponent } from "../../billing/providers/subscription/provider-subscription.component";
|
||||
import { ProviderWarningsModule } from "../../billing/providers/warnings/provider-warnings.module";
|
||||
|
||||
import { AddOrganizationComponent } from "./clients/add-organization.component";
|
||||
import { CreateOrganizationComponent } from "./clients/create-organization.component";
|
||||
import { AddExistingOrganizationDialogComponent } from "./clients/add-existing-organization-dialog.component";
|
||||
import { CreateClientDialogComponent } from "./clients/create-client-dialog.component";
|
||||
import { ManageClientNameDialogComponent } from "./clients/manage-client-name-dialog.component";
|
||||
import { ManageClientSubscriptionDialogComponent } from "./clients/manage-client-subscription-dialog.component";
|
||||
import { AcceptProviderComponent } from "./manage/accept-provider.component";
|
||||
import { AddEditMemberDialogComponent } from "./manage/dialogs/add-edit-member-dialog.component";
|
||||
import { BulkConfirmDialogComponent } from "./manage/dialogs/bulk-confirm-dialog.component";
|
||||
@@ -65,10 +61,8 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
|
||||
declarations: [
|
||||
AcceptProviderComponent,
|
||||
AccountComponent,
|
||||
AddOrganizationComponent,
|
||||
BulkConfirmDialogComponent,
|
||||
BulkRemoveDialogComponent,
|
||||
CreateOrganizationComponent,
|
||||
EventsComponent,
|
||||
MembersComponent,
|
||||
SetupComponent,
|
||||
|
||||
@@ -3,8 +3,6 @@ import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
|
||||
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
|
||||
import { PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
@@ -26,10 +24,8 @@ describe("WebProviderService", () => {
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
let billingApiService: MockProxy<BillingApiServiceAbstraction>;
|
||||
let stateProvider: MockProxy<StateProvider>;
|
||||
let providerApiService: MockProxy<ProviderApiServiceAbstraction>;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
|
||||
beforeEach(() => {
|
||||
keyService = mock();
|
||||
@@ -37,10 +33,8 @@ describe("WebProviderService", () => {
|
||||
apiService = mock();
|
||||
i18nService = mock();
|
||||
encryptService = mock();
|
||||
billingApiService = mock();
|
||||
stateProvider = mock();
|
||||
providerApiService = mock();
|
||||
accountService = mock();
|
||||
|
||||
sut = new WebProviderService(
|
||||
keyService,
|
||||
@@ -48,10 +42,8 @@ describe("WebProviderService", () => {
|
||||
apiService,
|
||||
i18nService,
|
||||
encryptService,
|
||||
billingApiService,
|
||||
stateProvider,
|
||||
providerApiService,
|
||||
accountService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -99,7 +91,7 @@ describe("WebProviderService", () => {
|
||||
expect(keyService.getProviderKey).toHaveBeenCalledWith(providerId);
|
||||
expect(encryptService.wrapSymmetricKey).toHaveBeenCalledWith(mockOrgKey, mockProviderKey);
|
||||
|
||||
expect(billingApiService.createProviderClientOrganization).toHaveBeenCalledWith(
|
||||
expect(providerApiService.createProviderOrganization).toHaveBeenCalledWith(
|
||||
providerId,
|
||||
expect.objectContaining({
|
||||
name,
|
||||
|
||||
@@ -6,13 +6,9 @@ import { switchMap } from "rxjs/operators";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
|
||||
import { CreateProviderOrganizationRequest } from "@bitwarden/common/admin-console/models/request/create-provider-organization.request";
|
||||
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
|
||||
import { ProviderAddOrganizationRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-add-organization.request";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
|
||||
import { PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { CreateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/create-client-organization.request";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
@@ -29,34 +25,11 @@ export class WebProviderService {
|
||||
private apiService: ApiService,
|
||||
private i18nService: I18nService,
|
||||
private encryptService: EncryptService,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private stateProvider: StateProvider,
|
||||
private providerApiService: ProviderApiServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async addOrganizationToProvider(providerId: string, organizationId: string) {
|
||||
const orgKey = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => this.keyService.orgKeys$(userId)),
|
||||
map((orgKeys) => orgKeys[organizationId as OrganizationId] ?? null),
|
||||
),
|
||||
);
|
||||
const providerKey = await this.keyService.getProviderKey(providerId);
|
||||
|
||||
const encryptedOrgKey = await this.encryptService.wrapSymmetricKey(orgKey, providerKey);
|
||||
|
||||
const request = new ProviderAddOrganizationRequest();
|
||||
request.organizationId = organizationId;
|
||||
request.key = encryptedOrgKey.encryptedString;
|
||||
|
||||
const response = await this.apiService.postProviderAddOrganization(providerId, request);
|
||||
await this.syncService.fullSync(true);
|
||||
return response;
|
||||
}
|
||||
|
||||
async addOrganizationToProviderVNext(providerId: string, organizationId: string): Promise<void> {
|
||||
async addOrganizationToProvider(providerId: string, organizationId: string): Promise<void> {
|
||||
const orgKey = await firstValueFrom(
|
||||
this.stateProvider.activeUserId$.pipe(
|
||||
switchMap((userId) => this.keyService.orgKeys$(userId)),
|
||||
@@ -96,7 +69,7 @@ export class WebProviderService {
|
||||
providerKey,
|
||||
);
|
||||
|
||||
const request = new CreateClientOrganizationRequest();
|
||||
const request = new CreateProviderOrganizationRequest();
|
||||
request.name = name;
|
||||
request.ownerEmail = ownerEmail;
|
||||
request.planType = planType;
|
||||
@@ -106,7 +79,7 @@ export class WebProviderService {
|
||||
request.keyPair = new OrganizationKeysRequest(publicKey, encryptedPrivateKey.encryptedString);
|
||||
request.collectionName = encryptedCollectionName.encryptedString;
|
||||
|
||||
await this.billingApiService.createProviderClientOrganization(providerId, request);
|
||||
await this.providerApiService.createProviderOrganization(providerId, request);
|
||||
|
||||
await this.apiService.refreshIdentityToken();
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export * from "./create-client-dialog.component";
|
||||
export * from "./manage-clients.component";
|
||||
export * from "./manage-client-name-dialog.component";
|
||||
export * from "./manage-client-subscription-dialog.component";
|
||||
export * from "./no-clients.component";
|
||||
export * from "./replace.pipe";
|
||||
@@ -1,22 +0,0 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { ActivatedRouteSnapshot, CanActivateFn, createUrlTreeFromSnapshot } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import { ProviderStatusType } from "@bitwarden/common/admin-console/enums";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
|
||||
export const hasConsolidatedBilling: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
|
||||
const providerService = inject(ProviderService);
|
||||
const accountService = inject(AccountService);
|
||||
|
||||
const userId = await firstValueFrom(getUserId(accountService.activeAccount$));
|
||||
const provider = await firstValueFrom(providerService.get$(route.params.providerId, userId));
|
||||
|
||||
if (!provider || provider.providerStatus !== ProviderStatusType.Billable) {
|
||||
return createUrlTreeFromSnapshot(route, ["/providers", route.params.providerId]);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
export * from "./billing-history/invoices.component";
|
||||
export * from "./billing-history/no-invoices.component";
|
||||
export * from "./billing-history/provider-billing-history.component";
|
||||
export * from "./clients";
|
||||
export * from "./guards/has-consolidated-billing.guard";
|
||||
export * from "./subscription/provider-subscription.component";
|
||||
export * from "./subscription/provider-subscription-status.component";
|
||||
@@ -29,7 +29,7 @@ import { FilterIntegrationsPipe } from "./integrations.pipe";
|
||||
export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
|
||||
tabIndex: number = 0;
|
||||
organization$: Observable<Organization> = new Observable<Organization>();
|
||||
isEventBasedIntegrationsEnabled: boolean = false;
|
||||
isEventManagementForDataDogAndCrowdStrikeEnabled: boolean = false;
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
// initialize the integrations list with default integrations
|
||||
@@ -230,24 +230,6 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
|
||||
this.hecOrganizationIntegrationService.setOrganizationIntegrations(org.id);
|
||||
this.datadogOrganizationIntegrationService.setOrganizationIntegrations(org.id);
|
||||
});
|
||||
|
||||
// For all existing event based configurations loop through and assign the
|
||||
// organizationIntegration for the correct services.
|
||||
this.hecOrganizationIntegrationService.integrations$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((integrations) => {
|
||||
// reset all integrations to null first - in case one was deleted
|
||||
this.integrationsList.forEach((i) => {
|
||||
i.organizationIntegration = null;
|
||||
});
|
||||
|
||||
integrations.map((integration) => {
|
||||
const item = this.integrationsList.find((i) => i.name === integration.serviceType);
|
||||
if (item) {
|
||||
item.organizationIntegration = integration;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
constructor(
|
||||
@@ -259,14 +241,14 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
|
||||
private datadogOrganizationIntegrationService: DatadogOrganizationIntegrationService,
|
||||
) {
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.EventBasedOrganizationIntegrations)
|
||||
.getFeatureFlag$(FeatureFlag.EventManagementForDataDogAndCrowdStrike)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((isEnabled) => {
|
||||
this.isEventBasedIntegrationsEnabled = isEnabled;
|
||||
this.isEventManagementForDataDogAndCrowdStrikeEnabled = isEnabled;
|
||||
});
|
||||
|
||||
// Add the new event based items to the list
|
||||
if (this.isEventBasedIntegrationsEnabled) {
|
||||
if (this.isEventManagementForDataDogAndCrowdStrikeEnabled) {
|
||||
const crowdstrikeIntegration: Integration = {
|
||||
name: OrganizationIntegrationServiceType.CrowdStrike,
|
||||
linkURL: "https://bitwarden.com/help/crowdstrike-siem/",
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
|
||||
import { CreateProviderOrganizationRequest } from "../../models/request/create-provider-organization.request";
|
||||
import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request";
|
||||
import { ProviderUpdateRequest } from "../../models/request/provider/provider-update.request";
|
||||
import { ProviderVerifyRecoverDeleteRequest } from "../../models/request/provider/provider-verify-recover-delete.request";
|
||||
import { UpdateProviderOrganizationRequest } from "../../models/request/update-provider-organization.request";
|
||||
import { ProviderResponse } from "../../models/response/provider/provider.response";
|
||||
|
||||
export abstract class ProviderApiServiceAbstraction {
|
||||
@@ -14,6 +18,9 @@ export abstract class ProviderApiServiceAbstraction {
|
||||
request: ProviderVerifyRecoverDeleteRequest,
|
||||
): Promise<any>;
|
||||
abstract deleteProvider(id: string): Promise<void>;
|
||||
abstract getProviderOrganizations(
|
||||
providerId: string,
|
||||
): Promise<ListResponse<ProviderOrganizationOrganizationDetailsResponse>>;
|
||||
abstract getProviderAddableOrganizations(
|
||||
providerId: string,
|
||||
): Promise<AddableOrganizationResponse[]>;
|
||||
@@ -24,4 +31,15 @@ export abstract class ProviderApiServiceAbstraction {
|
||||
organizationId: string;
|
||||
},
|
||||
): Promise<void>;
|
||||
|
||||
abstract updateProviderOrganization(
|
||||
providerId: string,
|
||||
organizationId: string,
|
||||
request: UpdateProviderOrganizationRequest,
|
||||
): Promise<any>;
|
||||
|
||||
abstract createProviderOrganization(
|
||||
providerId: string,
|
||||
request: CreateProviderOrganizationRequest,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { OrganizationKeysRequest } from "../../../admin-console/models/request/organization-keys.request";
|
||||
import { PlanType } from "../../../billing/enums";
|
||||
|
||||
export class CreateClientOrganizationRequest {
|
||||
import { OrganizationKeysRequest } from "./organization-keys.request";
|
||||
|
||||
export class CreateProviderOrganizationRequest {
|
||||
name: string;
|
||||
ownerEmail: string;
|
||||
planType: PlanType;
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
export class UpdateClientOrganizationRequest {
|
||||
export class UpdateProviderOrganizationRequest {
|
||||
assignedSeats: number;
|
||||
name: string;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AUTO_CONFIRM, UserKeyDefinition } from "../../../platform/state";
|
||||
|
||||
export class AutoConfirmState {
|
||||
enabled: boolean;
|
||||
showSetupDialog: boolean;
|
||||
showBrowserNotification: boolean | undefined;
|
||||
|
||||
constructor() {
|
||||
this.enabled = false;
|
||||
this.showSetupDialog = true;
|
||||
}
|
||||
}
|
||||
|
||||
export const AUTO_CONFIRM_STATE = UserKeyDefinition.record<AutoConfirmState>(
|
||||
AUTO_CONFIRM,
|
||||
"autoConfirm",
|
||||
{
|
||||
deserializer: (autoConfirmState) => autoConfirmState,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
@@ -1,10 +1,14 @@
|
||||
import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { ProviderApiServiceAbstraction } from "../../abstractions/provider/provider-api.service.abstraction";
|
||||
import { CreateProviderOrganizationRequest } from "../../models/request/create-provider-organization.request";
|
||||
import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request";
|
||||
import { ProviderUpdateRequest } from "../../models/request/provider/provider-update.request";
|
||||
import { ProviderVerifyRecoverDeleteRequest } from "../../models/request/provider/provider-verify-recover-delete.request";
|
||||
import { UpdateProviderOrganizationRequest } from "../../models/request/update-provider-organization.request";
|
||||
import { ProviderResponse } from "../../models/response/provider/provider.response";
|
||||
|
||||
export class ProviderApiService implements ProviderApiServiceAbstraction {
|
||||
@@ -47,6 +51,19 @@ export class ProviderApiService implements ProviderApiServiceAbstraction {
|
||||
await this.apiService.send("DELETE", "/providers/" + id, null, true, false);
|
||||
}
|
||||
|
||||
async getProviderOrganizations(
|
||||
providerId: string,
|
||||
): Promise<ListResponse<ProviderOrganizationOrganizationDetailsResponse>> {
|
||||
const response = await this.apiService.send(
|
||||
"GET",
|
||||
"/providers/" + providerId + "/organizations",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new ListResponse(response, ProviderOrganizationOrganizationDetailsResponse);
|
||||
}
|
||||
|
||||
async getProviderAddableOrganizations(
|
||||
providerId: string,
|
||||
): Promise<AddableOrganizationResponse[]> {
|
||||
@@ -76,4 +93,31 @@ export class ProviderApiService implements ProviderApiServiceAbstraction {
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async updateProviderOrganization(
|
||||
providerId: string,
|
||||
organizationId: string,
|
||||
request: UpdateProviderOrganizationRequest,
|
||||
): Promise<any> {
|
||||
return await this.apiService.send(
|
||||
"PUT",
|
||||
"/providers/" + providerId + "/clients/" + organizationId,
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
createProviderOrganization(
|
||||
providerId: string,
|
||||
request: CreateProviderOrganizationRequest,
|
||||
): Promise<void> {
|
||||
return this.apiService.send(
|
||||
"POST",
|
||||
"/providers/" + providerId + "/clients",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response";
|
||||
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
|
||||
import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response";
|
||||
import { PlanResponse } from "../../billing/models/response/plan.response";
|
||||
import { ListResponse } from "../../models/response/list.response";
|
||||
import { OrganizationId } from "../../types/guid";
|
||||
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
|
||||
import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request";
|
||||
import { InvoicesResponse } from "../models/response/invoices.response";
|
||||
import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response";
|
||||
|
||||
@@ -18,11 +15,6 @@ export abstract class BillingApiServiceAbstraction {
|
||||
|
||||
abstract cancelPremiumUserSubscription(request: SubscriptionCancellationRequest): Promise<void>;
|
||||
|
||||
abstract createProviderClientOrganization(
|
||||
providerId: string,
|
||||
request: CreateClientOrganizationRequest,
|
||||
): Promise<void>;
|
||||
|
||||
abstract getOrganizationBillingMetadata(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse>;
|
||||
@@ -35,20 +27,10 @@ export abstract class BillingApiServiceAbstraction {
|
||||
|
||||
abstract getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise<string>;
|
||||
|
||||
abstract getProviderClientOrganizations(
|
||||
providerId: string,
|
||||
): Promise<ListResponse<ProviderOrganizationOrganizationDetailsResponse>>;
|
||||
|
||||
abstract getProviderInvoices(providerId: string): Promise<InvoicesResponse>;
|
||||
|
||||
abstract getProviderSubscription(providerId: string): Promise<ProviderSubscriptionResponse>;
|
||||
|
||||
abstract updateProviderClientOrganization(
|
||||
providerId: string,
|
||||
organizationId: string,
|
||||
request: UpdateClientOrganizationRequest,
|
||||
): Promise<any>;
|
||||
|
||||
abstract restartSubscription(
|
||||
organizationId: string,
|
||||
request: OrganizationCreateRequest,
|
||||
|
||||
@@ -3,13 +3,10 @@
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response";
|
||||
import { ListResponse } from "../../models/response/list.response";
|
||||
import { OrganizationId } from "../../types/guid";
|
||||
import { BillingApiServiceAbstraction } from "../abstractions";
|
||||
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
|
||||
import { SubscriptionCancellationRequest } from "../models/request/subscription-cancellation.request";
|
||||
import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request";
|
||||
import { InvoicesResponse } from "../models/response/invoices.response";
|
||||
import { OrganizationBillingMetadataResponse } from "../models/response/organization-billing-metadata.response";
|
||||
import { PlanResponse } from "../models/response/plan.response";
|
||||
@@ -35,19 +32,6 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
return this.apiService.send("POST", "/accounts/cancel", request, true, false);
|
||||
}
|
||||
|
||||
createProviderClientOrganization(
|
||||
providerId: string,
|
||||
request: CreateClientOrganizationRequest,
|
||||
): Promise<void> {
|
||||
return this.apiService.send(
|
||||
"POST",
|
||||
"/providers/" + providerId + "/clients",
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async getOrganizationBillingMetadata(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse> {
|
||||
@@ -92,19 +76,6 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
return response as string;
|
||||
}
|
||||
|
||||
async getProviderClientOrganizations(
|
||||
providerId: string,
|
||||
): Promise<ListResponse<ProviderOrganizationOrganizationDetailsResponse>> {
|
||||
const response = await this.apiService.send(
|
||||
"GET",
|
||||
"/providers/" + providerId + "/organizations",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new ListResponse(response, ProviderOrganizationOrganizationDetailsResponse);
|
||||
}
|
||||
|
||||
async getProviderInvoices(providerId: string): Promise<InvoicesResponse> {
|
||||
const response = await this.apiService.send(
|
||||
"GET",
|
||||
@@ -127,20 +98,6 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
return new ProviderSubscriptionResponse(response);
|
||||
}
|
||||
|
||||
async updateProviderClientOrganization(
|
||||
providerId: string,
|
||||
organizationId: string,
|
||||
request: UpdateClientOrganizationRequest,
|
||||
): Promise<any> {
|
||||
return await this.apiService.send(
|
||||
"PUT",
|
||||
"/providers/" + providerId + "/clients/" + organizationId,
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async restartSubscription(
|
||||
organizationId: string,
|
||||
request: OrganizationCreateRequest,
|
||||
|
||||
@@ -45,7 +45,7 @@ export enum FeatureFlag {
|
||||
ChromiumImporterWithABE = "pm-25855-chromium-importer-abe",
|
||||
|
||||
/* DIRT */
|
||||
EventBasedOrganizationIntegrations = "event-based-organization-integrations",
|
||||
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
|
||||
PhishingDetection = "phishing-detection",
|
||||
PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab",
|
||||
|
||||
@@ -91,7 +91,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.ChromiumImporterWithABE]: FALSE,
|
||||
|
||||
/* DIRT */
|
||||
[FeatureFlag.EventBasedOrganizationIntegrations]: FALSE,
|
||||
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
|
||||
[FeatureFlag.PhishingDetection]: FALSE,
|
||||
[FeatureFlag.PM22887_RiskInsightsActivityTab]: FALSE,
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@ export abstract class TaskService {
|
||||
*/
|
||||
abstract pendingTasks$(userId: UserId): Observable<SecurityTask[]>;
|
||||
|
||||
/**
|
||||
* Observable of completed tasks for a given user.
|
||||
* @param userId
|
||||
*/
|
||||
abstract completedTasks$(userId: UserId): Observable<SecurityTask[]>;
|
||||
|
||||
/**
|
||||
* Retrieves tasks from the API for a given user and updates the local state.
|
||||
* @param userId
|
||||
|
||||
@@ -80,6 +80,12 @@ export class DefaultTaskService implements TaskService {
|
||||
);
|
||||
});
|
||||
|
||||
completedTasks$ = perUserCache$((userId) => {
|
||||
return this.tasks$(userId).pipe(
|
||||
map((tasks) => tasks.filter((t) => t.status === SecurityTaskStatus.Completed)),
|
||||
);
|
||||
});
|
||||
|
||||
async refreshTasks(userId: UserId): Promise<void> {
|
||||
await this.fetchTasksFromApi(userId);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ const defaultIcon: Record<BannerType, string> = {
|
||||
export class BannerComponent implements OnInit {
|
||||
readonly bannerType = input<BannerType>("info");
|
||||
|
||||
readonly icon = model<string>();
|
||||
// passing `null` will remove the icon from element from the banner
|
||||
readonly icon = model<string | null>();
|
||||
readonly useAlertRole = input(true);
|
||||
readonly showClose = input(true);
|
||||
|
||||
@@ -47,7 +48,7 @@ export class BannerComponent implements OnInit {
|
||||
@Output() onClose = new EventEmitter<void>();
|
||||
|
||||
ngOnInit(): void {
|
||||
if (!this.icon()) {
|
||||
if (!this.icon() && this.icon() !== null) {
|
||||
this.icon.set(defaultIcon[this.bannerType()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,16 +21,20 @@ import {
|
||||
imports: [BadgeModule, ButtonModule, IconModule, TypographyModule, CurrencyPipe],
|
||||
})
|
||||
export class PricingCardComponent {
|
||||
tagline = input.required<string>();
|
||||
price = input<{ amount: number; cadence: "monthly" | "annually"; showPerUser?: boolean }>();
|
||||
button = input<{
|
||||
readonly tagline = input.required<string>();
|
||||
readonly price = input<{
|
||||
amount: number;
|
||||
cadence: "monthly" | "annually";
|
||||
showPerUser?: boolean;
|
||||
}>();
|
||||
readonly button = input<{
|
||||
type: ButtonType;
|
||||
text: string;
|
||||
disabled?: boolean;
|
||||
icon?: { type: string; position: "before" | "after" };
|
||||
}>();
|
||||
features = input<string[]>();
|
||||
activeBadge = input<{ text: string; variant?: BadgeVariant }>();
|
||||
readonly features = input<string[]>();
|
||||
readonly activeBadge = input<{ text: string; variant?: BadgeVariant }>();
|
||||
|
||||
@Output() buttonClick = new EventEmitter<void>();
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export const DELETE_MANAGED_USER_WARNING = new StateDefinition(
|
||||
web: "disk-local",
|
||||
},
|
||||
);
|
||||
export const AUTO_CONFIRM = new StateDefinition("autoConfirm", "disk");
|
||||
|
||||
// Billing
|
||||
export const BILLING_DISK = new StateDefinition("billing", "disk");
|
||||
@@ -217,3 +218,4 @@ export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition(
|
||||
"vaultBrowserIntroCarousel",
|
||||
"disk",
|
||||
);
|
||||
export const VAULT_AT_RISK_PASSWORDS_MEMORY = new StateDefinition("vaultAtRiskPasswords", "memory");
|
||||
|
||||
@@ -135,7 +135,7 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
tick();
|
||||
|
||||
expect(cipherFormProvider.patchCipher).toHaveBeenCalled();
|
||||
const patchFn = cipherFormProvider.patchCipher.mock.lastCall[0];
|
||||
const patchFn = cipherFormProvider.patchCipher.mock.lastCall![0];
|
||||
|
||||
const updatedCipher = patchFn(new CipherView());
|
||||
|
||||
@@ -165,7 +165,7 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
tick();
|
||||
|
||||
expect(cipherFormProvider.patchCipher).toHaveBeenCalled();
|
||||
const patchFn = cipherFormProvider.patchCipher.mock.lastCall[0];
|
||||
const patchFn = cipherFormProvider.patchCipher.mock.lastCall![0];
|
||||
|
||||
const updatedCipher = patchFn(new CipherView());
|
||||
|
||||
@@ -440,7 +440,7 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(cipherFormProvider.patchCipher).toHaveBeenCalled();
|
||||
const patchFn = cipherFormProvider.patchCipher.mock.lastCall[0];
|
||||
const patchFn = cipherFormProvider.patchCipher.mock.lastCall![0];
|
||||
|
||||
const updatedCipher = patchFn(new CipherView());
|
||||
|
||||
@@ -640,6 +640,46 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("initFromExistingCipher", () => {
|
||||
it("should set organizationId to null when prefillCipher.organizationId is undefined", async () => {
|
||||
component.config.organizationDataOwnershipDisabled = true;
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
|
||||
const prefillCipher = {
|
||||
name: "Test Cipher",
|
||||
organizationId: undefined,
|
||||
folderId: null,
|
||||
collectionIds: [],
|
||||
favorite: false,
|
||||
} as unknown as CipherView;
|
||||
|
||||
getInitialCipherView.mockReturnValueOnce(prefillCipher);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.itemDetailsForm.controls.organizationId.value).toBeNull();
|
||||
});
|
||||
|
||||
it("should preserve organizationId when prefillCipher.organizationId has a value", async () => {
|
||||
component.config.organizationDataOwnershipDisabled = true;
|
||||
component.config.organizations = [{ id: "org1", name: "Organization 1" } as Organization];
|
||||
|
||||
const prefillCipher = {
|
||||
name: "Test Cipher",
|
||||
organizationId: "org1",
|
||||
folderId: null,
|
||||
collectionIds: [],
|
||||
favorite: false,
|
||||
} as unknown as CipherView;
|
||||
|
||||
getInitialCipherView.mockReturnValueOnce(prefillCipher);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.itemDetailsForm.controls.organizationId.value).toBe("org1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("form status when editing a cipher", () => {
|
||||
beforeEach(() => {
|
||||
component.config.mode = "edit";
|
||||
@@ -691,6 +731,35 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
expect(enableFormFields).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setFormState behavior with null/undefined", () => {
|
||||
it("calls disableFormFields when organizationId value is null", async () => {
|
||||
component.originalCipherView.organizationId = null as any;
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(disableFormFields).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls disableFormFields when organizationId value is undefined", async () => {
|
||||
component.originalCipherView.organizationId = undefined;
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(disableFormFields).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls enableFormFields when organizationId has a string value", async () => {
|
||||
component.originalCipherView.organizationId = "org-id" as any;
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(enableFormFields).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when an ownership change is not allowed", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, Input, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { concatMap, firstValueFrom, map } from "rxjs";
|
||||
import { concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
@@ -125,7 +125,7 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
this.itemDetailsForm.controls.organizationId.disabled ||
|
||||
(!this.allowPersonalOwnership &&
|
||||
this.config.originalCipher &&
|
||||
this.itemDetailsForm.controls.organizationId.value === null)
|
||||
this.itemDetailsForm.controls.organizationId.value == null)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -236,6 +236,7 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
this.itemDetailsForm.controls.organizationId.valueChanges
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
distinctUntilChanged(),
|
||||
concatMap(async () => {
|
||||
await this.updateCollectionOptions();
|
||||
this.setFormState();
|
||||
@@ -252,7 +253,7 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
// When editing a cipher and the user cannot have personal ownership
|
||||
// and the cipher is is not within the organization - force the user to
|
||||
// move the cipher within the organization first before editing any other field
|
||||
if (this.itemDetailsForm.controls.organizationId.value === null) {
|
||||
if (this.itemDetailsForm.controls.organizationId.value == null) {
|
||||
this.cipherFormContainer.disableFormFields();
|
||||
this.itemDetailsForm.controls.organizationId.enable();
|
||||
this.favoriteButtonDisabled = true;
|
||||
@@ -314,7 +315,10 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
|
||||
this.itemDetailsForm.patchValue({
|
||||
name: name ? name : (this.initialValues?.name ?? ""),
|
||||
organizationId: prefillCipher.organizationId, // We do not allow changing ownership of an existing cipher.
|
||||
// We do not allow changing ownership of an existing cipher.
|
||||
// Angular forms do not support `undefined` as a value for a form control,
|
||||
// force `null` if `organizationId` is undefined.
|
||||
organizationId: prefillCipher.organizationId ?? null,
|
||||
folderId: folderId ? folderId : (this.initialValues?.folderId ?? null),
|
||||
collectionIds: [],
|
||||
favorite: prefillCipher.favorite,
|
||||
|
||||
@@ -36,18 +36,18 @@ import { OrgIconDirective } from "../../components/org-icon.directive";
|
||||
],
|
||||
})
|
||||
export class ItemDetailsV2Component {
|
||||
hideOwner = input<boolean>(false);
|
||||
cipher = input.required<CipherView>();
|
||||
organization = input<Organization | undefined>();
|
||||
folder = input<FolderView | undefined>();
|
||||
collections = input<CollectionView[] | undefined>();
|
||||
showAllDetails = signal(false);
|
||||
readonly hideOwner = input<boolean>(false);
|
||||
readonly cipher = input.required<CipherView>();
|
||||
readonly organization = input<Organization | undefined>();
|
||||
readonly folder = input<FolderView | undefined>();
|
||||
readonly collections = input<CollectionView[] | undefined>();
|
||||
readonly showAllDetails = signal(false);
|
||||
|
||||
showOwnership = computed(() => {
|
||||
readonly showOwnership = computed(() => {
|
||||
return this.cipher().organizationId && this.organization() && !this.hideOwner();
|
||||
});
|
||||
|
||||
hasSmallScreen = toSignal(
|
||||
readonly hasSmallScreen = toSignal(
|
||||
fromEvent(window, "resize").pipe(
|
||||
map(() => window.innerWidth),
|
||||
startWith(window.innerWidth),
|
||||
@@ -56,7 +56,7 @@ export class ItemDetailsV2Component {
|
||||
);
|
||||
|
||||
// Array to hold all details of item. Organization, Collections, and Folder
|
||||
allItems = computed(() => {
|
||||
readonly allItems = computed(() => {
|
||||
let items: any[] = [];
|
||||
if (this.showOwnership() && this.organization()) {
|
||||
items.push(this.organization());
|
||||
@@ -70,7 +70,7 @@ export class ItemDetailsV2Component {
|
||||
return items;
|
||||
});
|
||||
|
||||
showItems = computed(() => {
|
||||
readonly showItems = computed(() => {
|
||||
if (
|
||||
this.hasSmallScreen() &&
|
||||
this.allItems().length > 2 &&
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export {
|
||||
AtRiskPasswordCalloutService,
|
||||
AtRiskPasswordCalloutData,
|
||||
} from "./services/at-risk-password-callout.service";
|
||||
export { PasswordRepromptService } from "./services/password-reprompt.service";
|
||||
export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field.service";
|
||||
export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive";
|
||||
|
||||
162
libs/vault/src/services/at-risk-password-callout.service.spec.ts
Normal file
162
libs/vault/src/services/at-risk-password-callout.service.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import {
|
||||
SecurityTask,
|
||||
SecurityTaskStatus,
|
||||
SecurityTaskType,
|
||||
TaskService,
|
||||
} from "@bitwarden/common/vault/tasks";
|
||||
import { StateProvider } from "@bitwarden/state";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { FakeSingleUserState } from "../../../common/spec/fake-state";
|
||||
|
||||
import {
|
||||
AtRiskPasswordCalloutData,
|
||||
AtRiskPasswordCalloutService,
|
||||
} from "./at-risk-password-callout.service";
|
||||
|
||||
const fakeUserState = () =>
|
||||
({
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
state$: of(null),
|
||||
}) as unknown as FakeSingleUserState<AtRiskPasswordCalloutData>;
|
||||
|
||||
class MockCipherView {
|
||||
constructor(
|
||||
public id: string,
|
||||
private deleted: boolean,
|
||||
) {}
|
||||
get isDeleted() {
|
||||
return this.deleted;
|
||||
}
|
||||
}
|
||||
|
||||
describe("AtRiskPasswordCalloutService", () => {
|
||||
let service: AtRiskPasswordCalloutService;
|
||||
const mockTaskService = {
|
||||
pendingTasks$: jest.fn(),
|
||||
completedTasks$: jest.fn(),
|
||||
};
|
||||
const mockCipherService = { cipherViews$: jest.fn() };
|
||||
const mockStateProvider = { getUser: jest.fn().mockReturnValue(fakeUserState()) };
|
||||
const userId: UserId = "user1" as UserId;
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
AtRiskPasswordCalloutService,
|
||||
{
|
||||
provide: TaskService,
|
||||
useValue: mockTaskService,
|
||||
},
|
||||
{
|
||||
provide: CipherService,
|
||||
useValue: mockCipherService,
|
||||
},
|
||||
{
|
||||
provide: StateProvider,
|
||||
useValue: mockStateProvider,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(AtRiskPasswordCalloutService);
|
||||
});
|
||||
|
||||
describe("completedTasks$", () => {
|
||||
it(" should return true if completed tasks exist", async () => {
|
||||
const tasks: SecurityTask[] = [
|
||||
{
|
||||
id: "t1",
|
||||
cipherId: "c1",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Completed,
|
||||
} as any,
|
||||
{
|
||||
id: "t2",
|
||||
cipherId: "c2",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
} as any,
|
||||
{
|
||||
id: "t3",
|
||||
cipherId: "nope",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Completed,
|
||||
} as any,
|
||||
{
|
||||
id: "t4",
|
||||
cipherId: "c3",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Completed,
|
||||
} as any,
|
||||
];
|
||||
|
||||
jest.spyOn(mockTaskService, "completedTasks$").mockReturnValue(of(tasks));
|
||||
|
||||
const result = await firstValueFrom(service.completedTasks$(userId));
|
||||
|
||||
expect(result).toEqual(tasks[0]);
|
||||
expect(result?.id).toBe("t1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("showCompletedTasksBanner$", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(mockTaskService, "pendingTasks$").mockReturnValue(of([]));
|
||||
jest.spyOn(mockTaskService, "completedTasks$").mockReturnValue(of([]));
|
||||
jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of([]));
|
||||
});
|
||||
|
||||
it("should return false if banner has been dismissed", async () => {
|
||||
const state: AtRiskPasswordCalloutData = {
|
||||
hasInteractedWithTasks: true,
|
||||
tasksBannerDismissed: true,
|
||||
};
|
||||
const mockState = { ...fakeUserState(), state$: of(state) };
|
||||
mockStateProvider.getUser.mockReturnValue(mockState);
|
||||
|
||||
const result = await firstValueFrom(service.showCompletedTasksBanner$(userId));
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when has completed tasks, no pending tasks, and banner not dismissed", async () => {
|
||||
const completedTasks = [
|
||||
{
|
||||
id: "t1",
|
||||
cipherId: "c1",
|
||||
type: SecurityTaskType.UpdateAtRiskCredential,
|
||||
status: SecurityTaskStatus.Completed,
|
||||
},
|
||||
];
|
||||
const ciphers = [new MockCipherView("c1", false)];
|
||||
const state: AtRiskPasswordCalloutData = {
|
||||
hasInteractedWithTasks: true,
|
||||
tasksBannerDismissed: false,
|
||||
};
|
||||
|
||||
jest.spyOn(mockTaskService, "completedTasks$").mockReturnValue(of(completedTasks));
|
||||
jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of(ciphers));
|
||||
mockStateProvider.getUser.mockReturnValue({ state$: of(state) });
|
||||
|
||||
const result = await firstValueFrom(service.showCompletedTasksBanner$(userId));
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when no completed tasks", async () => {
|
||||
const state: AtRiskPasswordCalloutData = {
|
||||
hasInteractedWithTasks: true,
|
||||
tasksBannerDismissed: false,
|
||||
};
|
||||
mockStateProvider.getUser.mockReturnValue({ state$: of(state) });
|
||||
|
||||
const result = await firstValueFrom(service.showCompletedTasksBanner$(userId));
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
93
libs/vault/src/services/at-risk-password-callout.service.ts
Normal file
93
libs/vault/src/services/at-risk-password-callout.service.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { combineLatest, map, Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
SingleUserState,
|
||||
StateProvider,
|
||||
UserKeyDefinition,
|
||||
VAULT_AT_RISK_PASSWORDS_MEMORY,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export type AtRiskPasswordCalloutData = {
|
||||
hasInteractedWithTasks: boolean;
|
||||
tasksBannerDismissed: boolean;
|
||||
};
|
||||
|
||||
export const AT_RISK_PASSWORD_CALLOUT_KEY = new UserKeyDefinition<AtRiskPasswordCalloutData>(
|
||||
VAULT_AT_RISK_PASSWORDS_MEMORY,
|
||||
"atRiskPasswords",
|
||||
{
|
||||
deserializer: (jsonData) => jsonData,
|
||||
clearOn: ["lock", "logout"],
|
||||
},
|
||||
);
|
||||
|
||||
@Injectable()
|
||||
export class AtRiskPasswordCalloutService {
|
||||
constructor(
|
||||
private taskService: TaskService,
|
||||
private cipherService: CipherService,
|
||||
private stateProvider: StateProvider,
|
||||
) {}
|
||||
|
||||
pendingTasks$(userId: UserId): Observable<SecurityTask[]> {
|
||||
return combineLatest([
|
||||
this.taskService.pendingTasks$(userId),
|
||||
this.cipherService.cipherViews$(userId),
|
||||
]).pipe(
|
||||
map(([tasks, ciphers]) => {
|
||||
return tasks.filter((t: SecurityTask) => {
|
||||
const associatedCipher = ciphers.find((c) => c.id === t.cipherId);
|
||||
|
||||
return (
|
||||
t.type === SecurityTaskType.UpdateAtRiskCredential &&
|
||||
associatedCipher &&
|
||||
!associatedCipher.isDeleted
|
||||
);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
completedTasks$(userId: UserId): Observable<SecurityTask | undefined> {
|
||||
return this.taskService.completedTasks$(userId).pipe(
|
||||
map((tasks) => {
|
||||
return tasks.find((t: SecurityTask) => t.type === SecurityTaskType.UpdateAtRiskCredential);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
showCompletedTasksBanner$(userId: UserId): Observable<boolean> {
|
||||
return combineLatest([
|
||||
this.pendingTasks$(userId),
|
||||
this.completedTasks$(userId),
|
||||
this.atRiskPasswordState(userId).state$,
|
||||
]).pipe(
|
||||
map(([pendingTasks, completedTasks, state]) => {
|
||||
const hasPendingTasks = pendingTasks.length > 0;
|
||||
const bannerDismissed = state?.tasksBannerDismissed ?? false;
|
||||
const hasInteracted = state?.hasInteractedWithTasks ?? false;
|
||||
|
||||
// This will ensure the banner remains visible only in the client the user resolved their tasks in
|
||||
// e.g. if the user did not see tasks in the browser, and resolves them in the web, the browser will not show the banner
|
||||
if (!hasPendingTasks && (!hasInteracted || bannerDismissed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show banner if there are completed tasks and no pending tasks, and banner hasn't been dismissed
|
||||
return !!completedTasks && !hasPendingTasks && !(state?.tasksBannerDismissed ?? false);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
atRiskPasswordState(userId: UserId): SingleUserState<AtRiskPasswordCalloutData> {
|
||||
return this.stateProvider.getUser(userId, AT_RISK_PASSWORD_CALLOUT_KEY);
|
||||
}
|
||||
|
||||
updateAtRiskPasswordState(userId: UserId, updatedState: AtRiskPasswordCalloutData): void {
|
||||
void this.atRiskPasswordState(userId).update(() => updatedState);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user