1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-02-16 01:21:48 +01:00

Merge remote-tracking branch 'origin/main' into auth/pm-8113/2fa-comps-ui-refresh + merge conflict fixes

This commit is contained in:
Jared Snider 2025-01-23 18:14:37 -05:00
commit 37ac40098c
No known key found for this signature in database
GPG Key ID: A149DDD612516286
129 changed files with 2197 additions and 629 deletions

4
.github/CODEOWNERS vendored
View File

@ -162,3 +162,7 @@ apps/web/src/locales/en/messages.json
**/*.Dockerfile
**/.dockerignore
**/entrypoint.sh
## Overrides
# tsconfig files are potentially dangerous and will be reviewed by platform to prevent misconfigurations
**/tsconfig.json @bitwarden/team-platform-dev

View File

@ -222,7 +222,7 @@ jobs:
with:
token: '${{ secrets.GITHUB_TOKEN }}'
state: 'success'
deployment_id: ${{ needs.setup.outputs.deployment_id }}
deployment-id: ${{ needs.setup.outputs.deployment_id }}
- name: Update deployment status to Failure
if: ${{ inputs.publish_type != 'Dry Run' && failure() }}
@ -230,4 +230,4 @@ jobs:
with:
token: '${{ secrets.GITHUB_TOKEN }}'
state: 'failure'
deployment_id: ${{ needs.setup.outputs.deployment_id }}
deployment-id: ${{ needs.setup.outputs.deployment_id }}

View File

@ -162,7 +162,7 @@ jobs:
with:
token: '${{ secrets.GITHUB_TOKEN }}'
state: 'success'
deployment_id: ${{ needs.setup.outputs.deployment_id }}
deployment-id: ${{ needs.setup.outputs.deployment_id }}
- name: Update deployment status to Failure
if: ${{ inputs.publish_type != 'Dry Run' && failure() }}
@ -170,7 +170,7 @@ jobs:
with:
token: '${{ secrets.GITHUB_TOKEN }}'
state: 'failure'
deployment_id: ${{ needs.setup.outputs.deployment_id }}
deployment-id: ${{ needs.setup.outputs.deployment_id }}
snap:
name: Deploy Snap
@ -283,7 +283,7 @@ jobs:
with:
token: '${{ secrets.GITHUB_TOKEN }}'
state: 'success'
deployment_id: ${{ needs.setup.outputs.deployment_id }}
deployment-id: ${{ needs.setup.outputs.deployment_id }}
- name: Update deployment status to Failure
if: ${{ inputs.publish_type != 'Dry Run' && failure() }}
@ -291,4 +291,4 @@ jobs:
with:
token: '${{ secrets.GITHUB_TOKEN }}'
state: 'failure'
deployment_id: ${{ needs.setup.outputs.deployment_id }}
deployment-id: ${{ needs.setup.outputs.deployment_id }}

View File

@ -40,7 +40,7 @@ jobs:
- name: Check Release Version
id: version
uses: bitwarden/gh-actions/release_version-check@main
uses: bitwarden/gh-actions/release-version-check@main
with:
release-type: ${{ github.event.inputs.release_type }}
project-type: ts

View File

@ -40,7 +40,7 @@ jobs:
- name: Check Release Version
id: version
uses: bitwarden/gh-actions/release_version-check@main
uses: bitwarden/gh-actions/release-version-check@main
with:
release-type: ${{ inputs.release_type }}
project-type: ts

View File

@ -47,7 +47,7 @@ jobs:
- name: Check Release Version
id: version
uses: bitwarden/gh-actions/release_version-check@main
uses: bitwarden/gh-actions/release-version-check@main
with:
release-type: 'Initial Release'
project-type: ts

View File

@ -40,7 +40,7 @@ jobs:
- name: Check Release Version
id: version
uses: bitwarden/gh-actions/release_version-check@main
uses: bitwarden/gh-actions/release-version-check@main
with:
release-type: ${{ inputs.release_type }}
project-type: ts

View File

