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:
commit
37ac40098c
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@ -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
|
||||
|
4
.github/workflows/publish-cli.yml
vendored
4
.github/workflows/publish-cli.yml
vendored
@ -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 }}
|
||||
|
8
.github/workflows/publish-desktop.yml
vendored
8
.github/workflows/publish-desktop.yml
vendored
@ -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 }}
|
||||
|
2
.github/workflows/release-browser.yml
vendored
2
.github/workflows/release-browser.yml
vendored
@ -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
|
||||
|
2
.github/workflows/release-cli.yml
vendored
2
.github/workflows/release-cli.yml
vendored
@ -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
|
||||
|
2
.github/workflows/release-desktop-beta.yml
vendored
2
.github/workflows/release-desktop-beta.yml
vendored
@ -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
|
||||
|
2
.github/workflows/release-desktop.yml
vendored
2
.github/workflows/release-desktop.yml
vendored
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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 });
|
||||
|
||||
|
@ -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":
|
||||
|
@ -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`);
|
||||
}
|
||||
}
|
@ -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);
|
||||
};
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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'"
|
||||
|
@ -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] : []),
|
||||
|
9
apps/cli/src/platform/services/cli-sdk-load.service.ts
Normal file
9
apps/cli/src/platform/services/cli-sdk-load.service.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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) {
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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"
|
||||
},
|
||||
|
4
apps/desktop/src/package-lock.json
generated
4
apps/desktop/src/package-lock.json
generated
@ -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"
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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"
|
||||
|
@ -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",
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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) } };
|
||||
};
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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,
|
||||
|
@ -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";
|
||||
|
@ -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 {
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -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({
|
||||
|
@ -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$);
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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()],
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
31
apps/web/src/app/platform/web-sdk-load.service.ts
Normal file
31
apps/web/src/app/platform/web-sdk-load.service.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
);
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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,
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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?"
|
||||
},
|
||||
|
@ -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">;
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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>);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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">;
|
||||
|
@ -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>
|
||||
|
@ -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())
|
||||
|
@ -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";
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -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 {}
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
@ -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) {
|
||||
|
71
libs/angular/src/auth/guards/active-auth.guard.spec.ts
Normal file
71
libs/angular/src/auth/guards/active-auth.guard.spec.ts
Normal 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.");
|
||||
});
|
||||
});
|
28
libs/angular/src/auth/guards/active-auth.guard.ts
Normal file
28
libs/angular/src/auth/guards/active-auth.guard.ts
Normal 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;
|
||||
};
|
||||
}
|
@ -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";
|
||||
|
@ -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({
|
||||
|
18
libs/auth/src/angular/icons/device-verification.icon.ts
Normal file
18
libs/auth/src/angular/icons/device-verification.icon.ts
Normal 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>
|
||||
`;
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
@ -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 } });
|
||||
}
|
||||
};
|
||||
}
|
@ -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>;
|
||||
}
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user