@ -647,6 +647,12 @@
"verifyIdentity": {
"message": "Verify identity"
},
"weDontRecognizeThisDevice": {
"message": "We don't recognize this device. Enter the code sent to your email to verify your identity."
},
"continueLoggingIn": {
"message": "Continue logging in"
},
"yourVaultIsLocked": {
"message": "Your vault is locked. Verify your identity to continue."
},
@ -986,8 +992,8 @@
"addLoginNotificationDescAlt": {
"message": "Ask to add an item if one isn't found in your vault. Applies to all logged in accounts."
},
"showCardsInVaultView": {
"message": "Show cards as Autofill suggestions on Vault view"
"showCardsInVaultViewV2": {
"message": "Always show cards as Autofill suggestions on Vault view"
},
"showCardsCurrentTab": {
"message": "Show cards on Tab page"
@ -995,8 +1001,8 @@
"showCardsCurrentTabDesc": {
"message": "List card items on the Tab page for easy autofill."
},
"showIdentitiesInVaultView": {
"message": "Show identities as Autofill suggestions on Vault view"
"showIdentitiesInVaultViewV2": {
"message": "Always show identities as Autofill suggestions on Vault view"
},
"showIdentitiesCurrentTab": {
"message": "Show identities on Tab page"

View File

@ -33,7 +33,7 @@ import { BrowserApi } from "../../platform/browser/browser-api";
import { ZonedMessageListenerService } from "../../platform/browser/zoned-message-listener.service";
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
import { closeTwoFactorAuthPopout } from "./utils/auth-popout-window";
import { closeTwoFactorAuthWebAuthnPopout } from "./utils/auth-popout-window";
@Component({
selector: "app-two-factor",
@ -171,7 +171,7 @@ export class TwoFactorComponentV1 extends BaseTwoFactorComponent implements OnIn
// We don't need this window anymore because the intent is for the user to be left
// on the web vault screen which tells them to continue in the browser extension (sidebar or popup)
await closeTwoFactorAuthPopout();
await closeTwoFactorAuthWebAuthnPopout();
};
}
});

View File

@ -7,8 +7,8 @@ import {
openUnlockPopout,
closeUnlockPopout,
openSsoAuthResultPopout,
openTwoFactorAuthPopout,
closeTwoFactorAuthPopout,
openTwoFactorAuthWebAuthnPopout,
closeTwoFactorAuthWebAuthnPopout,
closeSsoAuthResultPopout,
} from "./auth-popout-window";
@ -106,22 +106,22 @@ describe("AuthPopoutWindow", () => {
});
});
describe("openTwoFactorAuthPopout", () => {
it("opens a window that facilitates two factor authentication", async () => {
await openTwoFactorAuthPopout({ data: "data", remember: "remember" });
describe("openTwoFactorAuthWebAuthnPopout", () => {
it("opens a window that facilitates two factor authentication via WebAuthn", async () => {
await openTwoFactorAuthWebAuthnPopout({ data: "data", remember: "remember" });
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/2fa;webAuthnResponse=data;remember=remember",
{ singleActionKey: AuthPopoutType.twoFactorAuth },
{ singleActionKey: AuthPopoutType.twoFactorAuthWebAuthn },
);
});
});
describe("closeTwoFactorAuthPopout", () => {
it("closes the two-factor authentication window", async () => {
await closeTwoFactorAuthPopout();
describe("closeTwoFactorAuthWebAuthnPopout", () => {
it("closes the two-factor authentication WebAuthn window", async () => {
await closeTwoFactorAuthWebAuthnPopout();
expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith(AuthPopoutType.twoFactorAuth);
expect(closeSingleActionPopoutSpy).toHaveBeenCalledWith(AuthPopoutType.twoFactorAuthWebAuthn);
});
});
});

View File

@ -6,7 +6,7 @@ import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
const AuthPopoutType = {
unlockExtension: "auth_unlockExtension",
ssoAuthResult: "auth_ssoAuthResult",
twoFactorAuth: "auth_twoFactorAuth",
twoFactorAuthWebAuthn: "auth_twoFactorAuthWebAuthn",
} as const;
const extensionUnlockUrls = new Set([
chrome.runtime.getURL("popup/index.html#/lock"),
@ -60,33 +60,37 @@ async function openSsoAuthResultPopout(resultData: { code: string; state: string
}
/**
* Opens a window that facilitates two-factor authentication.
*
* @param twoFactorAuthData - The data from the two-factor authentication.
* Closes the SSO authentication result popout window.
*/
async function openTwoFactorAuthPopout(twoFactorAuthData: { data: string; remember: string }) {
const { data, remember } = twoFactorAuthData;
async function closeSsoAuthResultPopout() {
await BrowserPopupUtils.closeSingleActionPopout(AuthPopoutType.ssoAuthResult);
}
/**
* Opens a popout that facilitates two-factor authentication via WebAuthn.
*
* @param twoFactorAuthWebAuthnData - The data to send ot the popout via query param.
* It includes the WebAuthn response and whether to save the 2FA remember me token or not.
*/
async function openTwoFactorAuthWebAuthnPopout(twoFactorAuthWebAuthnData: {
data: string;
remember: string;
}) {
const { data, remember } = twoFactorAuthWebAuthnData;
const params =
`webAuthnResponse=${encodeURIComponent(data)};` + `remember=${encodeURIComponent(remember)}`;
const twoFactorUrl = `popup/index.html#/2fa;${params}`;
await BrowserPopupUtils.openPopout(twoFactorUrl, {
singleActionKey: AuthPopoutType.twoFactorAuth,
singleActionKey: AuthPopoutType.twoFactorAuthWebAuthn,
});
}
/**
* Closes the two-factor authentication popout window.
*/
async function closeTwoFactorAuthPopout() {
await BrowserPopupUtils.closeSingleActionPopout(AuthPopoutType.twoFactorAuth);
}
/**
* Closes the two-factor authentication popout window.
*/
async function closeSsoAuthResultPopout() {
await BrowserPopupUtils.closeSingleActionPopout(AuthPopoutType.ssoAuthResult);
async function closeTwoFactorAuthWebAuthnPopout() {
await BrowserPopupUtils.closeSingleActionPopout(AuthPopoutType.twoFactorAuthWebAuthn);
}
export {
@ -95,6 +99,6 @@ export {
closeUnlockPopout,
openSsoAuthResultPopout,
closeSsoAuthResultPopout,
openTwoFactorAuthPopout,
closeTwoFactorAuthPopout,
openTwoFactorAuthWebAuthnPopout,
closeTwoFactorAuthWebAuthnPopout,
};

View File

@ -118,7 +118,7 @@
(change)="updateShowCardsCurrentTab()"
[(ngModel)]="showCardsCurrentTab"
/>
<bit-label for="showCardsSuggestions">{{ "showCardsInVaultView" | i18n }}</bit-label>
<bit-label for="showCardsSuggestions">{{ "showCardsInVaultViewV2" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input
@ -129,7 +129,7 @@
[(ngModel)]="showIdentitiesCurrentTab"
/>
<bit-label for="showIdentitiesSuggestions" class="tw-whitespace-normal">
{{ "showIdentitiesInVaultView" | i18n }}
{{ "showIdentitiesInVaultViewV2" | i18n }}
</bit-label>
</bit-form-control>
<bit-form-control disableMargin>

View File

@ -21,7 +21,7 @@ jest.mock("../utils", () => {
const utils = jest.requireActual("../utils");
return {
...utils,
debounce: jest.fn((fn, wait) => setTimeout(() => fn(), wait)),
debounce: jest.fn((fn) => fn),
};
});

View File

@ -947,8 +947,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
}
if (!this.mutationsQueue.length) {
// Collect all mutations and debounce the processing of those mutations by 100ms to ensure we don't process too many mutations at once.
debounce(this.processMutations, 100);
requestIdleCallbackPolyfill(debounce(this.processMutations, 100), { timeout: 500 });
}
this.mutationsQueue.push(mutations);
};

View File

@ -37,7 +37,9 @@ export function requestIdleCallbackPolyfill(
return globalThis.requestIdleCallback(() => callback(), options);
}
return globalThis.setTimeout(() => callback(), 1);
const timeoutDelay = options?.timeout || 1;
return globalThis.setTimeout(() => callback(), timeoutDelay);
}
/**

View File

@ -92,6 +92,7 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor
import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service";
import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import {
@ -125,6 +126,7 @@ import { FileUploadService } from "@bitwarden/common/platform/services/file-uplo
import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory";
import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service";
import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory";
import { StateService } from "@bitwarden/common/platform/services/state.service";
@ -260,7 +262,7 @@ import { LocalBackedSessionStorageService } from "../platform/services/local-bac
import { BackgroundPlatformUtilsService } from "../platform/services/platform-utils/background-platform-utils.service";
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
import { PopupViewCacheBackgroundService } from "../platform/services/popup-view-cache-background.service";
import { BrowserSdkClientFactory } from "../platform/services/sdk/browser-sdk-client-factory";
import { BrowserSdkLoadService } from "../platform/services/sdk/browser-sdk-load.service";
import { BackgroundTaskSchedulerService } from "../platform/services/task-scheduler/background-task-scheduler.service";
import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service";
import { BrowserStorageServiceProvider } from "../platform/storage/browser-storage-service.provider";
@ -379,6 +381,7 @@ export default class MainBackground {
themeStateService: DefaultThemeStateService;
autoSubmitLoginBackground: AutoSubmitLoginBackground;
sdkService: SdkService;
sdkLoadService: SdkLoadService;
cipherAuthorizationService: CipherAuthorizationService;
inlineMenuFieldQualificationService: InlineMenuFieldQualificationService;
@ -730,8 +733,9 @@ export default class MainBackground {
);
const sdkClientFactory = flagEnabled("sdk")
? new BrowserSdkClientFactory(this.logService)
? new DefaultSdkClientFactory()
: new NoopSdkClientFactory();
this.sdkLoadService = new BrowserSdkLoadService(this.logService);
this.sdkService = new DefaultSdkService(
sdkClientFactory,
this.environmentService,
@ -1257,6 +1261,7 @@ export default class MainBackground {
async bootstrap() {
this.containerService.attachToGlobal(self);
await this.sdkLoadService.load();
// Only the "true" background should run migrations
await this.stateService.init({ runMigrations: true });

View File

@ -22,7 +22,7 @@ import { BiometricsCommands } from "@bitwarden/key-management";
import {
closeUnlockPopout,
openSsoAuthResultPopout,
openTwoFactorAuthPopout,
openTwoFactorAuthWebAuthnPopout,
} from "../auth/popup/utils/auth-popout-window";
import { LockedVaultPendingNotificationsData } from "../autofill/background/abstractions/notification.background";
import { AutofillService } from "../autofill/services/abstractions/autofill.service";
@ -333,9 +333,7 @@ export default class RuntimeBackground {
return;
}
// TODO: investigate when this is triggered and consider a rename to make it specific
// to webauthn 2fa
await openTwoFactorAuthPopout(msg);
await openTwoFactorAuthWebAuthnPopout(msg);
break;
}
case "reloadPopup":

View File

@ -1,11 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import type { BitwardenClient } from "@bitwarden/sdk-internal";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { BrowserApi } from "../../browser/browser-api";
export type GlobalWithWasmInit = typeof globalThis & {
initSdk: () => void;
};
// https://stackoverflow.com/a/47880734
const supported = (() => {
try {
@ -17,9 +18,7 @@ const supported = (() => {
return new WebAssembly.Instance(module) instanceof WebAssembly.Instance;
}
}
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
} catch {
// ignore
}
return false;
@ -33,54 +32,42 @@ let loadingPromise: Promise<any> | undefined;
if (BrowserApi.isManifestVersion(3)) {
if (supported) {
// eslint-disable-next-line no-console
console.debug("WebAssembly is supported in this environment");
console.info("WebAssembly is supported in this environment");
loadingPromise = import("./wasm");
} else {
// eslint-disable-next-line no-console
console.debug("WebAssembly is not supported in this environment");
console.info("WebAssembly is not supported in this environment");
loadingPromise = import("./fallback");
}
}
// Manifest v2 expects dynamic imports to prevent timing issues.
async function load() {
async function importModule(): Promise<GlobalWithWasmInit["initSdk"]> {
if (BrowserApi.isManifestVersion(3)) {
// Ensure we have loaded the module
await loadingPromise;
return;
}
if (supported) {
} else if (supported) {
// eslint-disable-next-line no-console
console.debug("WebAssembly is supported in this environment");
console.info("WebAssembly is supported in this environment");
await import("./wasm");
} else {
// eslint-disable-next-line no-console
console.debug("WebAssembly is not supported in this environment");
console.info("WebAssembly is not supported in this environment");
await import("./fallback");
}
// the wasm and fallback imports mutate globalThis to add the initSdk function
return (globalThis as GlobalWithWasmInit).initSdk;
}
/**
* SDK client factory with a js fallback for when WASM is not supported.
*
* Works both in popup and service worker.
*/
export class BrowserSdkClientFactory implements SdkClientFactory {
constructor(private logService: LogService) {}
export class BrowserSdkLoadService implements SdkLoadService {
constructor(readonly logService: LogService) {}
async createSdkClient(
...args: ConstructorParameters<typeof BitwardenClient>
): Promise<BitwardenClient> {
async load(): Promise<void> {
const startTime = performance.now();
await load();
await importModule().then((initSdk) => initSdk());
const endTime = performance.now();
const instance = (globalThis as any).init_sdk(...args);
this.logService.info("WASM SDK loaded in", Math.round(endTime - startTime), "ms");
return instance;
this.logService.info(`WASM SDK loaded in ${Math.round(endTime - startTime)}ms`);
}
}

View File

@ -1,8 +1,8 @@
import * as sdk from "@bitwarden/sdk-internal";
import * as wasm from "@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm.js";
(globalThis as any).init_sdk = (...args: ConstructorParameters<typeof sdk.BitwardenClient>) => {
(sdk as any).init(wasm);
import { GlobalWithWasmInit } from "./browser-sdk-load.service";
return new sdk.BitwardenClient(...args);
(globalThis as GlobalWithWasmInit).initSdk = () => {
(sdk as any).init(wasm);
};

View File

@ -1,8 +1,8 @@
import * as sdk from "@bitwarden/sdk-internal";
import * as wasm from "@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm";
(globalThis as any).init_sdk = (...args: ConstructorParameters<typeof sdk.BitwardenClient>) => {
(sdk as any).init(wasm);
import { GlobalWithWasmInit } from "./browser-sdk-load.service";
return new sdk.BitwardenClient(...args);
(globalThis as GlobalWithWasmInit).initSdk = () => {
(sdk as any).init(wasm);
};

View File

@ -1,6 +1,7 @@
import { Injectable, NgModule } from "@angular/core";
import { ActivatedRouteSnapshot, RouteReuseStrategy, RouterModule, Routes } from "@angular/router";
import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component";
import {
EnvironmentSelectorComponent,
EnvironmentSelectorRouteData,
@ -11,10 +12,12 @@ import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui
import {
authGuard,
lockGuard,
activeAuthGuard,
redirectGuard,
tdeDecryptionRequiredGuard,
unauthGuardFn,
} from "@bitwarden/angular/auth/guards";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards";
import {
AnonLayoutWrapperComponent,
@ -38,9 +41,11 @@ import {
SsoComponent,
TwoFactorTimeoutIcon,
TwoFactorAuthComponent,
TwoFactorTimeoutComponent,
TwoFactorAuthGuard,
NewDeviceVerificationComponent,
DeviceVerificationIcon,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { LockComponent } from "@bitwarden/key-management/angular";
import {
NewDeviceVerificationNoticePageOneComponent,
@ -172,12 +177,12 @@ const routes: Routes = [
component: ExtensionAnonLayoutWrapperComponent,
children: [
{
path: "2fa-timeout",
path: "authentication-timeout",
canActivate: [unauthGuardFn(unauthRouteOverrides)],
children: [
{
path: "",
component: TwoFactorTimeoutComponent,
component: AuthenticationTimeoutComponent,
},
],
data: {
@ -230,6 +235,27 @@ const routes: Routes = [
],
},
),
{
path: "device-verification",
component: ExtensionAnonLayoutWrapperComponent,
canActivate: [
canAccessFeature(FeatureFlag.NewDeviceVerification),
unauthGuardFn(),
activeAuthGuard(),
],
children: [{ path: "", component: NewDeviceVerificationComponent }],
data: {
pageIcon: DeviceVerificationIcon,
pageTitle: {
key: "verifyIdentity",
},
pageSubtitle: {
key: "weDontRecognizeThisDevice",
},
showBackButton: true,
elevation: 1,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
},
{
path: "set-password",
component: SetPasswordComponent,

View File

@ -6,6 +6,7 @@ import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { BrowserApi } from "../../platform/browser/browser-api";
@ -22,11 +23,13 @@ export class InitService {
private twoFactorService: TwoFactorService,
private logService: LogServiceAbstraction,
private themingService: AbstractThemingService,
private sdkLoadService: SdkLoadService,
@Inject(DOCUMENT) private document: Document,
) {}
init() {
return async () => {
await this.sdkLoadService.load();
await this.stateService.init({ runMigrations: false }); // Browser background is responsible for migrations
await this.i18nService.init();
this.twoFactorService.init();

View File

@ -75,6 +75,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import {
AbstractStorageService,
@ -87,6 +88,7 @@ import { flagEnabled } from "@bitwarden/common/platform/misc/flags";
import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling";
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory";
import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
@ -154,7 +156,7 @@ import BrowserMemoryStorageService from "../../platform/services/browser-memory-
import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service";
import I18nService from "../../platform/services/i18n.service";
import { ForegroundPlatformUtilsService } from "../../platform/services/platform-utils/foreground-platform-utils.service";
import { BrowserSdkClientFactory } from "../../platform/services/sdk/browser-sdk-client-factory";
import { BrowserSdkLoadService } from "../../platform/services/sdk/browser-sdk-load.service";
import { ForegroundTaskSchedulerService } from "../../platform/services/task-scheduler/foreground-task-scheduler.service";
import { BrowserStorageServiceProvider } from "../../platform/storage/browser-storage-service.provider";
import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service";
@ -601,11 +603,16 @@ const safeProviders: SafeProvider[] = [
deps: [MessageSender, MessageListener],
}),
safeProvider({
provide: SdkClientFactory,
useFactory: (logService: LogService) =>
flagEnabled("sdk") ? new BrowserSdkClientFactory(logService) : new NoopSdkClientFactory(),
provide: SdkLoadService,
useClass: BrowserSdkLoadService,
deps: [LogService],
}),
safeProvider({
provide: SdkClientFactory,
useFactory: () =>
flagEnabled("sdk") ? new DefaultSdkClientFactory() : new NoopSdkClientFactory(),
deps: [],
}),
safeProvider({
provide: LoginEmailService,
useClass: LoginEmailService,

View File

@ -26,5 +26,15 @@
<button bitButton type="submit" form="cipherForm" buttonType="primary" #submitBtn>
{{ "save" | i18n }}
</button>
<button
slot="end"
*ngIf="canDeleteCipher$ | async"
[bitAction]="delete"
type="button"
buttonType="danger"
bitIconButton="bwi-trash"
[appA11yTitle]="'delete' | i18n"
></button>
</popup-footer>
</popup-page>

View File

@ -1,17 +1,19 @@
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { ActivatedRoute, Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, Observable } from "rxjs";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventType } from "@bitwarden/common/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher-info";
import {
CipherFormConfig,
@ -40,7 +42,7 @@ describe("AddEditV2Component", () => {
const buildConfigResponse = { originalCipher: {} } as CipherFormConfig;
const buildConfig = jest.fn((mode: CipherFormMode) =>
Promise.resolve({ mode, ...buildConfigResponse }),
Promise.resolve({ ...buildConfigResponse, mode }),
);
const queryParams$ = new BehaviorSubject({});
const disable = jest.fn();
@ -55,9 +57,10 @@ describe("AddEditV2Component", () => {
back.mockClear();
collect.mockClear();
addEditCipherInfo$ = new BehaviorSubject(null);
addEditCipherInfo$ = new BehaviorSubject<AddEditCipherInfo | null>(null);
cipherServiceMock = mock<CipherService>();
cipherServiceMock.addEditCipherInfo$ = addEditCipherInfo$.asObservable();
cipherServiceMock.addEditCipherInfo$ =
addEditCipherInfo$.asObservable() as Observable<AddEditCipherInfo>;
await TestBed.configureTestingModule({
imports: [AddEditV2Component],
@ -71,6 +74,13 @@ describe("AddEditV2Component", () => {
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: CipherService, useValue: cipherServiceMock },
{ provide: EventCollectionService, useValue: { collect } },
{ provide: LogService, useValue: mock<LogService>() },
{
provide: CipherAuthorizationService,
useValue: {
canDeleteCipher$: jest.fn().mockReturnValue(true),
},
},
],
})
.overrideProvider(CipherFormConfigService, {
@ -92,7 +102,7 @@ describe("AddEditV2Component", () => {
tick();
expect(buildConfig.mock.lastCall[0]).toBe("add");
expect(buildConfig.mock.lastCall![0]).toBe("add");
expect(component.config.mode).toBe("add");
}));
@ -101,7 +111,7 @@ describe("AddEditV2Component", () => {
tick();
expect(buildConfig.mock.lastCall[0]).toBe("clone");
expect(buildConfig.mock.lastCall![0]).toBe("clone");
expect(component.config.mode).toBe("clone");
}));
@ -111,7 +121,7 @@ describe("AddEditV2Component", () => {
tick();
expect(buildConfig.mock.lastCall[0]).toBe("edit");
expect(buildConfig.mock.lastCall![0]).toBe("edit");
expect(component.config.mode).toBe("edit");
}));
@ -121,7 +131,7 @@ describe("AddEditV2Component", () => {
tick();
expect(buildConfig.mock.lastCall[0]).toBe("edit");
expect(buildConfig.mock.lastCall![0]).toBe("edit");
expect(component.config.mode).toBe("partial-edit");
}));
});
@ -218,7 +228,7 @@ describe("AddEditV2Component", () => {
tick();
expect(component.config.initialValues.username).toBe("identity-username");
expect(component.config.initialValues!.username).toBe("identity-username");
}));
it("overrides query params with `addEditCipherInfo` values", fakeAsync(() => {
@ -231,7 +241,7 @@ describe("AddEditV2Component", () => {
tick();
expect(component.config.initialValues.name).toBe("AddEditCipherName");
expect(component.config.initialValues!.name).toBe("AddEditCipherName");
}));
it("clears `addEditCipherInfo` after initialization", fakeAsync(() => {
@ -326,4 +336,30 @@ describe("AddEditV2Component", () => {
expect(back).toHaveBeenCalled();
});
});
describe("delete", () => {
it("dialogService openSimpleDialog called when deleteBtn is hit", async () => {
const dialogSpy = jest
.spyOn(component["dialogService"], "openSimpleDialog")
.mockResolvedValue(true);
await component.delete();
expect(dialogSpy).toHaveBeenCalled();
});
it("should call deleteCipher when user confirms deletion", async () => {
const deleteCipherSpy = jest.spyOn(component as any, "deleteCipher");
jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true);
await component.delete();
expect(deleteCipherSpy).toHaveBeenCalled();
});
it("navigates to vault tab after deletion", async () => {
jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true);
await component.delete();
expect(navigate).toHaveBeenCalledWith(["/tabs/vault"]);
});
});
});

View File

@ -5,18 +5,27 @@ import { Component, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";
import { ActivatedRoute, Params, Router } from "@angular/router";
import { firstValueFrom, map, switchMap } from "rxjs";
import { firstValueFrom, map, Observable, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher-info";
import { AsyncActionsModule, ButtonModule, SearchModule } from "@bitwarden/components";
import {
AsyncActionsModule,
ButtonModule,
SearchModule,
IconButtonModule,
DialogService,
ToastService,
} from "@bitwarden/components";
import {
CipherFormConfig,
CipherFormConfigService,
@ -131,11 +140,13 @@ export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
CipherFormModule,
AsyncActionsModule,
PopOutComponent,
IconButtonModule,
],
})
export class AddEditV2Component implements OnInit {
headerText: string;
config: CipherFormConfig;
canDeleteCipher$: Observable<boolean>;
get loading() {
return this.config == null;
@ -165,6 +176,10 @@ export class AddEditV2Component implements OnInit {
private router: Router,
private cipherService: CipherService,
private eventCollectionService: EventCollectionService,
private logService: LogService,
private toastService: ToastService,
private dialogService: DialogService,
protected cipherAuthorizationService: CipherAuthorizationService,
) {
this.subscribeToParams();
}
@ -281,6 +296,10 @@ export class AddEditV2Component implements OnInit {
}
if (["edit", "partial-edit"].includes(config.mode) && config.originalCipher?.id) {
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(
config.originalCipher,
);
await this.eventCollectionService.collect(
EventType.Cipher_ClientViewed,
config.originalCipher.id,
@ -337,6 +356,43 @@ export class AddEditV2Component implements OnInit {
return this.i18nService.t(partOne, this.i18nService.t("typeSshKey"));
}
}
delete = async () => {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteItem" },
content: {
key: "deleteItemConfirmation",
},
type: "warning",
});
if (!confirmed) {
return false;
}
try {
await this.deleteCipher();
} catch (e) {
this.logService.error(e);
return false;
}
await this.router.navigate(["/tabs/vault"]);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("deletedItem"),
});
return true;
};
protected deleteCipher() {
return this.config.originalCipher.deletedDate
? this.cipherService.deleteWithServer(this.config.originalCipher.id)
: this.cipherService.softDeleteWithServer(this.config.originalCipher.id);
}
}
/**

View File

@ -91,6 +91,7 @@
<span data-testid="item-name">{{ cipher.name }}</span>
<i
*ngIf="cipher.organizationId"
slot="default-trailing"
appOrgIcon
[tierType]="cipher.organization.productTierType"
[size]="'small'"

View File

@ -71,9 +71,9 @@ export class VaultPopupItemsService {
this.vaultPopupAutofillService.nonLoginCipherTypesOnPage$,
]).pipe(
map(([showCardsSettingEnabled, showIdentitiesSettingEnabled, nonLoginCipherTypesOnPage]) => {
const showCards = showCardsSettingEnabled && nonLoginCipherTypesOnPage[CipherType.Card];
const showCards = showCardsSettingEnabled || nonLoginCipherTypesOnPage[CipherType.Card];
const showIdentities =
showIdentitiesSettingEnabled && nonLoginCipherTypesOnPage[CipherType.Identity];
showIdentitiesSettingEnabled || nonLoginCipherTypesOnPage[CipherType.Identity];
return [
...(showCards ? [CipherType.Card] : []),

View File

@ -0,0 +1,9 @@
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import * as sdk from "@bitwarden/sdk-internal";
export class CliSdkLoadService implements SdkLoadService {
async load(): Promise<void> {
const module = await import("@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm");
(sdk as any).init(module);
}
}

View File

@ -68,6 +68,7 @@ import {
RegionConfig,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { KeySuffixOptions, LogLevelType } from "@bitwarden/common/platform/enums";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
@ -171,6 +172,7 @@ import {
import { CliBiometricsService } from "../key-management/cli-biometrics-service";
import { flagEnabled } from "../platform/flags";
import { CliPlatformUtilsService } from "../platform/services/cli-platform-utils.service";
import { CliSdkLoadService } from "../platform/services/cli-sdk-load.service";
import { ConsoleLogService } from "../platform/services/console-log.service";
import { I18nService } from "../platform/services/i18n.service";
import { LowdbStorageService } from "../platform/services/lowdb-storage.service";
@ -270,6 +272,7 @@ export class ServiceContainer {
kdfConfigService: KdfConfigService;
taskSchedulerService: TaskSchedulerService;
sdkService: SdkService;
sdkLoadService: SdkLoadService;
cipherAuthorizationService: CipherAuthorizationService;
constructor() {
@ -570,6 +573,7 @@ export class ServiceContainer {
const sdkClientFactory = flagEnabled("sdk")
? new DefaultSdkClientFactory()
: new NoopSdkClientFactory();
this.sdkLoadService = new CliSdkLoadService();
this.sdkService = new DefaultSdkService(
sdkClientFactory,
this.environmentService,
@ -859,6 +863,7 @@ export class ServiceContainer {
return;
}
await this.sdkLoadService.load();
await this.storageService.init();
await this.stateService.init();
this.containerService.attachToGlobal(global);

View File

@ -2,10 +2,14 @@
// @ts-strict-ignore
import { OptionValues } from "commander";
import * as inquirer from "inquirer";
import { firstValueFrom, map } from "rxjs";
import { firstValueFrom } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import {
OrganizationService,
getOrganizationById,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ImportServiceAbstraction, ImportType } from "@bitwarden/importer/core";
@ -24,16 +28,12 @@ export class ImportCommand {
async run(format: ImportType, filepath: string, options: OptionValues): Promise<Response> {
const organizationId = options.organizationid;
if (organizationId != null) {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
if (!userId) {
return Response.badRequest("No user found.");
}
const organization = await firstValueFrom(
this.organizationService
.organizations$(userId)
.pipe(map((organizations) => organizations.find((o) => o.id === organizationId))),
this.organizationService.organizations$(userId).pipe(getOrganizationById(organizationId)),
);
if (organization == null) {

View File

@ -1,7 +1,7 @@
{
"name": "@bitwarden/desktop",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.1.4",
"version": "2025.1.5",
"keywords": [
"bitwarden",
"password",

View File

@ -1,6 +1,7 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component";
import {
DesktopDefaultOverlayPosition,
EnvironmentSelectorComponent,
@ -9,10 +10,12 @@ import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui
import {
authGuard,
lockGuard,
activeAuthGuard,
redirectGuard,
tdeDecryptionRequiredGuard,
unauthGuardFn,
} from "@bitwarden/angular/auth/guards";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards";
import {
AnonLayoutWrapperComponent,
@ -36,9 +39,11 @@ import {
SsoComponent,
TwoFactorTimeoutIcon,
TwoFactorAuthComponent,
TwoFactorTimeoutComponent,
TwoFactorAuthGuard,
NewDeviceVerificationComponent,
DeviceVerificationIcon,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { LockComponent } from "@bitwarden/key-management/angular";
import {
NewDeviceVerificationNoticePageOneComponent,
@ -97,12 +102,12 @@ const routes: Routes = [
},
),
{
path: "2fa-timeout",
path: "authentication-timeout",
component: AnonLayoutWrapperComponent,
children: [
{
path: "",
component: TwoFactorTimeoutComponent,
component: AuthenticationTimeoutComponent,
},
],
data: {
@ -112,6 +117,25 @@ const routes: Routes = [
},
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
{
path: "device-verification",
component: AnonLayoutWrapperComponent,
canActivate: [
canAccessFeature(FeatureFlag.NewDeviceVerification),
unauthGuardFn(),
activeAuthGuard(),
],
children: [{ path: "", component: NewDeviceVerificationComponent }],
data: {
pageIcon: DeviceVerificationIcon,
pageTitle: {
key: "verifyIdentity",
},
pageSubtitle: {
key: "weDontRecognizeThisDevice",
},
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
{ path: "register", component: RegisterComponent },
{
path: "new-device-notice",

View File

@ -11,6 +11,7 @@ import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/comm
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
@ -47,11 +48,13 @@ export class InitService {
private versionService: VersionService,
private sshAgentService: SshAgentService,
private autofillService: DesktopAutofillService,
private sdkLoadService: SdkLoadService,
@Inject(DOCUMENT) private document: Document,
) {}
init() {
return async () => {
await this.sdkLoadService.load();
await this.sshAgentService.init();
this.nativeMessagingService.init();
await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process

View File

@ -68,6 +68,7 @@ import {
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/platform/abstractions/system.service";
@ -78,7 +79,9 @@ import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling";
import { Fido2AuthenticatorService } from "@bitwarden/common/platform/services/fido2/fido2-authenticator.service";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory";
import { DefaultSdkLoadService } from "@bitwarden/common/platform/services/sdk/default-sdk-load.service";
import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory";
import { NoopSdkLoadService } from "@bitwarden/common/platform/services/sdk/noop-sdk-load.service";
import { SystemService } from "@bitwarden/common/platform/services/system.service";
import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- Implementation for memory storage
@ -405,6 +408,11 @@ const safeProviders: SafeProvider[] = [
useClass: flagEnabled("sdk") ? DefaultSdkClientFactory : NoopSdkClientFactory,
deps: [],
}),
safeProvider({
provide: SdkLoadService,
useClass: flagEnabled("sdk") ? DefaultSdkLoadService : NoopSdkLoadService,
deps: [],
}),
safeProvider({
provide: LoginEmailService,
useClass: LoginEmailService,

View File

@ -885,6 +885,15 @@
"message": "Verify with Duo Security for your organization using the Duo Mobile app, SMS, phone call, or U2F security key.",
"description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated."
},
"verifyIdentity": {
"message": "Verify your Identity"
},
"weDontRecognizeThisDevice": {
"message": "We don't recognize this device. Enter the code sent to your email to verify your identity."
},
"continueLoggingIn": {
"message": "Continue logging in"
},
"webAuthnTitle": {
"message": "FIDO2 WebAuthn"
},

View File

@ -1,12 +1,12 @@
{
"name": "@bitwarden/desktop",
"version": "2025.1.4",
"version": "2025.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@bitwarden/desktop",
"version": "2025.1.4",
"version": "2025.1.5",
"license": "GPL-3.0",
"dependencies": {
"@bitwarden/desktop-napi": "file:../desktop_native/napi"

View File

@ -2,7 +2,7 @@
"name": "@bitwarden/desktop",
"productName": "Bitwarden",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.1.4",
"version": "2025.1.5",
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"license": "GPL-3.0",

View File

@ -6,7 +6,6 @@ import { firstValueFrom, concatMap, map, lastValueFrom, startWith, debounceTime
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
@ -123,7 +122,6 @@ export abstract class BasePeopleComponent<
protected platformUtilsService: PlatformUtilsService,
protected keyService: KeyService,
protected validationService: ValidationService,
protected modalService: ModalService,
private logService: LogService,
private searchPipe: SearchPipe,
protected userNamePipe: UserNamePipe,

View File

@ -2,9 +2,11 @@
<bit-dialog [disablePadding]="!loading" dialogSize="large">
<span bitDialogTitle>
{{ title }}
<span class="tw-text-sm tw-normal-case tw-text-muted" *ngIf="!loading && params.name">{{
params.name
}}</span>
<span
class="tw-text-sm tw-normal-case tw-text-muted"
*ngIf="!loading && editParams$ && (editParams$ | async)?.name"
>{{ (editParams$ | async)?.name }}</span
>
<span bitBadge variant="secondary" *ngIf="isRevoked">{{ "revoked" | i18n }}</span>
</span>
<div bitDialogContent>
@ -268,7 +270,9 @@
</button>
<button
*ngIf="
editMode && (!(accountDeprovisioningEnabled$ | async) || !params.managedByOrganization)
this.editMode &&
(!(accountDeprovisioningEnabled$ | async) ||
!(editParams$ | async)?.managedByOrganization)
"
type="button"
bitIconButton="bwi-close"
@ -280,7 +284,9 @@
></button>
<button
*ngIf="
editMode && (accountDeprovisioningEnabled$ | async) && params.managedByOrganization
this.editMode &&
(accountDeprovisioningEnabled$ | async) &&
(editParams$ | async)?.managedByOrganization
"
type="button"
bitIconButton="bwi-trash"

View File

@ -55,6 +55,7 @@ import {
} from "../../../shared/components/access-selector";
import { commaSeparatedEmails } from "./validators/comma-separated-emails.validator";
import { inputEmailLimitValidator } from "./validators/input-email-limit.validator";
import { orgSeatLimitReachedValidator } from "./validators/org-seat-limit-reached.validator";
export enum MemberDialogTab {
@ -63,18 +64,28 @@ export enum MemberDialogTab {
Collections = 2,
}
export interface MemberDialogParams {
name: string;
organizationId: string;
organizationUserId: string;
allOrganizationUserEmails: string[];
usesKeyConnector: boolean;
interface CommonMemberDialogParams {
isOnSecretsManagerStandalone: boolean;
initialTab?: MemberDialogTab;
numSeatsUsed: number;
managedByOrganization?: boolean;
organizationId: string;
}
export interface AddMemberDialogParams extends CommonMemberDialogParams {
kind: "Add";
occupiedSeatCount: number;
allOrganizationUserEmails: string[];
}
export interface EditMemberDialogParams extends CommonMemberDialogParams {
kind: "Edit";
name: string;
organizationUserId: string;
usesKeyConnector: boolean;
managedByOrganization?: boolean;
initialTab: MemberDialogTab;
}
export type MemberDialogParams = EditMemberDialogParams | AddMemberDialogParams;
export enum MemberDialogResult {
Saved = "saved",
Canceled = "canceled",
@ -98,6 +109,7 @@ export class MemberDialogComponent implements OnDestroy {
showNoMasterPasswordWarning = false;
isOnSecretsManagerStandalone: boolean;
remainingSeats$: Observable<number>;
editParams$: Observable<EditMemberDialogParams>;
protected organization$: Observable<Organization>;
protected collectionAccessItems: AccessItemView[] = [];
@ -143,6 +155,12 @@ export class MemberDialogComponent implements OnDestroy {
return this.formGroup.value.type === OrganizationUserType.Custom;
}
isEditDialogParams(
params: EditMemberDialogParams | AddMemberDialogParams,
): params is EditMemberDialogParams {
return params.kind === "Edit";
}
constructor(
@Inject(DIALOG_DATA) protected params: MemberDialogParams,
private dialogRef: DialogRef<MemberDialogResult>,
@ -168,9 +186,24 @@ export class MemberDialogComponent implements OnDestroy {
),
);
this.editMode = this.params.organizationUserId != null;
this.tabIndex = this.params.initialTab ?? MemberDialogTab.Role;
this.title = this.i18nService.t(this.editMode ? "editMember" : "inviteMember");
let userDetails$;
if (this.isEditDialogParams(this.params)) {
this.editMode = true;
this.title = this.i18nService.t("editMember");
userDetails$ = this.userService.get(
this.params.organizationId,
this.params.organizationUserId,
);
this.tabIndex = this.params.initialTab;
this.editParams$ = of(this.params);
} else {
this.editMode = false;
this.title = this.i18nService.t("inviteMember");
this.editParams$ = of(null);
userDetails$ = of(null);
this.tabIndex = MemberDialogTab.Role;
}
this.isOnSecretsManagerStandalone = this.params.isOnSecretsManagerStandalone;
if (this.isOnSecretsManagerStandalone) {
@ -187,10 +220,6 @@ export class MemberDialogComponent implements OnDestroy {
),
);
const userDetails$ = this.params.organizationUserId
? this.userService.get(this.params.organizationId, this.params.organizationUserId)
: of(null);
this.allowAdminAccessToAllCollectionItems$ = this.organization$.pipe(
map((organization) => {
return organization.allowAdminAccessToAllCollectionItems;
@ -271,18 +300,32 @@ export class MemberDialogComponent implements OnDestroy {
});
this.remainingSeats$ = this.organization$.pipe(
map((organization) => organization.seats - this.params.numSeatsUsed),
map((organization) => {
if (!this.isEditDialogParams(this.params)) {
return organization.seats - this.params.occupiedSeatCount;
}
return organization.seats;
}),
);
}
private setFormValidators(organization: Organization) {
if (this.isEditDialogParams(this.params)) {
return;
}
const emailsControlValidators = [
Validators.required,
commaSeparatedEmails,
inputEmailLimitValidator(organization, (maxEmailsCount: number) =>
this.i18nService.t("tooManyEmails", maxEmailsCount),
),
orgSeatLimitReachedValidator(
organization,
this.params.allOrganizationUserEmails,
this.i18nService.t("subscriptionUpgrade", organization.seats),
this.params.occupiedSeatCount,
),
];
@ -433,14 +476,25 @@ export class MemberDialogComponent implements OnDestroy {
return;
}
const userView = await this.getUserView();
if (this.isEditDialogParams(this.params)) {
await this.handleEditUser(userView, this.params);
} else {
await this.handleInviteUsers(userView, organization);
}
};
private async getUserView(): Promise<OrganizationUserAdminView> {
const userView = new OrganizationUserAdminView();
userView.id = this.params.organizationUserId;
userView.organizationId = this.params.organizationId;
userView.type = this.formGroup.value.type;
userView.permissions = this.setRequestPermissions(
userView.permissions ?? new PermissionsApi(),
userView.type !== OrganizationUserType.Custom,
);
userView.collections = this.formGroup.value.access
.filter((v) => v.type === AccessItemType.Collection)
.map(convertToSelectionView);
@ -451,44 +505,40 @@ export class MemberDialogComponent implements OnDestroy {
userView.accessSecretsManager = this.formGroup.value.accessSecretsManager;
if (this.editMode) {
await this.userService.save(userView);
} else {
userView.id = this.params.organizationUserId;
const maxEmailsCount =
organization.productTierType === ProductTierType.TeamsStarter ? 10 : 20;
const emails = [...new Set(this.formGroup.value.emails.trim().split(/\s*,\s*/))];
if (emails.length > maxEmailsCount) {
this.formGroup.controls.emails.setErrors({
tooManyEmails: { message: this.i18nService.t("tooManyEmails", maxEmailsCount) },
});
return;
}
if (
organization.hasReseller &&
this.params.numSeatsUsed + emails.length > organization.seats
) {
this.formGroup.controls.emails.setErrors({
tooManyEmails: { message: this.i18nService.t("seatLimitReachedContactYourProvider") },
});
return;
}
await this.userService.invite(emails, userView);
}
return userView;
}
private async handleEditUser(
userView: OrganizationUserAdminView,
params: EditMemberDialogParams,
) {
userView.id = params.organizationUserId;
await this.userService.save(userView);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t(
this.editMode ? "editedUserId" : "invitedUsers",
this.params.name,
),
message: this.i18nService.t("editedUserId", params.name),
});
this.close(MemberDialogResult.Saved);
}
private async handleInviteUsers(userView: OrganizationUserAdminView, organization: Organization) {
const emails = [...new Set(this.formGroup.value.emails.trim().split(/\s*,\s*/))];
await this.userService.invite(emails, userView);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("invitedUsers"),
});
this.close(MemberDialogResult.Saved);
};
}
remove = async () => {
if (!this.editMode) {
if (!this.isEditDialogParams(this.params)) {
return;
}
@ -507,7 +557,7 @@ export class MemberDialogComponent implements OnDestroy {
}
if (this.showNoMasterPasswordWarning) {
confirmed = await this.noMasterPasswordConfirmationDialog();
confirmed = await this.noMasterPasswordConfirmationDialog(this.params.name);
if (!confirmed) {
return false;
@ -528,7 +578,7 @@ export class MemberDialogComponent implements OnDestroy {
};
revoke = async () => {
if (!this.editMode) {
if (!this.isEditDialogParams(this.params)) {
return;
}
@ -544,7 +594,7 @@ export class MemberDialogComponent implements OnDestroy {
}
if (this.showNoMasterPasswordWarning) {
confirmed = await this.noMasterPasswordConfirmationDialog();
confirmed = await this.noMasterPasswordConfirmationDialog(this.params.name);
if (!confirmed) {
return false;
@ -566,7 +616,7 @@ export class MemberDialogComponent implements OnDestroy {
};
restore = async () => {
if (!this.editMode) {
if (!this.isEditDialogParams(this.params)) {
return;
}
@ -585,7 +635,7 @@ export class MemberDialogComponent implements OnDestroy {
};
delete = async () => {
if (!this.editMode) {
if (!this.isEditDialogParams(this.params)) {
return;
}
@ -633,14 +683,14 @@ export class MemberDialogComponent implements OnDestroy {
this.dialogRef.close(result);
}
private noMasterPasswordConfirmationDialog() {
private noMasterPasswordConfirmationDialog(username: string) {
return this.dialogService.openSimpleDialog({
title: {
key: "removeOrgUserNoMasterPasswordTitle",
},
content: {
key: "removeOrgUserNoMasterPasswordDesc",
placeholders: [this.params.name],
placeholders: [username],
},
type: "warning",
});

View File

@ -0,0 +1,191 @@
import { AbstractControl, FormControl } from "@angular/forms";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { inputEmailLimitValidator } from "./input-email-limit.validator";
const orgFactory = (props: Partial<Organization> = {}) =>
Object.assign(
new Organization(),
{
id: "myOrgId",
enabled: true,
type: OrganizationUserType.Admin,
},
props,
);
describe("inputEmailLimitValidator", () => {
const getErrorMessage = (max: number) => `You can only add up to ${max} unique emails.`;
const createUniqueEmailString = (numberOfEmails: number) =>
Array(numberOfEmails)
.fill(null)
.map((_, i) => `email${i}@example.com`)
.join(", ");
const createIdenticalEmailString = (numberOfEmails: number) =>
Array(numberOfEmails)
.fill(null)
.map(() => `email@example.com`)
.join(", ");
describe("TeamsStarter limit validation", () => {
let teamsStarterOrganization: Organization;
beforeEach(() => {
teamsStarterOrganization = orgFactory({
productTierType: ProductTierType.TeamsStarter,
seats: 10,
});
});
it("should return null if unique email count is within the limit", () => {
// Arrange
const control = new FormControl(createUniqueEmailString(3));
const validatorFn = inputEmailLimitValidator(teamsStarterOrganization, getErrorMessage);
// Act
const result = validatorFn(control);
// Assert
expect(result).toBeNull();
});
it("should return null if unique email count is equal the limit", () => {
// Arrange
const control = new FormControl(createUniqueEmailString(10));
const validatorFn = inputEmailLimitValidator(teamsStarterOrganization, getErrorMessage);
// Act
const result = validatorFn(control);
// Assert
expect(result).toBeNull();
});
it("should return an error if unique email count exceeds the limit", () => {
// Arrange
const control = new FormControl(createUniqueEmailString(11));
const validatorFn = inputEmailLimitValidator(teamsStarterOrganization, getErrorMessage);
// Act
const result = validatorFn(control);
// Assert
expect(result).toEqual({
tooManyEmails: { message: "You can only add up to 10 unique emails." },
});
});
});
describe("Non-TeamsStarter limit validation", () => {
let nonTeamsStarterOrganization: Organization;
beforeEach(() => {
nonTeamsStarterOrganization = orgFactory({
productTierType: ProductTierType.Enterprise,
seats: 100,
});
});
it("should return null if unique email count is within the limit", () => {
// Arrange
const control = new FormControl(createUniqueEmailString(3));
const validatorFn = inputEmailLimitValidator(nonTeamsStarterOrganization, getErrorMessage);
// Act
const result = validatorFn(control);
// Assert
expect(result).toBeNull();
});
it("should return null if unique email count is equal the limit", () => {
// Arrange
const control = new FormControl(createUniqueEmailString(10));
const validatorFn = inputEmailLimitValidator(nonTeamsStarterOrganization, getErrorMessage);
// Act
const result = validatorFn(control);
// Assert
expect(result).toBeNull();
});
it("should return an error if unique email count exceeds the limit", () => {
// Arrange
const control = new FormControl(createUniqueEmailString(21));
const validatorFn = inputEmailLimitValidator(nonTeamsStarterOrganization, getErrorMessage);
// Act
const result = validatorFn(control);
// Assert
expect(result).toEqual({
tooManyEmails: { message: "You can only add up to 20 unique emails." },
});
});
});
describe("input email validation", () => {
let organization: Organization;
beforeEach(() => {
organization = orgFactory({
productTierType: ProductTierType.Enterprise,
seats: 100,
});
});
it("should ignore duplicate emails and validate only unique ones", () => {
// Arrange
const sixUniqueEmails = createUniqueEmailString(6);
const sixDuplicateEmails = createIdenticalEmailString(6);
const control = new FormControl(sixUniqueEmails + sixDuplicateEmails);
const validatorFn = inputEmailLimitValidator(organization, getErrorMessage);
// Act
const result = validatorFn(control);
// Assert
expect(result).toBeNull();
});
it("should return null if input is null", () => {
// Arrange
const control: AbstractControl = new FormControl(null);
const validatorFn = inputEmailLimitValidator(organization, getErrorMessage);
// Act
const result = validatorFn(control);
// Assert
expect(result).toBeNull();
});
it("should return null if input is empty", () => {
// Arrange
const control: AbstractControl = new FormControl("");
const validatorFn = inputEmailLimitValidator(organization, getErrorMessage);
// Act
const result = validatorFn(control);
// Assert
expect(result).toBeNull();
});
});
});

View File

@ -0,0 +1,40 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductTierType } from "@bitwarden/common/billing/enums";
function getUniqueInputEmails(control: AbstractControl): string[] {
const emails: string[] = control.value
.split(",")
.filter((email: string) => email && email.trim() !== "");
const uniqueEmails: string[] = Array.from(new Set(emails));
return uniqueEmails;
}
/**
* Ensure the number of unique emails in an input does not exceed the allowed maximum.
* @param organization An object representing the organization
* @param getErrorMessage A callback function that generates the error message. It takes the `maxEmailsCount` as a parameter.
* @returns A function that validates an `AbstractControl` and returns `ValidationErrors` or `null`
*/
export function inputEmailLimitValidator(
organization: Organization,
getErrorMessage: (maxEmailsCount: number) => string,
): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value?.trim()) {
return null;
}
const maxEmailsCount = organization.productTierType === ProductTierType.TeamsStarter ? 10 : 20;
const uniqueEmails = getUniqueInputEmails(control);
if (uniqueEmails.length <= maxEmailsCount) {
return null;
}
return { tooManyEmails: { message: getErrorMessage(maxEmailsCount) } };
};
}

View File

@ -1,10 +1,14 @@
import { AbstractControl, FormControl, ValidationErrors } from "@angular/forms";
import { FormControl } from "@angular/forms";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { orgSeatLimitReachedValidator } from "./org-seat-limit-reached.validator";
import {
orgSeatLimitReachedValidator,
isFixedSeatPlan,
isDynamicSeatPlan,
} from "./org-seat-limit-reached.validator";
const orgFactory = (props: Partial<Organization> = {}) =>
Object.assign(
@ -17,20 +21,35 @@ const orgFactory = (props: Partial<Organization> = {}) =>
props,
);
const createUniqueEmailString = (numberOfEmails: number) =>
Array(numberOfEmails)
.fill(null)
.map((_, i) => `email${i}@example.com`)
.join(", ");
const createIdenticalEmailString = (numberOfEmails: number) =>
Array(numberOfEmails)
.fill(null)
.map(() => `email@example.com`)
.join(", ");
describe("orgSeatLimitReachedValidator", () => {
let organization: Organization;
let allOrganizationUserEmails: string[];
let validatorFn: (control: AbstractControl) => ValidationErrors | null;
let occupiedSeatCount: number;
beforeEach(() => {
allOrganizationUserEmails = ["user1@example.com"];
allOrganizationUserEmails = [createUniqueEmailString(1)];
occupiedSeatCount = 1;
organization = null;
});
it("should return null when control value is empty", () => {
validatorFn = orgSeatLimitReachedValidator(
const validatorFn = orgSeatLimitReachedValidator(
organization,
allOrganizationUserEmails,
"You cannot invite more than 2 members without upgrading your plan.",
occupiedSeatCount,
);
const control = new FormControl("");
@ -40,10 +59,11 @@ describe("orgSeatLimitReachedValidator", () => {
});
it("should return null when control value is null", () => {
validatorFn = orgSeatLimitReachedValidator(
const validatorFn = orgSeatLimitReachedValidator(
organization,
allOrganizationUserEmails,
"You cannot invite more than 2 members without upgrading your plan.",
occupiedSeatCount,
);
const control = new FormControl(null);
@ -52,82 +72,123 @@ describe("orgSeatLimitReachedValidator", () => {
expect(result).toBeNull();
});
it("should return null when max seats are not exceeded on free plan", () => {
organization = orgFactory({
productTierType: ProductTierType.Free,
seats: 2,
});
validatorFn = orgSeatLimitReachedValidator(
organization,
allOrganizationUserEmails,
"You cannot invite more than 2 members without upgrading your plan.",
);
const control = new FormControl("user2@example.com");
const result = validatorFn(control);
expect(result).toBeNull();
});
it("should return null when max seats are not exceeded on teams starter plan", () => {
organization = orgFactory({
productTierType: ProductTierType.TeamsStarter,
seats: 10,
});
validatorFn = orgSeatLimitReachedValidator(
organization,
allOrganizationUserEmails,
"You cannot invite more than 10 members without upgrading your plan.",
);
const control = new FormControl(
"user2@example.com," +
"user3@example.com," +
"user4@example.com," +
"user5@example.com," +
"user6@example.com," +
"user7@example.com," +
"user8@example.com," +
"user9@example.com," +
"user10@example.com",
);
const result = validatorFn(control);
expect(result).toBeNull();
});
it("should return validation error when max seats are exceeded on free plan", () => {
organization = orgFactory({
productTierType: ProductTierType.Free,
seats: 2,
});
const errorMessage = "You cannot invite more than 2 members without upgrading your plan.";
validatorFn = orgSeatLimitReachedValidator(
organization,
allOrganizationUserEmails,
"You cannot invite more than 2 members without upgrading your plan.",
);
const control = new FormControl("user2@example.com,user3@example.com");
const result = validatorFn(control);
expect(result).toStrictEqual({ seatLimitReached: { message: errorMessage } });
});
it("should return null when not on free plan", () => {
const control = new FormControl("user2@example.com,user3@example.com");
organization = orgFactory({
it("should return null when on dynamic seat plan", () => {
const control = new FormControl(createUniqueEmailString(1));
const organization = orgFactory({
productTierType: ProductTierType.Enterprise,
seats: 100,
});
validatorFn = orgSeatLimitReachedValidator(
const validatorFn = orgSeatLimitReachedValidator(
organization,
allOrganizationUserEmails,
"You cannot invite more than 2 members without upgrading your plan.",
"Enterprise plan dummy error.",
occupiedSeatCount,
);
const result = validatorFn(control);
expect(result).toBeNull();
});
it("should only count unique input email addresses", () => {
const twoUniqueEmails = createUniqueEmailString(2);
const sixDuplicateEmails = createIdenticalEmailString(6);
const control = new FormControl(twoUniqueEmails + sixDuplicateEmails);
const organization = orgFactory({
productTierType: ProductTierType.Families,
seats: 6,
});
const occupiedSeatCount = 3;
const validatorFn = orgSeatLimitReachedValidator(
organization,
allOrganizationUserEmails,
"Family plan dummy error.",
occupiedSeatCount,
);
const result = validatorFn(control);
expect(result).toBeNull();
});
describe("when total occupied seat count is below plan's max count", () => {
test.each([
[ProductTierType.Free, 2],
[ProductTierType.Families, 6],
[ProductTierType.TeamsStarter, 10],
])(`should return null on plan %s`, (plan, planSeatCount) => {
const organization = orgFactory({
productTierType: plan,
seats: planSeatCount,
});
const occupiedSeatCount = 0;
const validatorFn = orgSeatLimitReachedValidator(
organization,
allOrganizationUserEmails,
"Generic error message",
occupiedSeatCount,
);
const control = new FormControl(createUniqueEmailString(1));
const result = validatorFn(control);
expect(result).toBeNull();
});
});
describe("when total occupied seat count is at plan's max count", () => {
test.each([
[ProductTierType.Free, 2, 1],
[ProductTierType.Families, 6, 5],
[ProductTierType.TeamsStarter, 10, 9],
])(`should return null on plan %s`, (plan, planSeatCount, newEmailCount) => {
const organization = orgFactory({
productTierType: plan,
seats: planSeatCount,
});
const occupiedSeatCount = 1;
const validatorFn = orgSeatLimitReachedValidator(
organization,
allOrganizationUserEmails,
"Generic error message",
occupiedSeatCount,
);
const control = new FormControl(createUniqueEmailString(newEmailCount));
const result = validatorFn(control);
expect(result).toBeNull();
});
});
});
describe("isFixedSeatPlan", () => {
test.each([
[true, ProductTierType.Free],
[true, ProductTierType.Families],
[true, ProductTierType.TeamsStarter],
[false, ProductTierType.Enterprise],
])("should return %s for %s", (expected, input) => {
expect(isFixedSeatPlan(input)).toBe(expected);
});
});
describe("isDynamicSeatPlan", () => {
test.each([
[true, ProductTierType.Enterprise],
[true, ProductTierType.Teams],
[false, ProductTierType.Free],
[false, ProductTierType.Families],
[false, ProductTierType.TeamsStarter],
])("should return %s for %s", (expected, input) => {
expect(isDynamicSeatPlan(input)).toBe(expected);
});
});

View File

@ -9,41 +9,68 @@ import { ProductTierType } from "@bitwarden/common/billing/enums";
* @param organization An object representing the organization
* @param allOrganizationUserEmails An array of strings with existing user email addresses
* @param errorMessage A localized string to display if validation fails
* @param occupiedSeatCount The current count of active users occupying the organization's seats.
* @returns A function that validates an `AbstractControl` and returns `ValidationErrors` or `null`
*/
export function orgSeatLimitReachedValidator(
organization: Organization,
allOrganizationUserEmails: string[],
errorMessage: string,
occupiedSeatCount: number,
): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (control.value === "" || !control.value) {
if (!control.value?.trim()) {
return null;
}
const newEmailsToAdd = Array.from(
new Set(
control.value
.split(",")
.filter(
(newEmailToAdd: string) =>
newEmailToAdd &&
newEmailToAdd.trim() !== "" &&
!allOrganizationUserEmails.some(
(existingEmail) => existingEmail === newEmailToAdd.trim(),
),
),
),
);
if (isDynamicSeatPlan(organization.productTierType)) {
return null;
}
const productHasAdditionalSeatsOption =
organization.productTierType !== ProductTierType.Free &&
organization.productTierType !== ProductTierType.Families &&
organization.productTierType !== ProductTierType.TeamsStarter;
const newTotalUserCount =
occupiedSeatCount + getUniqueNewEmailCount(allOrganizationUserEmails, control);
return !productHasAdditionalSeatsOption &&
allOrganizationUserEmails.length + newEmailsToAdd.length > organization.seats
? { seatLimitReached: { message: errorMessage } }
: null;
if (newTotalUserCount > organization.seats) {
return { seatLimitReached: { message: errorMessage } };
}
return null;
};
}
export function isDynamicSeatPlan(productTierType: ProductTierType): boolean {
return !isFixedSeatPlan(productTierType);
}
export function isFixedSeatPlan(productTierType: ProductTierType): boolean {
switch (productTierType) {
case ProductTierType.Free:
case ProductTierType.Families:
case ProductTierType.TeamsStarter:
return true;
default:
return false;
}
}
function getUniqueNewEmailCount(
allOrganizationUserEmails: string[],
control: AbstractControl,
): number {
const newEmailsToAdd = Array.from(
new Set(
control.value
.split(",")
.filter(
(newEmailToAdd: string) =>
newEmailToAdd &&
newEmailToAdd.trim() !== "" &&
!allOrganizationUserEmails.some(
(existingEmail) => existingEmail === newEmailToAdd.trim(),
),
),
),
);
return newEmailsToAdd.length;
}

View File

@ -25,7 +25,6 @@ import {
CollectionDetailsResponse,
} from "@bitwarden/admin-console/common";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
@ -77,6 +76,7 @@ import {
MemberDialogTab,
openUserAddEditDialog,
} from "./components/member-dialog";
import { isFixedSeatPlan } from "./components/member-dialog/validators/org-seat-limit-reached.validator";
import {
ResetPasswordComponent,
ResetPasswordDialogResult,
@ -110,6 +110,10 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
protected rowHeight = 69;
protected rowHeightClass = `tw-h-[69px]`;
get occupiedSeatCount(): number {
return this.dataSource.activeUserCount;
}
constructor(
apiService: ApiService,
i18nService: I18nService,
@ -133,7 +137,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
private groupService: GroupApiService,
private collectionService: CollectionService,
private billingApiService: BillingApiServiceAbstraction,
private modalService: ModalService,
private configService: ConfigService,
) {
super(
@ -477,68 +480,79 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
firstValueFrom(simpleDialog.closed).then(this.handleDialogClose.bind(this));
}
async edit(user: OrganizationUserView, initialTab: MemberDialogTab = MemberDialogTab.Role) {
if (
!user &&
this.organization.hasReseller &&
this.organization.seats === this.dataSource.confirmedUserCount
) {
private async handleInviteDialog() {
const dialog = openUserAddEditDialog(this.dialogService, {
data: {
kind: "Add",
organizationId: this.organization.id,
allOrganizationUserEmails: this.dataSource.data?.map((user) => user.email) ?? [],
occupiedSeatCount: this.occupiedSeatCount,
isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone,
},
});
const result = await lastValueFrom(dialog.closed);
if (result === MemberDialogResult.Saved) {
await this.load();
}
}
private async handleSeatLimitForFixedTiers() {
if (!this.organization.canEditSubscription) {
await this.showSeatLimitReachedDialog();
return;
}
const reference = openChangePlanDialog(this.dialogService, {
data: {
organizationId: this.organization.id,
subscription: null,
productTierType: this.organization.productTierType,
},
});
const result = await lastValueFrom(reference.closed);
if (result === ChangePlanDialogResultType.Submitted) {
await this.load();
}
}
async invite() {
if (this.organization.hasReseller && this.organization.seats === this.occupiedSeatCount) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("seatLimitReached"),
message: this.i18nService.t("contactYourProvider"),
});
return;
}
// Invite User: Add Flow
// Click on user email: Edit Flow
// User attempting to invite new users in a free org with max users
if (
!user &&
this.dataSource.data.length === this.organization.seats &&
(this.organization.productTierType === ProductTierType.Free ||
this.organization.productTierType === ProductTierType.TeamsStarter ||
this.organization.productTierType === ProductTierType.Families)
this.occupiedSeatCount === this.organization.seats &&
isFixedSeatPlan(this.organization.productTierType)
) {
if (!this.organization.canEditSubscription) {
await this.showSeatLimitReachedDialog();
return;
}
await this.handleSeatLimitForFixedTiers();
const reference = openChangePlanDialog(this.dialogService, {
data: {
organizationId: this.organization.id,
subscription: null,
productTierType: this.organization.productTierType,
},
});
const result = await lastValueFrom(reference.closed);
if (result === ChangePlanDialogResultType.Submitted) {
await this.load();
}
return;
}
const numSeatsUsed =
this.dataSource.confirmedUserCount +
this.dataSource.invitedUserCount +
this.dataSource.acceptedUserCount;
await this.handleInviteDialog();
}
async edit(user: OrganizationUserView, initialTab: MemberDialogTab = MemberDialogTab.Role) {
const dialog = openUserAddEditDialog(this.dialogService, {
data: {
kind: "Edit",
name: this.userNamePipe.transform(user),
organizationId: this.organization.id,
organizationUserId: user != null ? user.id : null,
allOrganizationUserEmails: this.dataSource.data?.map((user) => user.email) ?? [],
usesKeyConnector: user?.usesKeyConnector,
organizationUserId: user.id,
usesKeyConnector: user.usesKeyConnector,
isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone,
initialTab: initialTab,
numSeatsUsed,
managedByOrganization: user?.managedByOrganization,
managedByOrganization: user.managedByOrganization,
},
});
@ -550,9 +564,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
case MemberDialogResult.Saved:
case MemberDialogResult.Revoked:
case MemberDialogResult.Restored:
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.load();
await this.load();
break;
}
}

View File

@ -14,7 +14,6 @@ import {
takeUntil,
} from "rxjs";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
getOrganizationById,
@ -26,7 +25,6 @@ import { OrganizationUpdateRequest } from "@bitwarden/common/admin-console/model
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@ -85,7 +83,6 @@ export class AccountComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
constructor(
private modalService: ModalService,
private i18nService: I18nService,
private route: ActivatedRoute,
private platformUtilsService: PlatformUtilsService,
@ -97,7 +94,6 @@ export class AccountComponent implements OnInit, OnDestroy {
private dialogService: DialogService,
private formBuilder: FormBuilder,
private toastService: ToastService,
private configService: ConfigService,
) {}
async ngOnInit() {

View File

@ -6,7 +6,6 @@ import { ActivatedRoute } from "@angular/router";
import { concatMap, takeUntil, map, lastValueFrom, firstValueFrom } from "rxjs";
import { first, tap } from "rxjs/operators";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import {
getOrganizationById,
@ -36,7 +35,6 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme
constructor(
dialogService: DialogService,
apiService: ApiService,
modalService: ModalService,
messagingService: MessagingService,
policyService: PolicyService,
private route: ActivatedRoute,
@ -47,7 +45,6 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme
super(
dialogService,
apiService,
modalService,
messagingService,
policyService,
billingAccountProfileStateService,

View File

@ -17,6 +17,8 @@ import {
TableModule,
TabsModule,
} from "@bitwarden/components";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
import { PreloadedEnglishI18nModule } from "../../../../../core/tests";

View File

@ -13,6 +13,8 @@ import { Subject, takeUntil } from "rxjs";
import { ControlsOf } from "@bitwarden/angular/types/controls-of";
import { FormSelectionList } from "@bitwarden/angular/utils/form-selection-list";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
import {

View File

@ -6,6 +6,8 @@ import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-t
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { SharedModule } from "@bitwarden/components/src/shared";
import { I18nPipe } from "@bitwarden/ui-common";

View File

@ -8,6 +8,8 @@ import { IntegrationType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { SharedModule } from "@bitwarden/components/src/shared";
import { I18nPipe } from "@bitwarden/ui-common";

View File

@ -15,6 +15,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { RouterService } from "../../../../../../../../apps/web/src/app/core";
import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service";

View File

@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DialogRef } from "@angular/cdk/dialog";
import { Component, OnDestroy, OnInit, Type, ViewChild, ViewContainerRef } from "@angular/core";
import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import {
first,
firstValueFrom,
@ -14,7 +14,6 @@ import {
} from "rxjs";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@ -67,7 +66,6 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy {
constructor(
protected dialogService: DialogService,
protected apiService: ApiService,
protected modalService: ModalService,
protected messagingService: MessagingService,
protected policyService: PolicyService,
billingAccountProfileStateService: BillingAccountProfileStateService,
@ -268,13 +266,6 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy {
return type === TwoFactorProviderType.OrganizationDuo;
}
protected async openModal<T>(ref: ViewContainerRef, type: Type<T>): Promise<T> {
const [modal, childComponent] = await this.modalService.openViewRef(type, ref);
this.modal = modal;
return childComponent;
}
protected updateStatus(enabled: boolean, type: TwoFactorProviderType) {
if (!enabled && this.modal != null) {
this.modal.close();

View File

@ -10,6 +10,8 @@ import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstract
import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { DialogService } from "@bitwarden/components/src/dialog/dialog.service";
import { WebauthnLoginAdminService } from "../../../core/services/webauthn-login/webauthn-login-admin.service";

View File

@ -1,5 +1,7 @@
import { NgModule } from "@angular/core";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { BannerModule } from "../../../../../../libs/components/src/banner/banner.module";
import { UserVerificationModule } from "../../auth/shared/components/user-verification";
import { LooseComponentsModule } from "../../shared";

View File

@ -99,6 +99,7 @@ export class AdjustPaymentDialogV2Component implements OnInit {
submit = async (): Promise<void> => {
if (!this.taxInfoComponent.validate()) {
this.taxInfoComponent.markAllAsTouched();
return;
}
@ -117,10 +118,11 @@ export class AdjustPaymentDialogV2Component implements OnInit {
this.dialogRef.close(AdjustPaymentDialogV2ResultType.Submitted);
} catch (error) {
const msg = typeof error == "object" ? error.message : error;
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t(error.message) || error.message,
message: this.i18nService.t(msg) || msg,
});
}
};

View File

@ -68,6 +68,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { AppIdService as DefaultAppIdService } from "@bitwarden/common/platform/services/app-id.service";
@ -75,7 +76,9 @@ import { MemoryStorageService } from "@bitwarden/common/platform/services/memory
// eslint-disable-next-line import/no-restricted-paths -- Implementation for memory storage
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory";
import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory";
import { NoopSdkLoadService } from "@bitwarden/common/platform/services/sdk/noop-sdk-load.service";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
/* eslint-disable import/no-restricted-paths -- Implementation for memory storage */
import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state";
@ -114,7 +117,7 @@ import { WebProcessReloadService } from "../key-management/services/web-process-
import { WebBiometricsService } from "../key-management/web-biometric.service";
import { WebEnvironmentService } from "../platform/web-environment.service";
import { WebMigrationRunner } from "../platform/web-migration-runner";
import { WebSdkClientFactory } from "../platform/web-sdk-client-factory";
import { WebSdkLoadService } from "../platform/web-sdk-load.service";
import { WebStorageServiceProvider } from "../platform/web-storage-service.provider";
import { EventService } from "./event.service";
@ -297,9 +300,14 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultCollectionAdminService,
deps: [ApiService, KeyServiceAbstraction, EncryptService, CollectionService],
}),
safeProvider({
provide: SdkLoadService,
useClass: flagEnabled("sdk") ? WebSdkLoadService : NoopSdkLoadService,
deps: [],
}),
safeProvider({
provide: SdkClientFactory,
useClass: flagEnabled("sdk") ? WebSdkClientFactory : NoopSdkClientFactory,
useClass: flagEnabled("sdk") ? DefaultSdkClientFactory : NoopSdkClientFactory,
deps: [],
}),
safeProvider({

View File

@ -10,6 +10,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
@ -35,11 +36,13 @@ export class InitService {
private userAutoUnlockKeyService: UserAutoUnlockKeyService,
private accountService: AccountService,
private versionService: VersionService,
private sdkLoadService: SdkLoadService,
@Inject(DOCUMENT) private document: Document,
) {}
init() {
return async () => {
await this.sdkLoadService.load();
await this.stateService.init();
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);

View File

@ -2,8 +2,14 @@
// @ts-strict-ignore
import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common";
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { SendWithIdRequest } from "@bitwarden/common/src/tools/send/models/request/send-with-id.request";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { CipherWithIdRequest } from "@bitwarden/common/src/vault/models/request/cipher-with-id.request";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { FolderWithIdRequest } from "@bitwarden/common/src/vault/models/request/folder-with-id.request";
import { EmergencyAccessWithIdRequest } from "../../../auth/emergency-access/request/emergency-access-update.request";

View File

@ -7,6 +7,8 @@ import { BehaviorSubject } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { IconButtonModule, NavigationModule } from "@bitwarden/components";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { NavItemComponent } from "@bitwarden/components/src/navigation/nav-item.component";
import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-switcher.service";

View File

@ -13,6 +13,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { LayoutComponent, NavigationModule } from "@bitwarden/components";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { I18nMockService } from "@bitwarden/components/src/utils/i18n-mock.service";
import { ProductSwitcherService } from "../shared/product-switcher.service";

View File

@ -1,5 +1,7 @@
import { AfterViewInit, ChangeDetectorRef, Component, Input } from "@angular/core";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { IconButtonType } from "@bitwarden/components/src/icon-button/icon-button.component";
import { ProductSwitcherService } from "./shared/product-switcher.service";

View File

@ -13,6 +13,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { IconButtonModule, LinkModule, MenuModule } from "@bitwarden/components";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { I18nMockService } from "@bitwarden/components/src/utils/i18n-mock.service";
import { ProductSwitcherContentComponent } from "./product-switcher-content.component";

View File

@ -1,6 +1,7 @@
import { NgModule } from "@angular/core";
import { Route, RouterModule, Routes } from "@angular/router";
import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component";
import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap";
import {
authGuard,
@ -8,7 +9,9 @@ import {
redirectGuard,
tdeDecryptionRequiredGuard,
unauthGuardFn,
activeAuthGuard,
} from "@bitwarden/angular/auth/guards";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import { generatorSwap } from "@bitwarden/angular/tools/generator/generator-swap";
import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards";
import {
@ -36,9 +39,11 @@ import {
VaultIcon,
LoginDecryptionOptionsComponent,
TwoFactorAuthComponent,
TwoFactorTimeoutComponent,
TwoFactorAuthGuard,
NewDeviceVerificationComponent,
DeviceVerificationIcon,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { LockComponent } from "@bitwarden/key-management/angular";
import {
NewDeviceVerificationNoticePageOneComponent,
@ -563,12 +568,12 @@ const routes: Routes = [
} satisfies AnonLayoutWrapperData,
},
{
path: "2fa-timeout",
path: "authentication-timeout",
canActivate: [unauthGuardFn()],
children: [
{
path: "",
component: TwoFactorTimeoutComponent,
component: AuthenticationTimeoutComponent,
},
{
path: "",
@ -605,6 +610,29 @@ const routes: Routes = [
titleId: "recoverAccountTwoStep",
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
{
path: "device-verification",
canActivate: [
canAccessFeature(FeatureFlag.NewDeviceVerification),
unauthGuardFn(),
activeAuthGuard(),
],
children: [
{
path: "",
component: NewDeviceVerificationComponent,
},
],
data: {
pageIcon: DeviceVerificationIcon,
pageTitle: {
key: "verifyIdentity",
},
pageSubtitle: {
key: "weDontRecognizeThisDevice",
},
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
{
path: "accept-emergency",
canActivate: [deepLinkGuard()],

View File

@ -1,44 +0,0 @@
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import * as sdk from "@bitwarden/sdk-internal";
/**
* SDK client factory with a js fallback for when WASM is not supported.
*/
export class WebSdkClientFactory implements SdkClientFactory {
async createSdkClient(
...args: ConstructorParameters<typeof sdk.BitwardenClient>
): Promise<sdk.BitwardenClient> {
const module = await load();
(sdk as any).init(module);
return Promise.resolve(new sdk.BitwardenClient(...args));
}
}
// https://stackoverflow.com/a/47880734
const supported = (() => {
try {
if (typeof WebAssembly === "object" && typeof WebAssembly.instantiate === "function") {
const module = new WebAssembly.Module(
Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00),
);
if (module instanceof WebAssembly.Module) {
return new WebAssembly.Instance(module) instanceof WebAssembly.Instance;
}
}
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
// ignore
}
return false;
})();
async function load() {
if (supported) {
return await import("@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm");
} else {
return await import("@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm.js");
}
}

View File

@ -0,0 +1,31 @@
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import * as sdk from "@bitwarden/sdk-internal";
// https://stackoverflow.com/a/47880734
const supported = (() => {
try {
if (typeof WebAssembly === "object" && typeof WebAssembly.instantiate === "function") {
const module = new WebAssembly.Module(
Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00),
);
if (module instanceof WebAssembly.Module) {
return new WebAssembly.Instance(module) instanceof WebAssembly.Instance;
}
}
} catch {
// ignore
}
return false;
})();
export class WebSdkLoadService implements SdkLoadService {
async load(): Promise<void> {
let module: any;
if (supported) {
module = await import("@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm");
} else {
module = await import("@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm.js");
}
(sdk as any).init(module);
}
}

View File

@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Guid } from "@bitwarden/common/src/types/guid";
import { Guid } from "@bitwarden/common/types/guid";
export class RequestSMAccessRequest {
OrganizationId: Guid;

View File

@ -1,8 +1,7 @@
import { Injectable } from "@angular/core";
import { CollectionAdminService, CollectionAdminView } from "@bitwarden/admin-console/common";
import { ImportCollectionServiceAbstraction } from "../../../../../../libs/importer/src/services/import-collection.service.abstraction";
import { ImportCollectionServiceAbstraction } from "@bitwarden/importer/core";
@Injectable()
export class ImportCollectionAdminService implements ImportCollectionServiceAbstraction {

View File

@ -204,6 +204,7 @@ export class VaultBannersService {
private async isLowKdfIteration(userId: UserId) {
const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId));
return (
kdfConfig != null &&
kdfConfig.kdfType === KdfType.PBKDF2_SHA256 &&
kdfConfig.iterations < PBKDF2KdfConfig.ITERATIONS.defaultValue
);

View File

@ -4,6 +4,8 @@ import { Observable } from "rxjs";
import { CollectionAdminView, CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { FolderView } from "@bitwarden/common/src/vault/models/view/folder.view";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";

View File

@ -1,5 +1,7 @@
import { CollectionAdminView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { FolderView } from "@bitwarden/common/src/vault/models/view/folder.view";
import { CipherType } from "@bitwarden/common/vault/enums";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";

View File

@ -26,7 +26,11 @@ import {
ToastService,
} from "@bitwarden/components";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { PremiumUpgradePromptService } from "../../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component";
import { SharedModule } from "../../shared/shared.module";
import { WebVaultPremiumUpgradePromptService } from "../services/web-premium-upgrade-prompt.service";

View File

@ -16,6 +16,8 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import {
CipherFormConfig,
CipherFormConfigService,

View File

@ -3,6 +3,8 @@ import { Injectable } from "@angular/core";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { ViewPasswordHistoryService } from "../../../../../../libs/common/src/vault/abstractions/view-password-history.service";
import { openPasswordHistoryDialog } from "../individual-vault/password-history.component";

View File

@ -176,6 +176,12 @@
"totalApplications": {
"message": "Total applications"
},
"unmarkAsCriticalApp": {
"message": "Unmark as critical app"
},
"criticalApplicationSuccessfullyUnmarked": {
"message": "Critical application successfully unmarked"
},
"whatTypeOfItem": {
"message": "What type of item is this?"
},
@ -1182,6 +1188,12 @@
"verifyIdentity": {
"message": "Verify your Identity"
},
"weDontRecognizeThisDevice": {
"message": "We don't recognize this device. Enter the code sent to your email to verify your identity."
},
"continueLoggingIn": {
"message": "Continue logging in"
},
"whatIsADevice": {
"message": "What is a device?"
},

View File

@ -1,6 +1,9 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Opaque } from "type-fest";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { BadgeVariant } from "@bitwarden/components";
@ -113,3 +116,31 @@ export type AtRiskApplicationDetail = {
applicationName: string;
atRiskPasswordCount: number;
};
/**
* Request to drop a password health report application
* Model is expected by the API endpoint
*/
export interface PasswordHealthReportApplicationDropRequest {
organizationId: OrganizationId;
passwordHealthReportApplicationIds: string[];
}
/**
* Response from the API after marking an app as critical
*/
export interface PasswordHealthReportApplicationsResponse {
id: PasswordHealthReportApplicationId;
organizationId: OrganizationId;
uri: string;
}
/*
* Request to save a password health report application
* Model is expected by the API endpoint
*/
export interface PasswordHealthReportApplicationsRequest {
organizationId: OrganizationId;
url: string;
}
export type PasswordHealthReportApplicationId = Opaque<string, "PasswordHealthReportApplicationId">;

View File

@ -3,12 +3,14 @@ import { mock } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { CriticalAppsApiService } from "./critical-apps-api.service";
import {
PasswordHealthReportApplicationDropRequest,
PasswordHealthReportApplicationId,
PasswordHealthReportApplicationsRequest,
PasswordHealthReportApplicationsResponse,
} from "./critical-apps.service";
} from "../models/password-health";
import { CriticalAppsApiService } from "./critical-apps-api.service";
describe("CriticalAppsApiService", () => {
let service: CriticalAppsApiService;
@ -76,4 +78,24 @@ describe("CriticalAppsApiService", () => {
done();
});
});
it("should call apiService.send with correct parameters for DropCriticalApp", (done) => {
const request: PasswordHealthReportApplicationDropRequest = {
organizationId: "org1" as OrganizationId,
passwordHealthReportApplicationIds: ["123"],
};
apiService.send.mockReturnValue(Promise.resolve());
service.dropCriticalApp(request).subscribe(() => {
expect(apiService.send).toHaveBeenCalledWith(
"DELETE",
"/reports/password-health-report-application/",
request,
true,
true,
);
done();
});
});
});

View File

@ -4,9 +4,10 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
PasswordHealthReportApplicationDropRequest,
PasswordHealthReportApplicationsRequest,
PasswordHealthReportApplicationsResponse,
} from "./critical-apps.service";
} from "../models/password-health";
export class CriticalAppsApiService {
constructor(private apiService: ApiService) {}
@ -36,4 +37,16 @@ export class CriticalAppsApiService {
return from(dbResponse as Promise<PasswordHealthReportApplicationsResponse[]>);
}
dropCriticalApp(request: PasswordHealthReportApplicationDropRequest): Observable<void> {
const dbResponse = this.apiService.send(
"DELETE",
"/reports/password-health-report-application/",
request,
true,
true,
);
return from(dbResponse as Promise<void>);
}
}

View File

@ -12,13 +12,14 @@ import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { CriticalAppsApiService } from "./critical-apps-api.service";
import {
CriticalAppsService,
PasswordHealthReportApplicationId,
PasswordHealthReportApplicationsRequest,
PasswordHealthReportApplicationsResponse,
} from "./critical-apps.service";
} from "../models/password-health";
import { CriticalAppsApiService } from "./critical-apps-api.service";
import { CriticalAppsService } from "./critical-apps.service";
describe("CriticalAppsService", () => {
let service: CriticalAppsService;
@ -139,4 +140,54 @@ describe("CriticalAppsService", () => {
expect(res).toHaveLength(2);
});
});
it("should drop a critical app", async () => {
// arrange
const orgId = "org1" as OrganizationId;
const selectedUrl = "https://example.com";
const initialList = [
{ id: "id1", organizationId: "org1", uri: "https://example.com" },
{ id: "id2", organizationId: "org1", uri: "https://example.org" },
] as PasswordHealthReportApplicationsResponse[];
service.setAppsInListForOrg(initialList);
// act
await service.dropCriticalApp(orgId, selectedUrl);
// expectations
expect(criticalAppsApiService.dropCriticalApp).toHaveBeenCalledWith({
organizationId: orgId,
passwordHealthReportApplicationIds: ["id1"],
});
expect(service.getAppsListForOrg(orgId)).toBeTruthy();
service.getAppsListForOrg(orgId).subscribe((res) => {
expect(res).toHaveLength(1);
expect(res[0].uri).toBe("https://example.org");
});
});
it("should not drop a critical app if it does not exist", async () => {
// arrange
const orgId = "org1" as OrganizationId;
const selectedUrl = "https://nonexistent.com";
const initialList = [
{ id: "id1", organizationId: "org1", uri: "https://example.com" },
{ id: "id2", organizationId: "org1", uri: "https://example.org" },
] as PasswordHealthReportApplicationsResponse[];
service.setAppsInListForOrg(initialList);
// act
await service.dropCriticalApp(orgId, selectedUrl);
// expectations
expect(criticalAppsApiService.dropCriticalApp).not.toHaveBeenCalled();
expect(service.getAppsListForOrg(orgId)).toBeTruthy();
service.getAppsListForOrg(orgId).subscribe((res) => {
expect(res).toHaveLength(2);
});
});
});

View File

@ -12,7 +12,6 @@ import {
takeUntil,
zip,
} from "rxjs";
import { Opaque } from "type-fest";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
@ -20,6 +19,11 @@ import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import {
PasswordHealthReportApplicationsRequest,
PasswordHealthReportApplicationsResponse,
} from "../models/password-health";
import { CriticalAppsApiService } from "./critical-apps-api.service";
/* Retrieves and decrypts critical apps for a given organization
@ -94,6 +98,25 @@ export class CriticalAppsService {
this.orgId.next(orgId);
}
// Drop a critical app for a given organization
// Only one app may be dropped at a time
async dropCriticalApp(orgId: OrganizationId, selectedUrl: string) {
const app = this.criticalAppsList.value.find(
(f) => f.organizationId === orgId && f.uri === selectedUrl,
);
if (!app) {
return;
}
await this.criticalAppsApiService.dropCriticalApp({
organizationId: app.organizationId,
passwordHealthReportApplicationIds: [app.id],
});
this.criticalAppsList.next(this.criticalAppsList.value.filter((f) => f.uri !== selectedUrl));
}
private retrieveCriticalApps(
orgId: OrganizationId | null,
): Observable<PasswordHealthReportApplicationsResponse[]> {
@ -144,16 +167,3 @@ export class CriticalAppsService {
return await Promise.all(criticalAppsPromises);
}
}
export interface PasswordHealthReportApplicationsRequest {
organizationId: OrganizationId;
url: string;
}
export interface PasswordHealthReportApplicationsResponse {
id: PasswordHealthReportApplicationId;
organizationId: OrganizationId;
uri: string;
}
export type PasswordHealthReportApplicationId = Opaque<string, "PasswordHealthReportApplicationId">;

View File

@ -95,6 +95,21 @@
<td bitCell data-testid="total-membership">
{{ r.memberCount }}
</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #rowMenu>
<button type="button" bitMenuItem (click)="unmarkAsCriticalApp(r.applicationName)">
<i aria-hidden="true" class="bwi bwi-star-f"></i> {{ "unmarkAsCriticalApp" | i18n }}
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>

View File

@ -15,12 +15,15 @@ import {
ApplicationHealthReportDetailWithCriticalFlag,
ApplicationHealthReportSummary,
} from "@bitwarden/bit-common/tools/reports/risk-insights/models/password-health";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
DialogService,
Icons,
NoItemsModule,
SearchModule,
TableDataSource,
ToastService,
} from "@bitwarden/components";
import { CardComponent } from "@bitwarden/tools-card";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
@ -37,6 +40,7 @@ import { RiskInsightsTabType } from "./risk-insights.component";
selector: "tools-critical-applications",
templateUrl: "./critical-applications.component.html",
imports: [CardComponent, HeaderModule, SearchModule, NoItemsModule, PipesModule, SharedModule],
providers: [],
})
export class CriticalApplicationsComponent implements OnInit {
protected dataSource = new TableDataSource<ApplicationHealthReportDetailWithCriticalFlag>();
@ -80,13 +84,38 @@ export class CriticalApplicationsComponent implements OnInit {
});
};
unmarkAsCriticalApp = async (hostname: string) => {
try {
await this.criticalAppsService.dropCriticalApp(
this.organizationId as OrganizationId,
hostname,
);
} catch {
this.toastService.showToast({
message: this.i18nService.t("unexpectedError"),
variant: "error",
title: this.i18nService.t("error"),
});
return;
}
this.toastService.showToast({
message: this.i18nService.t("criticalApplicationSuccessfullyUnmarked"),
variant: "success",
title: this.i18nService.t("success"),
});
this.dataSource.data = this.dataSource.data.filter((app) => app.applicationName !== hostname);
};
constructor(
protected activatedRoute: ActivatedRoute,
protected router: Router,
protected toastService: ToastService,
protected dataService: RiskInsightsDataService,
protected criticalAppsService: CriticalAppsService,
protected reportService: RiskInsightsReportService,
protected dialogService: DialogService,
protected i18nService: I18nService,
) {
this.searchControl.valueChanges
.pipe(debounceTime(200), takeUntilDestroyed())

View File

@ -2,16 +2,18 @@ import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { Observable, EMPTY } from "rxjs";
import { EMPTY, Observable } from "rxjs";
import { map, switchMap } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
RiskInsightsDataService,
CriticalAppsService,
PasswordHealthReportApplicationsResponse,
RiskInsightsDataService,
} from "@bitwarden/bit-common/tools/reports/risk-insights";
import { ApplicationHealthReportDetail } from "@bitwarden/bit-common/tools/reports/risk-insights/models/password-health";
import {
ApplicationHealthReportDetail,
PasswordHealthReportApplicationsResponse,
} from "@bitwarden/bit-common/tools/reports/risk-insights/models/password-health";
// eslint-disable-next-line no-restricted-imports -- used for dependency injection
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";

View File

@ -93,17 +93,16 @@ export class MemberAccessReportComponent implements OnInit {
});
};
edit = async (user: MemberAccessReportView | null): Promise<void> => {
edit = async (user: MemberAccessReportView): Promise<void> => {
const dialog = openUserAddEditDialog(this.dialogService, {
data: {
kind: "Edit",
name: this.userNamePipe.transform(user),
organizationId: this.organizationId,
organizationUserId: user != null ? user.userGuid : null,
allOrganizationUserEmails: this.dataSource.data?.map((user) => user.email) ?? [],
usesKeyConnector: user?.usesKeyConnector,
organizationUserId: user.userGuid,
usesKeyConnector: user.usesKeyConnector,
isOnSecretsManagerStandalone: this.orgIsOnSecretsManagerStandalone,
initialTab: MemberDialogTab.Role,
numSeatsUsed: this.dataSource.data.length,
},
});

View File

@ -10,7 +10,7 @@ import { ButtonModule } from "@bitwarden/components";
* It provides a button to navigate to the login page.
*/
@Component({
selector: "app-two-factor-expired",
selector: "app-authentication-timeout",
standalone: true,
imports: [CommonModule, JslibModule, ButtonModule, RouterModule],
template: `
@ -22,4 +22,4 @@ import { ButtonModule } from "@bitwarden/components";
</a>
`,
})
export class TwoFactorTimeoutComponent {}
export class AuthenticationTimeoutComponent {}

View File

@ -86,12 +86,12 @@ describe("TwoFactorComponent", () => {
};
let selectedUserDecryptionOptions: BehaviorSubject<UserDecryptionOptions>;
let twoFactorTimeoutSubject: BehaviorSubject<boolean>;
let authenticationSessionTimeoutSubject: BehaviorSubject<boolean>;
beforeEach(() => {
twoFactorTimeoutSubject = new BehaviorSubject<boolean>(false);
authenticationSessionTimeoutSubject = new BehaviorSubject<boolean>(false);
mockLoginStrategyService = mock<LoginStrategyServiceAbstraction>();
mockLoginStrategyService.twoFactorTimeout$ = twoFactorTimeoutSubject;
mockLoginStrategyService.authenticationSessionTimeout$ = authenticationSessionTimeoutSubject;
mockRouter = mock<Router>();
mockI18nService = mock<I18nService>();
mockApiService = mock<ApiService>();
@ -153,7 +153,9 @@ describe("TwoFactorComponent", () => {
}),
};
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(null);
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(
mockUserDecryptionOpts.withMasterPassword,
);
mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions;
TestBed.configureTestingModule({
@ -497,8 +499,8 @@ describe("TwoFactorComponent", () => {
});
it("navigates to the timeout route when timeout expires", async () => {
twoFactorTimeoutSubject.next(true);
authenticationSessionTimeoutSubject.next(true);
expect(mockRouter.navigate).toHaveBeenCalledWith(["2fa-timeout"]);
expect(mockRouter.navigate).toHaveBeenCalledWith(["authentication-timeout"]);
});
});

View File

@ -71,7 +71,7 @@ export class TwoFactorComponentV1 extends CaptchaProtectedComponent implements O
protected changePasswordRoute = "set-password";
protected forcePasswordResetRoute = "update-temp-password";
protected successRoute = "vault";
protected twoFactorTimeoutRoute = "2fa-timeout";
protected twoFactorTimeoutRoute = "authentication-timeout";
get isDuoProvider(): boolean {
return (
@ -104,8 +104,8 @@ export class TwoFactorComponentV1 extends CaptchaProtectedComponent implements O
super(environmentService, i18nService, platformUtilsService, toastService);
this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win);
// Add subscription to twoFactorTimeout$ and navigate to twoFactorTimeoutRoute if expired
this.loginStrategyService.twoFactorTimeout$
// Add subscription to authenticationSessionTimeout$ and navigate to twoFactorTimeoutRoute if expired
this.loginStrategyService.authenticationSessionTimeout$
.pipe(takeUntilDestroyed())
.subscribe(async (expired) => {
if (!expired) {

View File

@ -0,0 +1,71 @@
import { Component } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { activeAuthGuard } from "./active-auth.guard";
@Component({ template: "" })
class EmptyComponent {}
describe("activeAuthGuard", () => {
const setup = (authType: AuthenticationType | null) => {
const loginStrategyService: MockProxy<LoginStrategyServiceAbstraction> =
mock<LoginStrategyServiceAbstraction>();
const currentAuthTypeSubject = new BehaviorSubject<AuthenticationType | null>(authType);
loginStrategyService.currentAuthType$ = currentAuthTypeSubject;
const logService: MockProxy<LogService> = mock<LogService>();
const testBed = TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes([
{ path: "", component: EmptyComponent },
{
path: "protected-route",
component: EmptyComponent,
canActivate: [activeAuthGuard()],
},
{ path: "login", component: EmptyComponent },
]),
],
providers: [
{ provide: LoginStrategyServiceAbstraction, useValue: loginStrategyService },
{ provide: LogService, useValue: logService },
],
declarations: [EmptyComponent],
});
return {
router: testBed.inject(Router),
logService,
loginStrategyService,
};
};
it("creates the guard", () => {
const { router } = setup(AuthenticationType.Password);
expect(router).toBeTruthy();
});
it("allows access with an active login session", async () => {
const { router } = setup(AuthenticationType.Password);
await router.navigate(["protected-route"]);
expect(router.url).toBe("/protected-route");
});
it("redirects to login with no active session", async () => {
const { router, logService } = setup(null);
await router.navigate(["protected-route"]);
expect(router.url).toBe("/login");
expect(logService.error).toHaveBeenCalledWith("No active login session found.");
});
});

View File

@ -0,0 +1,28 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
/**
* Guard that ensures there is an active login session before allowing access
* to the new device verification route.
* If not, redirects to login.
*/
export function activeAuthGuard(): CanActivateFn {
return async () => {
const loginStrategyService = inject(LoginStrategyServiceAbstraction);
const logService = inject(LogService);
const router = inject(Router);
// Check if we have a valid login session
const authType = await firstValueFrom(loginStrategyService.currentAuthType$);
if (authType === null) {
logService.error("No active login session found.");
return router.createUrlTree(["/login"]);
}
return true;
};
}

View File

@ -1,4 +1,5 @@
export * from "./auth.guard";
export * from "./active-auth.guard";
export * from "./lock.guard";
export * from "./redirect.guard";
export * from "./tde-decryption-required.guard";

View File

@ -45,6 +45,8 @@ import {
DefaultAuthRequestApiService,
DefaultLoginSuccessHandlerService,
LoginSuccessHandlerService,
PasswordLoginStrategy,
PasswordLoginStrategyData,
LoginApprovalComponentServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
@ -1457,6 +1459,37 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultLoginSuccessHandlerService,
deps: [SyncService, UserAsymmetricKeysRegenerationService],
}),
safeProvider({
provide: PasswordLoginStrategy,
useClass: PasswordLoginStrategy,
deps: [
PasswordLoginStrategyData,
PasswordStrengthServiceAbstraction,
PolicyServiceAbstraction,
LoginStrategyServiceAbstraction,
AccountServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
KeyService,
EncryptService,
ApiServiceAbstraction,
TokenServiceAbstraction,
AppIdServiceAbstraction,
PlatformUtilsServiceAbstraction,
MessagingServiceAbstraction,
LogService,
StateServiceAbstraction,
TwoFactorServiceAbstraction,
InternalUserDecryptionOptionsServiceAbstraction,
BillingAccountProfileStateService,
VaultTimeoutSettingsServiceAbstraction,
KdfConfigService,
],
}),
safeProvider({
provide: PasswordLoginStrategyData,
useClass: PasswordLoginStrategyData,
deps: [],
}),
];
@NgModule({

View File

@ -0,0 +1,18 @@
import { svgIcon } from "@bitwarden/components";
export const DeviceVerificationIcon = svgIcon`
<svg viewBox="0 0 98 95" fill="none" xmlns="http://www.w3.org/2000/svg">
<path class="tw-stroke-art-primary" d="M12.1759 27.7453L2.54349 34.9329C1.57215 35.6577 1 36.7986 1 38.0105V89.6281C1 91.7489 2.71922 93.4681 4.84 93.4681H93.16C95.2808 93.4681 97 91.7489 97 89.6281V38.0276C97 36.806 96.4188 35.6574 95.4347 34.9338L85.6576 27.7453M61.8791 10.2622L50.9367 2.2168C49.5753 1.21588 47.7197 1.22245 46.3655 2.23297L35.6054 10.2622" stroke-width="1.92"/>
<path class="tw-stroke-art-primary" d="M85.7661 45.4682V12.1542C85.7661 11.0938 84.9064 10.2342 83.8461 10.2342H14.1541C13.0937 10.2342 12.2341 11.0938 12.2341 12.1542V45.4682" stroke-width="1.92" stroke-linecap="round"/>
<path class="tw-stroke-art-primary" d="M95.7335 92.1003L62.3151 61.2912C61.2514 60.3106 59.8576 59.7661 58.4109 59.7661H38.043C36.5571 59.7661 35.1286 60.3404 34.0562 61.3689L2.01148 92.1003" stroke-width="1.92"/>
<line class="tw-stroke-art-primary" x1="96.157" y1="39.125" x2="61.0395" y2="60.0979" stroke-width="1.92" stroke-linecap="round"/>
<path class="tw-stroke-art-primary" d="M1.84229 39.1248L36.673 59.7488" stroke-width="1.92" stroke-linecap="round"/>
<rect class="tw-stroke-art-accent" x="23.0046" y="25.5344" width="51.925" height="17.4487" rx="8.72434" stroke-width="0.96"/>
<circle class="tw-fill-art-accent" cx="30.2299" cy="34.2588" r="2.24846"/>
<circle class="tw-fill-art-accent" cx="45.2196" cy="34.2587" r="2.24846"/>
<circle class="tw-fill-art-accent" cx="60.2094" cy="34.2587" r="2.24846"/>
<circle class="tw-fill-art-accent" cx="37.7248" cy="34.2587" r="2.24846"/>
<circle class="tw-fill-art-accent" cx="52.7145" cy="34.2587" r="2.24846"/>
<circle class="tw-fill-art-accent" cx="67.704" cy="34.2587" r="2.24846"/>
</svg>
`;

View File

@ -12,3 +12,4 @@ export * from "./registration-lock-alt.icon";
export * from "./registration-expired-link.icon";
export * from "./sso-key.icon";
export * from "./two-factor-timeout.icon";
export * from "./device-verification.icon";

View File

@ -74,3 +74,6 @@ export * from "./login-approval/default-login-approval-component.service";
// two factor auth
export * from "./two-factor-auth";
// device verification
export * from "./new-device-verification/new-device-verification.component";

View File

@ -275,6 +275,12 @@ export class LoginComponent implements OnInit, OnDestroy {
return;
}
// Redirect to device verification if this is an unknown device
if (authResult.requiresDeviceVerification) {
await this.router.navigate(["device-verification"]);
return;
}
await this.loginSuccessHandlerService.run(authResult.userId);
if (authResult.forcePasswordReset != ForceSetPasswordReason.None) {

View File

@ -0,0 +1,36 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-form-field class="!tw-mb-1">
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
<input
bitInput
type="text"
id="verificationCode"
name="verificationCode"
formControlName="code"
appInputVerbatim
/>
</bit-form-field>
<button
bitLink
type="button"
linkType="primary"
(click)="resendOTP()"
[disabled]="disableRequestOTP"
class="tw-text-sm"
>
{{ "resendCode" | i18n }}
</button>
<div class="tw-flex tw-mt-4">
<button
bitButton
buttonType="primary"
type="submit"
[block]="true"
[disabled]="formGroup.invalid"
>
{{ "continueLoggingIn" | i18n }}
</button>
</div>
</form>

View File

@ -0,0 +1,163 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import {
AsyncActionsModule,
ButtonModule,
FormFieldModule,
IconButtonModule,
LinkModule,
ToastService,
} from "@bitwarden/components";
import { LoginEmailServiceAbstraction } from "../../common/abstractions/login-email.service";
import { LoginStrategyServiceAbstraction } from "../../common/abstractions/login-strategy.service";
import { PasswordLoginStrategy } from "../../common/login-strategies/password-login.strategy";
/**
* Component for verifying a new device via a one-time password (OTP).
*/
@Component({
standalone: true,
selector: "app-new-device-verification",
templateUrl: "./new-device-verification.component.html",
imports: [
CommonModule,
ReactiveFormsModule,
AsyncActionsModule,
JslibModule,
ButtonModule,
FormFieldModule,
IconButtonModule,
LinkModule,
],
})
export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
formGroup = this.formBuilder.group({
code: [
"",
{
validators: [Validators.required],
updateOn: "change",
},
],
});
protected disableRequestOTP = false;
private destroy$ = new Subject<void>();
protected authenticationSessionTimeoutRoute = "/authentication-timeout";
constructor(
private router: Router,
private formBuilder: FormBuilder,
private passwordLoginStrategy: PasswordLoginStrategy,
private apiService: ApiService,
private loginStrategyService: LoginStrategyServiceAbstraction,
private logService: LogService,
private toastService: ToastService,
private i18nService: I18nService,
private syncService: SyncService,
private loginEmailService: LoginEmailServiceAbstraction,
) {}
async ngOnInit() {
// Redirect to timeout route if session expires
this.loginStrategyService.authenticationSessionTimeout$
.pipe(takeUntil(this.destroy$))
.subscribe((expired) => {
if (!expired) {
return;
}
try {
void this.router.navigate([this.authenticationSessionTimeoutRoute]);
} catch (err) {
this.logService.error(
`Failed to navigate to ${this.authenticationSessionTimeoutRoute} route`,
err,
);
}
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Resends the OTP for device verification.
*/
async resendOTP() {
this.disableRequestOTP = true;
try {
const email = await this.loginStrategyService.getEmail();
const masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash();
if (!email || !masterPasswordHash) {
throw new Error("Missing email or master password hash");
}
await this.apiService.send(
"POST",
"/accounts/resend-new-device-otp",
{
email: email,
masterPasswordHash: masterPasswordHash,
},
false,
false,
);
} catch (e) {
this.logService.error(e);
} finally {
this.disableRequestOTP = false;
}
}
/**
* Submits the OTP for device verification.
*/
submit = async (): Promise<void> => {
const codeControl = this.formGroup.get("code");
if (!codeControl || !codeControl.value) {
return;
}
try {
const authResult = await this.loginStrategyService.logInNewDeviceVerification(
codeControl.value,
);
if (authResult.requiresTwoFactor) {
await this.router.navigate(["/2fa"]);
return;
}
if (authResult.forcePasswordReset) {
await this.router.navigate(["/update-temp-password"]);
return;
}
this.loginEmailService.clearValues();
await this.syncService.fullSync(true);
// If verification succeeds, navigate to vault
await this.router.navigate(["/vault"]);
} catch (e) {
this.logService.error(e);
const errorMessage =
(e as any)?.response?.error_description ?? this.i18nService.t("errorOccurred");
codeControl.setErrors({ serverError: { message: errorMessage } });
}
};
}

View File

@ -47,7 +47,6 @@ export abstract class LoginStrategyServiceAbstraction {
* Auth Request. Otherwise, it will return null.
*/
getAuthRequestId: () => Promise<string | null>;
/**
* Sends a token request to the server using the provided credentials.
*/
@ -75,7 +74,11 @@ export abstract class LoginStrategyServiceAbstraction {
*/
makePreloginKey: (masterPassword: string, email: string) => Promise<MasterKey>;
/**
* Emits true if the two factor session has expired.
* Emits true if the authentication session has expired.
*/
twoFactorTimeout$: Observable<boolean>;
authenticationSessionTimeout$: Observable<boolean>;
/**
* Sends a token request to the server with the provided device verification OTP.
*/
logInNewDeviceVerification: (deviceVerificationOtp: string) => Promise<AuthResult>;
}

View File

@ -6,3 +6,4 @@ export * from "./models";
export * from "./types";
export * from "./services";
export * from "./utilities";
export * from "./login-strategies";

Some files were not shown because too many files have changed in this diff Show More