diff --git a/.github/renovate.json5 b/.github/renovate.json5 index ee97f16b0a9..91b4ac86328 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -149,6 +149,8 @@ { matchPackageNames: [ "@angular-eslint/schematics", + "@typescript-eslint/rule-tester", + "@typescript-eslint/utils", "angular-eslint", "eslint-config-prettier", "eslint-import-resolver-typescript", @@ -313,8 +315,6 @@ "@storybook/angular", "@storybook/manager-api", "@storybook/theming", - "@typescript-eslint/utils", - "@typescript-eslint/rule-tester", "@types/react", "autoprefixer", "bootstrap", diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 4ca6dc25aab..630e1e55682 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -196,7 +196,7 @@ jobs: } - name: Set up QEMU emulators - uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0 + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 diff --git a/LICENSE.txt b/LICENSE.txt index 55bf3b3f736..8ad59f788b3 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -5,13 +5,13 @@ specifies another license. Bitwarden Licensed code is found only in the /bitwarden_license directory. GPL v3.0: -https://github.com/bitwarden/web/blob/master/LICENSE_GPL.txt +https://github.com/bitwarden/clients/blob/main/LICENSE_GPL.txt Bitwarden License v1.0: -https://github.com/bitwarden/web/blob/master/LICENSE_BITWARDEN.txt +https://github.com/bitwarden/clients/blob/main/LICENSE_BITWARDEN.txt No grant of any rights in the trademarks, service marks, or logos of Bitwarden is made (except as may be necessary to comply with the notice requirements as applicable), and use of any Bitwarden trademarks must comply with Bitwarden Trademark Guidelines -. +. diff --git a/LICENSE_BITWARDEN.txt b/LICENSE_BITWARDEN.txt index 08e09f28639..938946a09a1 100644 --- a/LICENSE_BITWARDEN.txt +++ b/LICENSE_BITWARDEN.txt @@ -56,7 +56,7 @@ such Open Source Software only. logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 2.3), and use of any Bitwarden trademarks must comply with Bitwarden Trademark Guidelines -. +. 3. TERMINATION diff --git a/apps/browser/README.md b/apps/browser/README.md index c99d0844a09..fdeb1307567 100644 --- a/apps/browser/README.md +++ b/apps/browser/README.md @@ -1,4 +1,4 @@ -[![Github Workflow build browser on master](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml/badge.svg?branch=master)](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml?query=branch:master) +[![Github Workflow build browser on main](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml/badge.svg?branch=main)](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml?query=branch:main) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/bitwarden-browser/localized.svg)](https://crowdin.com/project/bitwarden-browser) [![Join the chat at https://gitter.im/bitwarden/Lobby](https://badges.gitter.im/bitwarden/Lobby.svg)](https://gitter.im/bitwarden/Lobby) @@ -15,7 +15,7 @@ The Bitwarden browser extension is written using the Web Extension API and Angular. -![My Vault](https://raw.githubusercontent.com/bitwarden/brand/master/screenshots/browser-chrome.png) +![My Vault](https://raw.githubusercontent.com/bitwarden/brand/main/screenshots/web-browser-extension-generator.png) ## Documentation diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 5c9e829e82f..6e1e2ef57ac 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5248,5 +5248,35 @@ }, "hasItemsVaultNudgeBody": { "message": "Autofill items for the current page\nFavorite items for easy access\nSearch your vault for something else" + }, + "newLoginNudgeTitle": { + "message": "Save time with autofill" + }, + "newLoginNudgeBody": { + "message": "Include a Website so this login appears as an autofill suggestion." + }, + "newCardNudgeTitle": { + "message": "Seamless online checkout" + }, + "newCardNudgeBody": { + "message": "With cards, easily autofill payment forms securely and accurately." + }, + "newIdentityNudgeTitle": { + "message": "Simplify creating accounts" + }, + "newIdentityNudgeBody": { + "message": "With identities, quickly autofill long registration or contact forms." + }, + "newNoteNudgeTitle": { + "message": "Keep your sensitive data safe" + }, + "newNoteNudgeBody": { + "message": "With notes, securely store sensitive data like banking or insurance details." + }, + "newSshNudgeTitle": { + "message": "Developer-friendly SSH access" + }, + "newSshNudgeBody": { + "message": "Store your keys and connect with the SSH agent for fast, encrypted authentication." } -} \ No newline at end of file +} diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 4e2e773a0c7..ab5dd4abb8f 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -661,20 +661,23 @@ export class OverlayBackground implements OverlayBackgroundInterface { return this.inlineMenuFido2Credentials.has(credentialId); } + /** + * When focused field data contains account creation field type of totp + * and there are totp fields in the current frame for page details return true + * + * @returns boolean + */ private isTotpFieldForCurrentField(): boolean { if (!this.focusedFieldData) { return false; } - const { tabId, frameId } = this.focusedFieldData; - const pageDetailsMap = this.pageDetailsForTab[tabId]; - if (!pageDetailsMap || !pageDetailsMap.has(frameId)) { + const totpFields = this.getTotpFields(); + if (!totpFields) { return false; } - const pageDetail = pageDetailsMap.get(frameId); return ( - pageDetail?.details?.fields?.every((field) => - this.inlineMenuFieldQualificationService.isTotpField(field), - ) || false + totpFields.length > 0 && + this.focusedFieldData?.accountCreationFieldType === InlineMenuAccountCreationFieldType.Totp ); } @@ -1399,7 +1402,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { const pageDetailsMap = this.pageDetailsForTab[currentTabId]; const pageDetails = pageDetailsMap?.get(currentFrameId); - const fields = pageDetails.details.fields; + const fields = pageDetails?.details?.fields || []; const totpFields = fields.filter((f) => this.inlineMenuFieldQualificationService.isTotpField(f), ); @@ -1679,7 +1682,12 @@ export class OverlayBackground implements OverlayBackgroundInterface { !this.focusedFieldMatchesFillType( focusedFieldData?.inlineMenuFillType, previousFocusedFieldData, - ) + ) || + // a TOTP field was just focused to - or unfocused from — a non-TOTP field + // may want to generalize this logic if cipher inline menu types exceed [general cipher, TOTP] + [focusedFieldData, previousFocusedFieldData].filter( + (fd) => fd?.accountCreationFieldType === InlineMenuAccountCreationFieldType.Totp, + ).length === 1 ) { const updateAllCipherTypes = !this.focusedFieldMatchesFillType( CipherType.Login, diff --git a/apps/browser/src/autofill/enums/autofill-overlay.enum.ts b/apps/browser/src/autofill/enums/autofill-overlay.enum.ts index 66ad0da546d..9cc457f3c1a 100644 --- a/apps/browser/src/autofill/enums/autofill-overlay.enum.ts +++ b/apps/browser/src/autofill/enums/autofill-overlay.enum.ts @@ -32,6 +32,7 @@ export const InlineMenuAccountCreationFieldType = { Text: "text", Email: "email", Password: "password", + Totp: "totp", } as const; export type InlineMenuAccountCreationFieldTypes = diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 55260cc1149..dc8f45d104b 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -1128,6 +1128,11 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ * @param autofillFieldData - Autofill field data captured from the form field element. */ private qualifyAccountCreationFieldType(autofillFieldData: AutofillField) { + if (this.inlineMenuFieldQualificationService.isTotpField(autofillFieldData)) { + autofillFieldData.accountCreationFieldType = InlineMenuAccountCreationFieldType.Totp; + return; + } + if (!this.inlineMenuFieldQualificationService.isUsernameField(autofillFieldData)) { autofillFieldData.accountCreationFieldType = InlineMenuAccountCreationFieldType.Password; return; diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 0423078fd1c..da46ceb0864 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -931,28 +931,37 @@ export default class AutofillService implements AutofillServiceInterface { } if (!passwordFields.length) { - // No password fields on this page. Let's try to just fuzzy fill the username. - pageDetails.fields.forEach((f) => { - if ( - !options.skipUsernameOnlyFill && - f.viewable && - (f.type === "text" || f.type === "email" || f.type === "tel") && - AutofillService.fieldIsFuzzyMatch(f, AutoFillConstants.UsernameFieldNames) - ) { - usernames.push(f); + // If there are no passwords, username or TOTP fields may be present. + // username and TOTP fields are mutually exclusive + pageDetails.fields.forEach((field) => { + if (!field.viewable) { + return; } - if ( + const isFillableTotpField = options.allowTotpAutofill && - f.viewable && - (f.type === "text" || f.type === "number") && - (AutofillService.fieldIsFuzzyMatch(f, [ + ["number", "tel", "text"].some((t) => t === field.type) && + (AutofillService.fieldIsFuzzyMatch(field, [ ...AutoFillConstants.TotpFieldNames, ...AutoFillConstants.AmbiguousTotpFieldNames, ]) || - f.autoCompleteType === "one-time-code") - ) { - totps.push(f); + field.autoCompleteType === "one-time-code"); + + const isFillableUsernameField = + !options.skipUsernameOnlyFill && + ["email", "tel", "text"].some((t) => t === field.type) && + AutofillService.fieldIsFuzzyMatch(field, AutoFillConstants.UsernameFieldNames); + + // Prefer more uniquely keyworded fields first. + switch (true) { + case isFillableTotpField: + totps.push(field); + return; + case isFillableUsernameField: + usernames.push(field); + return; + default: + return; } }); } @@ -2903,52 +2912,46 @@ export default class AutofillService implements AutofillServiceInterface { /** * Accepts a field and returns true if the field contains a * value that matches any of the names in the provided list. + * + * Returns boolean and attr of value that was matched as a tuple if showMatch is set to true. + * * @param {AutofillField} field * @param {string[]} names - * @returns {boolean} + * @param {boolean} showMatch + * @returns {boolean | [boolean, { attr: string; value: string }?]} */ - static fieldIsFuzzyMatch(field: AutofillField, names: string[]): boolean { - if (AutofillService.hasValue(field.htmlID) && this.fuzzyMatch(names, field.htmlID)) { - return true; - } - if (AutofillService.hasValue(field.htmlName) && this.fuzzyMatch(names, field.htmlName)) { - return true; - } - if ( - AutofillService.hasValue(field["label-tag"]) && - this.fuzzyMatch(names, field["label-tag"]) - ) { - return true; - } - if (AutofillService.hasValue(field.placeholder) && this.fuzzyMatch(names, field.placeholder)) { - return true; - } - if ( - AutofillService.hasValue(field["label-left"]) && - this.fuzzyMatch(names, field["label-left"]) - ) { - return true; - } - if ( - AutofillService.hasValue(field["label-top"]) && - this.fuzzyMatch(names, field["label-top"]) - ) { - return true; - } - if ( - AutofillService.hasValue(field["label-aria"]) && - this.fuzzyMatch(names, field["label-aria"]) - ) { - return true; - } - if ( - AutofillService.hasValue(field.dataSetValues) && - this.fuzzyMatch(names, field.dataSetValues) - ) { - return true; - } + static fieldIsFuzzyMatch( + field: AutofillField, + names: string[], + showMatch: true, + ): [boolean, { attr: string; value: string }?]; + static fieldIsFuzzyMatch(field: AutofillField, names: string[]): boolean; + static fieldIsFuzzyMatch( + field: AutofillField, + names: string[], + showMatch: boolean = false, + ): boolean | [boolean, { attr: string; value: string }?] { + const attrs = [ + "htmlID", + "htmlName", + "label-tag", + "placeholder", + "label-left", + "label-top", + "label-aria", + "dataSetValues", + ]; - return false; + for (const attr of attrs) { + const value = field[attr]; + if (!AutofillService.hasValue(value)) { + continue; + } + if (this.fuzzyMatch(names, value)) { + return showMatch ? [true, { attr, value }] : true; + } + } + return showMatch ? [false] : false; } /** diff --git a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts index f5daff93815..34ee4fa0f77 100644 --- a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts +++ b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts @@ -8,6 +8,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service"; import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; @@ -80,7 +81,72 @@ describe("ForegroundSyncService", () => { const fullSyncPromise = sut.fullSync(true, false); expect(sut.syncInProgress).toBe(true); - const requestId = getAndAssertRequestId({ forceSync: true, allowThrowOnError: false }); + const requestId = getAndAssertRequestId({ + forceSync: true, + options: { allowThrowOnError: false, skipTokenRefresh: false }, + }); + + // Pretend the sync has finished + messages.next({ successfully: true, errorMessage: null, requestId: requestId }); + + const result = await fullSyncPromise; + + expect(sut.syncInProgress).toBe(false); + expect(result).toBe(true); + }); + + const testData: { + input: boolean | SyncOptions | undefined; + normalized: Required; + }[] = [ + { + input: undefined, + normalized: { allowThrowOnError: false, skipTokenRefresh: false }, + }, + { + input: true, + normalized: { allowThrowOnError: true, skipTokenRefresh: false }, + }, + { + input: false, + normalized: { allowThrowOnError: false, skipTokenRefresh: false }, + }, + { + input: { allowThrowOnError: false }, + normalized: { allowThrowOnError: false, skipTokenRefresh: false }, + }, + { + input: { allowThrowOnError: true }, + normalized: { allowThrowOnError: true, skipTokenRefresh: false }, + }, + { + input: { allowThrowOnError: false, skipTokenRefresh: false }, + normalized: { allowThrowOnError: false, skipTokenRefresh: false }, + }, + { + input: { allowThrowOnError: true, skipTokenRefresh: false }, + normalized: { allowThrowOnError: true, skipTokenRefresh: false }, + }, + { + input: { allowThrowOnError: true, skipTokenRefresh: true }, + normalized: { allowThrowOnError: true, skipTokenRefresh: true }, + }, + { + input: { allowThrowOnError: false, skipTokenRefresh: true }, + normalized: { allowThrowOnError: false, skipTokenRefresh: true }, + }, + ]; + + it.each(testData)("normalize input $input options correctly", async ({ input, normalized }) => { + const messages = new Subject(); + messageListener.messages$.mockReturnValue(messages); + const fullSyncPromise = sut.fullSync(true, input); + expect(sut.syncInProgress).toBe(true); + + const requestId = getAndAssertRequestId({ + forceSync: true, + options: normalized, + }); // Pretend the sync has finished messages.next({ successfully: true, errorMessage: null, requestId: requestId }); @@ -97,7 +163,10 @@ describe("ForegroundSyncService", () => { const fullSyncPromise = sut.fullSync(false, false); expect(sut.syncInProgress).toBe(true); - const requestId = getAndAssertRequestId({ forceSync: false, allowThrowOnError: false }); + const requestId = getAndAssertRequestId({ + forceSync: false, + options: { allowThrowOnError: false, skipTokenRefresh: false }, + }); // Pretend the sync has finished messages.next({ @@ -118,7 +187,10 @@ describe("ForegroundSyncService", () => { const fullSyncPromise = sut.fullSync(true, true); expect(sut.syncInProgress).toBe(true); - const requestId = getAndAssertRequestId({ forceSync: true, allowThrowOnError: true }); + const requestId = getAndAssertRequestId({ + forceSync: true, + options: { allowThrowOnError: true, skipTokenRefresh: false }, + }); // Pretend the sync has finished messages.next({ diff --git a/apps/browser/src/platform/sync/foreground-sync.service.ts b/apps/browser/src/platform/sync/foreground-sync.service.ts index a6ed7281851..ce776f53685 100644 --- a/apps/browser/src/platform/sync/foreground-sync.service.ts +++ b/apps/browser/src/platform/sync/foreground-sync.service.ts @@ -14,6 +14,7 @@ import { import { Utils } from "@bitwarden/common/platform/misc/utils"; import { StateProvider } from "@bitwarden/common/platform/state"; import { CoreSyncService } from "@bitwarden/common/platform/sync/internal"; +import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -22,7 +23,7 @@ import { InternalFolderService } from "@bitwarden/common/vault/abstractions/fold import { FULL_SYNC_FINISHED } from "./sync-service.listener"; -export type FullSyncMessage = { forceSync: boolean; allowThrowOnError: boolean; requestId: string }; +export type FullSyncMessage = { forceSync: boolean; options: SyncOptions; requestId: string }; export const DO_FULL_SYNC = new CommandDefinition("doFullSync"); @@ -60,9 +61,20 @@ export class ForegroundSyncService extends CoreSyncService { ); } - async fullSync(forceSync: boolean, allowThrowOnError: boolean = false): Promise { + async fullSync( + forceSync: boolean, + allowThrowOnErrorOrOptions?: boolean | SyncOptions, + ): Promise { this.syncInProgress = true; try { + // Normalize options + const options = + typeof allowThrowOnErrorOrOptions === "boolean" + ? { allowThrowOnError: allowThrowOnErrorOrOptions, skipTokenRefresh: false } + : { + allowThrowOnError: allowThrowOnErrorOrOptions?.allowThrowOnError ?? false, + skipTokenRefresh: allowThrowOnErrorOrOptions?.skipTokenRefresh ?? false, + }; const requestId = Utils.newGuid(); const syncCompletedPromise = firstValueFrom( this.messageListener.messages$(FULL_SYNC_FINISHED).pipe( @@ -79,10 +91,10 @@ export class ForegroundSyncService extends CoreSyncService { }), ), ); - this.messageSender.send(DO_FULL_SYNC, { forceSync, allowThrowOnError, requestId }); + this.messageSender.send(DO_FULL_SYNC, { forceSync, options, requestId }); const result = await syncCompletedPromise; - if (allowThrowOnError && result.errorMessage != null) { + if (options.allowThrowOnError && result.errorMessage != null) { throw new Error(result.errorMessage); } diff --git a/apps/browser/src/platform/sync/sync-service.listener.spec.ts b/apps/browser/src/platform/sync/sync-service.listener.spec.ts index 51f97e9f879..9682e2cdb57 100644 --- a/apps/browser/src/platform/sync/sync-service.listener.spec.ts +++ b/apps/browser/src/platform/sync/sync-service.listener.spec.ts @@ -27,11 +27,18 @@ describe("SyncServiceListener", () => { const emissionPromise = firstValueFrom(listener); syncService.fullSync.mockResolvedValueOnce(value); - messages.next({ forceSync: true, allowThrowOnError: false, requestId: "1" }); + messages.next({ + forceSync: true, + options: { allowThrowOnError: false, skipTokenRefresh: false }, + requestId: "1", + }); await emissionPromise; - expect(syncService.fullSync).toHaveBeenCalledWith(true, false); + expect(syncService.fullSync).toHaveBeenCalledWith(true, { + allowThrowOnError: false, + skipTokenRefresh: false, + }); expect(messageSender.send).toHaveBeenCalledWith(FULL_SYNC_FINISHED, { successfully: value, errorMessage: null, @@ -45,11 +52,18 @@ describe("SyncServiceListener", () => { const emissionPromise = firstValueFrom(listener); syncService.fullSync.mockRejectedValueOnce(new Error("SyncError")); - messages.next({ forceSync: true, allowThrowOnError: false, requestId: "1" }); + messages.next({ + forceSync: true, + options: { allowThrowOnError: false, skipTokenRefresh: false }, + requestId: "1", + }); await emissionPromise; - expect(syncService.fullSync).toHaveBeenCalledWith(true, false); + expect(syncService.fullSync).toHaveBeenCalledWith(true, { + allowThrowOnError: false, + skipTokenRefresh: false, + }); expect(messageSender.send).toHaveBeenCalledWith(FULL_SYNC_FINISHED, { successfully: false, errorMessage: "SyncError", diff --git a/apps/browser/src/platform/sync/sync-service.listener.ts b/apps/browser/src/platform/sync/sync-service.listener.ts index b7171528648..4274eafcf6a 100644 --- a/apps/browser/src/platform/sync/sync-service.listener.ts +++ b/apps/browser/src/platform/sync/sync-service.listener.ts @@ -9,6 +9,7 @@ import { MessageSender, isExternalMessage, } from "@bitwarden/common/platform/messaging"; +import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DO_FULL_SYNC } from "./foreground-sync.service"; @@ -34,15 +35,15 @@ export class SyncServiceListener { listener$(): Observable { return this.messageListener.messages$(DO_FULL_SYNC).pipe( filter((message) => isExternalMessage(message)), - concatMap(async ({ forceSync, allowThrowOnError, requestId }) => { - await this.doFullSync(forceSync, allowThrowOnError, requestId); + concatMap(async ({ forceSync, options, requestId }) => { + await this.doFullSync(forceSync, options, requestId); }), ); } - private async doFullSync(forceSync: boolean, allowThrowOnError: boolean, requestId: string) { + private async doFullSync(forceSync: boolean, options: SyncOptions, requestId: string) { try { - const result = await this.syncService.fullSync(forceSync, allowThrowOnError); + const result = await this.syncService.fullSync(forceSync, options); this.messageSender.send(FULL_SYNC_FINISHED, { successfully: result, errorMessage: null, diff --git a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts index fa4137d9849..ed78d9433f1 100644 --- a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts @@ -1,7 +1,7 @@ import { CommonModule } from "@angular/common"; import { Component, inject } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { map, of, switchMap } from "rxjs"; +import { map, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -20,21 +20,7 @@ export class AtRiskPasswordCalloutComponent { private activeAccount$ = inject(AccountService).activeAccount$.pipe(getUserId); protected pendingTasks$ = this.activeAccount$.pipe( - switchMap((userId) => - this.taskService.tasksEnabled$(userId).pipe( - switchMap((enabled) => { - if (!enabled) { - return of([]); - } - return this.taskService - .pendingTasks$(userId) - .pipe( - map((tasks) => - tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential), - ), - ); - }), - ), - ), + switchMap((userId) => this.taskService.pendingTasks$(userId)), + map((tasks) => tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential)), ); } diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html index 7d04f23795e..894f27245b2 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html @@ -15,12 +15,11 @@ {{ "yourVaultIsEmpty" | i18n }} -

{{ "emptyVaultDescription" | i18n }}

+

{{ "emptyVaultDescription" | i18n }}

- + + {{ "newLogin" | i18n }} +
diff --git a/apps/cli/README.md b/apps/cli/README.md index d39c0e39c8f..2b13270cdba 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,4 +1,4 @@ -[![Github Workflow build on master](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml/badge.svg?branch=master)](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml?query=branch:master) +[![Github Workflow build on main](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml/badge.svg?branch=main)](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml?query=branch:main) [![Join the chat at https://gitter.im/bitwarden/Lobby](https://badges.gitter.im/bitwarden/Lobby.svg)](https://gitter.im/bitwarden/Lobby) # Bitwarden Command-line Interface diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6578699369b..ee13f451641 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,4 +1,4 @@ -[![Github Workflow build on master](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml/badge.svg?branch=master)](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml?query=branch:master) +[![Github Workflow build on main](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml/badge.svg?branch=main)](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml?query=branch:main) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/bitwarden-desktop/localized.svg)](https://crowdin.com/project/bitwarden-desktop) [![Join the chat at https://gitter.im/bitwarden/Lobby](https://badges.gitter.im/bitwarden/Lobby.svg)](https://gitter.im/bitwarden/Lobby) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index aaca0f56b1a..f392e42ab56 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2025.4.2", + "version": "2025.5.0", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 81e3a94ff4d..2350e0df4c7 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3712,5 +3712,35 @@ }, "move": { "message": "Move" + }, + "newLoginNudgeTitle": { + "message": "Save time with autofill" + }, + "newLoginNudgeBody": { + "message": "Include a Website so this login appears as an autofill suggestion." + }, + "newCardNudgeTitle": { + "message": "Seamless online checkout" + }, + "newCardNudgeBody": { + "message": "With cards, easily autofill payment forms securely and accurately." + }, + "newIdentityNudgeTitle": { + "message": "Simplify creating accounts" + }, + "newIdentityNudgeBody": { + "message": "With identities, quickly autofill long registration or contact forms." + }, + "newNoteNudgeTitle": { + "message": "Keep your sensitive data safe" + }, + "newNoteNudgeBody": { + "message": "With notes, securely store sensitive data like banking or insurance details." + }, + "newSshNudgeTitle": { + "message": "Developer-friendly SSH access" + }, + "newSshNudgeBody": { + "message": "Store your keys and connect with the SSH agent for fast, encrypted authentication." } } diff --git a/apps/desktop/src/main/menu/menu.file.ts b/apps/desktop/src/main/menu/menu.file.ts index 712e579515e..25db5b695e7 100644 --- a/apps/desktop/src/main/menu/menu.file.ts +++ b/apps/desktop/src/main/menu/menu.file.ts @@ -103,6 +103,12 @@ export class FileMenu extends FirstMenu implements IMenubarMenu { click: () => this.sendMessage("newSecureNote"), accelerator: "CmdOrCtrl+Shift+S", }, + { + id: "typeSshKey", + label: this.localize("typeSshKey"), + click: () => this.sendMessage("newSshKey"), + accelerator: "CmdOrCtrl+Shift+K", + }, ]; } diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index f6449bd9626..b3a33dc75e3 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2025.4.2", + "version": "2025.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2025.4.2", + "version": "2025.5.0", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index 45a6f6b90af..c180ed8c744 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2025.4.2", + "version": "2025.5.0", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/desktop/src/vault/app/vault/vault-items-v2.component.html b/apps/desktop/src/vault/app/vault/vault-items-v2.component.html index ff35e00fb0f..63e648e3cf3 100644 --- a/apps/desktop/src/vault/app/vault/vault-items-v2.component.html +++ b/apps/desktop/src/vault/app/vault/vault-items-v2.component.html @@ -88,5 +88,9 @@ {{ "typeSecureNote" | i18n }} + diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.html b/apps/desktop/src/vault/app/vault/vault-v2.component.html index 12f52502984..00e225f41d1 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.html +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.html @@ -6,7 +6,6 @@ (onCipherClicked)="viewCipher($event)" (onCipherRightClicked)="viewCipherMenu($event)" (onAddCipher)="addCipher($event)" - (onAddCipherOptions)="addCipherOptions()" >
diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index 7e799899418..05c6c5e261e 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -208,6 +208,9 @@ export class VaultV2Component implements OnInit, OnDestroy { case "newSecureNote": await this.addCipher(CipherType.SecureNote).catch(() => {}); break; + case "newSshKey": + await this.addCipher(CipherType.SshKey).catch(() => {}); + break; case "focusSearch": (document.querySelector("#search") as HTMLInputElement)?.select(); detectChanges = false; @@ -531,28 +534,14 @@ export class VaultV2Component implements OnInit, OnDestroy { this.action = "add"; this.prefillCipherFromFilter(); await this.go().catch(() => {}); - } - addCipherOptions() { - const menu: RendererMenuItem[] = [ - { - label: this.i18nService.t("typeLogin"), - click: () => this.addCipherWithChangeDetection(CipherType.Login), - }, - { - label: this.i18nService.t("typeCard"), - click: () => this.addCipherWithChangeDetection(CipherType.Card), - }, - { - label: this.i18nService.t("typeIdentity"), - click: () => this.addCipherWithChangeDetection(CipherType.Identity), - }, - { - label: this.i18nService.t("typeSecureNote"), - click: () => this.addCipherWithChangeDetection(CipherType.SecureNote), - }, - ]; - invokeMenu(menu); + if (type === CipherType.SshKey) { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("sshKeyGenerated"), + }); + } } async savedCipher(cipher: CipherView) { diff --git a/apps/desktop/src/vault/app/vault/vault.component.ts b/apps/desktop/src/vault/app/vault/vault.component.ts index a21a285a428..6c0d5ef81d0 100644 --- a/apps/desktop/src/vault/app/vault/vault.component.ts +++ b/apps/desktop/src/vault/app/vault/vault.component.ts @@ -151,6 +151,9 @@ export class VaultComponent implements OnInit, OnDestroy { case "newSecureNote": await this.addCipher(CipherType.SecureNote); break; + case "newSshKey": + await this.addCipher(CipherType.SshKey); + break; case "focusSearch": (document.querySelector("#search") as HTMLInputElement).select(); detectChanges = false; @@ -470,6 +473,14 @@ export class VaultComponent implements OnInit, OnDestroy { this.cipherId = null; this.prefillNewCipherFromFilter(); this.go(); + + if (type === CipherType.SshKey) { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("sshKeyGenerated"), + }); + } } addCipherOptions() { diff --git a/apps/web/README.md b/apps/web/README.md index f43a9dc1614..c5e03eebb59 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,12 +1,12 @@

- +

The Bitwarden web project is an Angular application that powers the web vault (https://vault.bitwarden.com/).

- - Github Workflow build on master + + Github Workflow build on main Crowdin diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts index 3ffb54f5b1c..b341fc4f8e4 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts @@ -8,14 +8,16 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; -import { UserId } from "@bitwarden/common/types/guid"; +import { UserId, EmergencyAccessId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { TaskService } from "@bitwarden/common/vault/tasks"; import { DialogService, DialogRef, DIALOG_DATA } from "@bitwarden/components"; import { ChangeLoginPasswordService } from "@bitwarden/vault"; @@ -28,14 +30,15 @@ describe("EmergencyViewDialogComponent", () => { const open = jest.fn(); const close = jest.fn(); + const emergencyAccessId = "emergency-access-id" as EmergencyAccessId; const mockCipher = { id: "cipher1", name: "Cipher", type: CipherType.Login, - login: { uris: [] }, + login: { uris: [] } as Partial, card: {}, - } as CipherView; + } as Partial as CipherView; const accountService: FakeAccountService = mockAccountServiceWith(Utils.newGuid() as UserId); @@ -56,6 +59,7 @@ describe("EmergencyViewDialogComponent", () => { { provide: DIALOG_DATA, useValue: { cipher: mockCipher } }, { provide: AccountService, useValue: accountService }, { provide: TaskService, useValue: mock() }, + { provide: LogService, useValue: mock() }, ], }) .overrideComponent(EmergencyViewDialogComponent, { @@ -94,18 +98,24 @@ describe("EmergencyViewDialogComponent", () => { }); it("opens dialog", () => { - EmergencyViewDialogComponent.open({ open } as unknown as DialogService, { cipher: mockCipher }); + EmergencyViewDialogComponent.open({ open } as unknown as DialogService, { + cipher: mockCipher, + emergencyAccessId, + }); expect(open).toHaveBeenCalled(); }); it("closes the dialog", () => { - EmergencyViewDialogComponent.open({ open } as unknown as DialogService, { cipher: mockCipher }); + EmergencyViewDialogComponent.open({ open } as unknown as DialogService, { + cipher: mockCipher, + emergencyAccessId, + }); fixture.detectChanges(); const cancelButton = fixture.debugElement.queryAll(By.css("button")).pop(); - cancelButton.nativeElement.click(); + cancelButton!.nativeElement.click(); expect(close).toHaveBeenCalled(); }); diff --git a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html index 2dbcc577e54..405211d6ecb 100644 --- a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html +++ b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html @@ -34,7 +34,15 @@

- - - - - + + +

+ {{ "sponsorshipFreeBitwardenFamilies" | i18n }} +

+
+ {{ "sponsoredFamiliesIncludeMessage" | i18n }}: +
    +
  • {{ "sponsoredFamiliesPremiumAccess" | i18n }}
  • +
  • {{ "sponsoredFamiliesSharedCollectionsMessage" | i18n }}
  • +
+
- - - -
+

{{ "sponsoredBitwardenFamilies" | i18n }}

-

{{ "sponsoredFamiliesRemoveActiveSponsorship" | i18n }}

+ @if (loading()) { + + + {{ "loading" | i18n }} + + } + + @if (!loading() && sponsoredFamilies?.length > 0) { + + + + + {{ "recipient" | i18n }} + {{ "status" | i18n }} + {{ "notes" | i18n }} + + + + + @for (o of sponsoredFamilies; let i = $index; track i) { + + + {{ o.friendlyName }} + {{ o.statusMessage }} + {{ o.notes }} + + + + + +
+ + +
+ + +
+ } +
+
+
+
+ } @else if (!loading()) { +
+ Search +

{{ "noSponsoredFamiliesMessage" | i18n }}

+

{{ "nosponsoredFamiliesDetails" | i18n }}

+
+ } + + @if (!loading() && sponsoredFamilies.length > 0) { +

{{ "sponsoredFamiliesRemoveActiveSponsorship" | i18n }}

+ } +
+ diff --git a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts index af43e5a4bc1..c141eaebd78 100644 --- a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts +++ b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts @@ -1,62 +1,259 @@ import { DialogRef } from "@angular/cdk/dialog"; -import { Component, OnInit } from "@angular/core"; -import { Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; +import { formatDate } from "@angular/common"; +import { Component, OnInit, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute } from "@angular/router"; +import { firstValueFrom, map, Observable, switchMap } from "rxjs"; -import { DialogService } from "@bitwarden/components"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction"; +import { OrganizationSponsorshipInvitesResponse } from "@bitwarden/common/billing/models/response/organization-sponsorship-invites.response"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.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 { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; -import { FreeFamiliesPolicyService } from "../services/free-families-policy.service"; - -import { - AddSponsorshipDialogComponent, - AddSponsorshipDialogResult, -} from "./add-sponsorship-dialog.component"; -import { SponsoredFamily } from "./types/sponsored-family"; +import { AddSponsorshipDialogComponent } from "./add-sponsorship-dialog.component"; @Component({ selector: "app-free-bitwarden-families", templateUrl: "free-bitwarden-families.component.html", }) export class FreeBitwardenFamiliesComponent implements OnInit { + loading = signal(true); tabIndex = 0; - sponsoredFamilies: SponsoredFamily[] = []; + sponsoredFamilies: OrganizationSponsorshipInvitesResponse[] = []; + + organizationId = ""; + organizationKey$: Observable; + + private locale: string = ""; constructor( - private router: Router, + private route: ActivatedRoute, private dialogService: DialogService, - private freeFamiliesPolicyService: FreeFamiliesPolicyService, - ) {} + private apiService: ApiService, + private encryptService: EncryptService, + private keyService: KeyService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private logService: LogService, + private toastService: ToastService, + private organizationSponsorshipApiService: OrganizationSponsorshipApiServiceAbstraction, + private stateProvider: StateProvider, + ) { + this.organizationId = this.route.snapshot.params.organizationId || ""; + this.organizationKey$ = this.stateProvider.activeUserId$.pipe( + switchMap( + (userId) => + this.keyService.orgKeys$(userId as UserId) as Observable>, + ), + map((organizationKeysById) => organizationKeysById[this.organizationId as OrganizationId]), + takeUntilDestroyed(), + ); + } async ngOnInit() { - await this.preventAccessToFreeFamiliesPage(); + this.locale = await firstValueFrom(this.i18nService.locale$); + await this.loadSponsorships(); + + this.loading.set(false); + } + + async loadSponsorships() { + if (!this.organizationId) { + return; + } + + const [response, orgKey] = await Promise.all([ + this.organizationSponsorshipApiService.getOrganizationSponsorship(this.organizationId), + firstValueFrom(this.organizationKey$), + ]); + + if (!orgKey) { + this.logService.error("Organization key not found"); + return; + } + + const organizationFamilies = response.data; + + this.sponsoredFamilies = await Promise.all( + organizationFamilies.map(async (family) => { + let decryptedNote = ""; + try { + decryptedNote = await this.encryptService.decryptString( + new EncString(family.notes), + orgKey, + ); + } catch (e) { + this.logService.error(e); + } + + const { statusMessage, statusClass } = this.getStatus( + this.isSelfHosted, + family.toDelete, + family.validUntil, + family.lastSyncDate, + this.locale, + ); + + const newFamily = { + ...family, + notes: decryptedNote, + statusMessage: statusMessage || "", + statusClass: statusClass || "tw-text-success", + status: statusMessage || "", + }; + + return new OrganizationSponsorshipInvitesResponse(newFamily); + }), + ); } async addSponsorship() { - const addSponsorshipDialogRef: DialogRef = - AddSponsorshipDialogComponent.open(this.dialogService); + const addSponsorshipDialogRef: DialogRef = AddSponsorshipDialogComponent.open( + this.dialogService, + { + data: { + organizationId: this.organizationId, + organizationKey: await firstValueFrom(this.organizationKey$), + }, + }, + ); - const dialogRef = await firstValueFrom(addSponsorshipDialogRef.closed); + await firstValueFrom(addSponsorshipDialogRef.closed); - if (dialogRef?.value) { - this.sponsoredFamilies = [dialogRef.value, ...this.sponsoredFamilies]; + await this.loadSponsorships(); + } + + async removeSponsorship(sponsorship: OrganizationSponsorshipInvitesResponse) { + try { + await this.doRevokeSponsorship(sponsorship); + } catch (e) { + this.logService.error(e); } } - removeSponsorhip(sponsorship: any) { - const index = this.sponsoredFamilies.findIndex( - (e) => e.sponsorshipEmail == sponsorship.sponsorshipEmail, - ); - this.sponsoredFamilies.splice(index, 1); + get isSelfHosted(): boolean { + return this.platformUtilsService.isSelfHost(); } - private async preventAccessToFreeFamiliesPage() { - const showFreeFamiliesPage = await firstValueFrom( - this.freeFamiliesPolicyService.showFreeFamilies$, - ); + async resendEmail(sponsorship: OrganizationSponsorshipInvitesResponse) { + await this.apiService.postResendSponsorshipOffer(sponsorship.sponsoringOrganizationUserId); + this.toastService.showToast({ + variant: "success", + title: undefined, + message: this.i18nService.t("emailSent"), + }); + } - if (!showFreeFamiliesPage) { - await this.router.navigate(["/"]); + private async doRevokeSponsorship(sponsorship: OrganizationSponsorshipInvitesResponse) { + const content = sponsorship.validUntil + ? this.i18nService.t( + "updatedRevokeSponsorshipConfirmationForAcceptedSponsorship", + sponsorship.friendlyName, + formatDate(sponsorship.validUntil, "MM/dd/yyyy", this.locale), + ) + : this.i18nService.t( + "updatedRevokeSponsorshipConfirmationForSentSponsorship", + sponsorship.friendlyName, + ); + + const confirmed = await this.dialogService.openSimpleDialog({ + title: `${this.i18nService.t("removeSponsorship")}?`, + content, + acceptButtonText: { key: "remove" }, + type: "warning", + }); + + if (!confirmed) { return; } + + await this.apiService.deleteRevokeSponsorship(sponsorship.sponsoringOrganizationUserId); + + this.toastService.showToast({ + variant: "success", + title: undefined, + message: this.i18nService.t("reclaimedFreePlan"), + }); + + await this.loadSponsorships(); + } + + private getStatus( + selfHosted: boolean, + toDelete?: boolean, + validUntil?: Date, + lastSyncDate?: Date, + locale: string = "", + ): { statusMessage: string; statusClass: "tw-text-success" | "tw-text-danger" } { + /* + * Possible Statuses: + * Requested (self-hosted only) + * Sent + * Active + * RequestRevoke + * RevokeWhenExpired + */ + + if (toDelete && validUntil) { + // They want to delete but there is a valid until date which means there is an active sponsorship + return { + statusMessage: this.i18nService.t( + "revokeWhenExpired", + formatDate(validUntil, "MM/dd/yyyy", locale), + ), + statusClass: "tw-text-danger", + }; + } + + if (toDelete) { + // They want to delete and we don't have a valid until date so we can + // this should only happen on a self-hosted install + return { + statusMessage: this.i18nService.t("requestRemoved"), + statusClass: "tw-text-danger", + }; + } + + if (validUntil) { + // They don't want to delete and they have a valid until date + // that means they are actively sponsoring someone + return { + statusMessage: this.i18nService.t("active"), + statusClass: "tw-text-success", + }; + } + + if (selfHosted && lastSyncDate) { + // We are on a self-hosted install and it has been synced but we have not gotten + // a valid until date so we can't know if they are actively sponsoring someone + return { + statusMessage: this.i18nService.t("sent"), + statusClass: "tw-text-success", + }; + } + + if (!selfHosted) { + // We are in cloud and all other status checks have been false therefore we have + // sent the request but it hasn't been accepted yet + return { + statusMessage: this.i18nService.t("sent"), + statusClass: "tw-text-success", + }; + } + + // We are on a self-hosted install and we have not synced yet + return { + statusMessage: this.i18nService.t("requested"), + statusClass: "tw-text-success", + }; } } diff --git a/apps/web/src/app/billing/members/organization-member-families.component.html b/apps/web/src/app/billing/members/organization-member-families.component.html deleted file mode 100644 index c5b7283d9d9..00000000000 --- a/apps/web/src/app/billing/members/organization-member-families.component.html +++ /dev/null @@ -1,47 +0,0 @@ - - -

- {{ "membersWithSponsoredFamilies" | i18n }} -

- -

{{ "memberFamilies" | i18n }}

- - @if (loading) { - - - {{ "loading" | i18n }} - - } - - @if (!loading && memberFamilies?.length > 0) { - - - - - {{ "member" | i18n }} - {{ "status" | i18n }} - - - - - @for (o of memberFamilies; let i = $index; track i) { - - - {{ o.sponsorshipEmail }} - {{ o.status }} - - - } - - -
-
- } @else { -
- Search -

{{ "noMemberFamilies" | i18n }}

-

{{ "noMemberFamiliesDescription" | i18n }}

-
- } -
-
diff --git a/apps/web/src/app/billing/members/organization-member-families.component.ts b/apps/web/src/app/billing/members/organization-member-families.component.ts deleted file mode 100644 index 52c95646a11..00000000000 --- a/apps/web/src/app/billing/members/organization-member-families.component.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Component, Input, OnDestroy, OnInit } from "@angular/core"; -import { Subject } from "rxjs"; - -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; - -import { SponsoredFamily } from "./types/sponsored-family"; - -@Component({ - selector: "app-organization-member-families", - templateUrl: "organization-member-families.component.html", -}) -export class OrganizationMemberFamiliesComponent implements OnInit, OnDestroy { - tabIndex = 0; - loading = false; - - @Input() memberFamilies: SponsoredFamily[] = []; - - private _destroy = new Subject(); - - constructor(private platformUtilsService: PlatformUtilsService) {} - - async ngOnInit() { - this.loading = false; - } - - ngOnDestroy(): void { - this._destroy.next(); - this._destroy.complete(); - } - - get isSelfHosted(): boolean { - return this.platformUtilsService.isSelfHost(); - } -} diff --git a/apps/web/src/app/billing/members/organization-sponsored-families.component.html b/apps/web/src/app/billing/members/organization-sponsored-families.component.html deleted file mode 100644 index 7db96deb4ab..00000000000 --- a/apps/web/src/app/billing/members/organization-sponsored-families.component.html +++ /dev/null @@ -1,87 +0,0 @@ - - -

- {{ "sponsorFreeBitwardenFamilies" | i18n }} -

-
- {{ "sponsoredFamiliesInclude" | i18n }}: -
    -
  • {{ "sponsoredFamiliesPremiumAccess" | i18n }}
  • -
  • {{ "sponsoredFamiliesSharedCollections" | i18n }}
  • -
-
- -

{{ "sponsoredBitwardenFamilies" | i18n }}

- - @if (loading) { - - - {{ "loading" | i18n }} - - } - - @if (!loading && sponsoredFamilies?.length > 0) { - - - - - {{ "recipient" | i18n }} - {{ "status" | i18n }} - {{ "notes" | i18n }} - - - - - @for (o of sponsoredFamilies; let i = $index; track i) { - - - {{ o.sponsorshipEmail }} - {{ o.status }} - {{ o.sponsorshipNote }} - - - - - -
- - -
- - -
- } -
-
-
-
- } @else { -
- Search -

{{ "noSponsoredFamilies" | i18n }}

-

{{ "noSponsoredFamiliesDescription" | i18n }}

-
- } -
-
diff --git a/apps/web/src/app/billing/members/organization-sponsored-families.component.ts b/apps/web/src/app/billing/members/organization-sponsored-families.component.ts deleted file mode 100644 index 7cc46634a38..00000000000 --- a/apps/web/src/app/billing/members/organization-sponsored-families.component.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { Subject } from "rxjs"; - -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; - -import { SponsoredFamily } from "./types/sponsored-family"; - -@Component({ - selector: "app-organization-sponsored-families", - templateUrl: "organization-sponsored-families.component.html", -}) -export class OrganizationSponsoredFamiliesComponent implements OnInit, OnDestroy { - loading = false; - tabIndex = 0; - - @Input() sponsoredFamilies: SponsoredFamily[] = []; - @Output() removeSponsorshipEvent = new EventEmitter(); - - private _destroy = new Subject(); - - constructor(private platformUtilsService: PlatformUtilsService) {} - - async ngOnInit() { - this.loading = false; - } - - get isSelfHosted(): boolean { - return this.platformUtilsService.isSelfHost(); - } - - remove(sponsorship: SponsoredFamily) { - this.removeSponsorshipEvent.emit(sponsorship); - } - - ngOnDestroy(): void { - this._destroy.next(); - this._destroy.complete(); - } -} diff --git a/apps/web/src/app/billing/settings/sponsored-families.component.html b/apps/web/src/app/billing/settings/sponsored-families.component.html index 12e942aaf18..5a6957718a3 100644 --- a/apps/web/src/app/billing/settings/sponsored-families.component.html +++ b/apps/web/src/app/billing/settings/sponsored-families.component.html @@ -10,10 +10,10 @@ {{ "sponsoredFamiliesEligible" | i18n }}

- {{ "sponsoredFamiliesInclude" | i18n }}: + {{ "sponsoredFamiliesIncludeMessage" | i18n }}:
  • {{ "sponsoredFamiliesPremiumAccess" | i18n }}
  • -
  • {{ "sponsoredFamiliesSharedCollections" | i18n }}
  • +
  • {{ "sponsoredFamiliesSharedCollectionsMessage" | i18n }}
diff --git a/apps/web/src/app/billing/settings/sponsoring-org-row.component.html b/apps/web/src/app/billing/settings/sponsoring-org-row.component.html index eeeaa256049..1e5690cd85a 100644 --- a/apps/web/src/app/billing/settings/sponsoring-org-row.component.html +++ b/apps/web/src/app/billing/settings/sponsoring-org-row.component.html @@ -32,7 +32,7 @@ type="button" bitMenuItem (click)="revokeSponsorship()" - [attr.aria-label]="'revokeAccount' | i18n: sponsoringOrg.familySponsorshipFriendlyName" + [attr.aria-label]="'revokeAccountMessage' | i18n: sponsoringOrg.familySponsorshipFriendlyName" > {{ "remove" | i18n }} diff --git a/apps/web/src/app/billing/shared/payment/payment.component.html b/apps/web/src/app/billing/shared/payment/payment.component.html index c86975cd0e8..0d76d98e334 100644 --- a/apps/web/src/app/billing/shared/payment/payment.component.html +++ b/apps/web/src/app/billing/shared/payment/payment.component.html @@ -81,7 +81,7 @@ - {{ "verifyBankAccountWithStatementDescriptorWarning" | i18n }} + {{ bankAccountWarning }}
diff --git a/apps/web/src/app/billing/shared/payment/payment.component.ts b/apps/web/src/app/billing/shared/payment/payment.component.ts index c7c3e31c89f..5911e377869 100644 --- a/apps/web/src/app/billing/shared/payment/payment.component.ts +++ b/apps/web/src/app/billing/shared/payment/payment.component.ts @@ -8,6 +8,7 @@ import { takeUntil } from "rxjs/operators"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PaymentMethodType } from "@bitwarden/common/billing/enums"; import { TokenizedPaymentSourceRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-source.request"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SharedModule } from "../../../shared"; import { BillingServicesModule, BraintreeService, StripeService } from "../../services"; @@ -37,6 +38,8 @@ export class PaymentComponent implements OnInit, OnDestroy { /** If provided, will be invoked with the tokenized payment source during form submission. */ @Input() protected onSubmit?: (request: TokenizedPaymentSourceRequest) => Promise; + @Input() private bankAccountWarningOverride?: string; + @Output() submitted = new EventEmitter(); private destroy$ = new Subject(); @@ -56,6 +59,7 @@ export class PaymentComponent implements OnInit, OnDestroy { constructor( private billingApiService: BillingApiServiceAbstraction, private braintreeService: BraintreeService, + private i18nService: I18nService, private stripeService: StripeService, ) {} @@ -200,4 +204,12 @@ export class PaymentComponent implements OnInit, OnDestroy { private get usingStripe(): boolean { return this.usingBankAccount || this.usingCard; } + + get bankAccountWarning(): string { + if (this.bankAccountWarningOverride) { + return this.bankAccountWarningOverride; + } else { + return this.i18nService.t("verifyBankAccountWithStatementDescriptorWarning"); + } + } } diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index bec06888c57..90e4c6ba9c3 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -62,8 +62,6 @@ import { PipesModule } from "../vault/individual-vault/pipes/pipes.module"; import { PurgeVaultComponent } from "../vault/settings/purge-vault.component"; import { FreeBitwardenFamiliesComponent } from "./../billing/members/free-bitwarden-families.component"; -import { OrganizationMemberFamiliesComponent } from "./../billing/members/organization-member-families.component"; -import { OrganizationSponsoredFamiliesComponent } from "./../billing/members/organization-sponsored-families.component"; import { EnvironmentSelectorModule } from "./../components/environment-selector/environment-selector.module"; import { AccountFingerprintComponent } from "./components/account-fingerprint/account-fingerprint.component"; import { SharedModule } from "./shared.module"; @@ -128,8 +126,6 @@ import { SharedModule } from "./shared.module"; SelectableAvatarComponent, SetPasswordComponent, SponsoredFamiliesComponent, - OrganizationSponsoredFamiliesComponent, - OrganizationMemberFamiliesComponent, FreeBitwardenFamiliesComponent, SponsoringOrgRowComponent, UpdatePasswordComponent, @@ -176,8 +172,6 @@ import { SharedModule } from "./shared.module"; SelectableAvatarComponent, SetPasswordComponent, SponsoredFamiliesComponent, - OrganizationSponsoredFamiliesComponent, - OrganizationMemberFamiliesComponent, FreeBitwardenFamiliesComponent, SponsoringOrgRowComponent, UpdateTempPasswordComponent, diff --git a/apps/web/src/app/tools/reports/pages/cipher-report.component.ts b/apps/web/src/app/tools/reports/pages/cipher-report.component.ts index 0ad8a0a519c..ceda7b1c480 100644 --- a/apps/web/src/app/tools/reports/pages/cipher-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/cipher-report.component.ts @@ -67,7 +67,7 @@ export class CipherReportComponent implements OnDestroy { protected i18nService: I18nService, private syncService: SyncService, private cipherFormConfigService: CipherFormConfigService, - private adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService, + protected adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService, ) { this.organizations$ = this.accountService.activeAccount$.pipe( getUserId, @@ -207,7 +207,7 @@ export class CipherReportComponent implements OnDestroy { // If the dialog was closed by deleting the cipher, refresh the report. if (result === VaultItemDialogResult.Deleted || result === VaultItemDialogResult.Saved) { - await this.load(); + await this.refresh(result, cipher); } } @@ -215,6 +215,10 @@ export class CipherReportComponent implements OnDestroy { this.allCiphers = []; } + protected async refresh(result: VaultItemDialogResult, cipher: CipherView) { + await this.load(); + } + protected async repromptCipher(c: CipherView) { return ( c.reprompt === CipherRepromptType.None || diff --git a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts index f7631e37a7d..4144c9ac20f 100644 --- a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.ts @@ -1,18 +1,22 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; +import { firstValueFrom } from "rxjs"; import { OrganizationService } 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { CipherId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeVariant, DialogService } from "@bitwarden/components"; import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; +import { VaultItemDialogResult } from "@bitwarden/web-vault/app/vault/components/vault-item-dialog/vault-item-dialog.component"; import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service"; @@ -40,7 +44,7 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen i18nService: I18nService, syncService: SyncService, cipherFormConfigService: CipherFormConfigService, - adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService, + protected adminConsoleCipherFormConfigService: AdminConsoleCipherFormConfigService, ) { super( cipherService, @@ -66,62 +70,112 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen this.findWeakPasswords(allCiphers); } - protected findWeakPasswords(ciphers: CipherView[]): void { - ciphers.forEach((ciph) => { - const { type, login, isDeleted, edit, viewPassword } = ciph; - if ( - type !== CipherType.Login || - login.password == null || - login.password === "" || - isDeleted || - (!this.organization && !edit) || - !viewPassword - ) { + protected async refresh(result: VaultItemDialogResult, cipher: CipherView) { + if (result === VaultItemDialogResult.Deleted) { + // remove the cipher from the list + this.weakPasswordCiphers = this.weakPasswordCiphers.filter((c) => c.id !== cipher.id); + this.filterCiphersByOrg(this.weakPasswordCiphers); + return; + } + + if (result == VaultItemDialogResult.Saved) { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + let updatedCipher = await this.cipherService.get(cipher.id, activeUserId); + + if (this.isAdminConsoleActive) { + updatedCipher = await this.adminConsoleCipherFormConfigService.getCipher( + cipher.id as CipherId, + this.organization, + ); + } + + const updatedCipherView = await updatedCipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(updatedCipher, activeUserId), + ); + // update the cipher views + const updatedReportResult = this.determineWeakPasswordScore(updatedCipherView); + const index = this.weakPasswordCiphers.findIndex((c) => c.id === updatedCipherView.id); + + if (updatedReportResult == null) { + // the password is no longer weak + // remove the cipher from the list + this.weakPasswordCiphers.splice(index, 1); + this.filterCiphersByOrg(this.weakPasswordCiphers); return; } - const hasUserName = this.isUserNameNotEmpty(ciph); - let userInput: string[] = []; - if (hasUserName) { - const atPosition = login.username.indexOf("@"); - if (atPosition > -1) { - userInput = userInput - .concat( - login.username - .substr(0, atPosition) - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/), - ) - .filter((i) => i.length >= 3); - } else { - userInput = login.username - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/) - .filter((i) => i.length >= 3); - } + if (index > -1) { + // update the existing cipher + this.weakPasswordCiphers[index] = updatedReportResult; + this.filterCiphersByOrg(this.weakPasswordCiphers); } - const result = this.passwordStrengthService.getPasswordStrength( - login.password, - null, - userInput.length > 0 ? userInput : null, - ); + } + } - if (result.score != null && result.score <= 2) { - const scoreValue = this.scoreKey(result.score); - const row = { - ...ciph, - score: result.score, - reportValue: scoreValue, - scoreKey: scoreValue.sortOrder, - } as ReportResult; + protected findWeakPasswords(ciphers: CipherView[]): void { + ciphers.forEach((ciph) => { + const row = this.determineWeakPasswordScore(ciph); + if (row != null) { this.weakPasswordCiphers.push(row); } }); this.filterCiphersByOrg(this.weakPasswordCiphers); } + protected determineWeakPasswordScore(ciph: CipherView): ReportResult | null { + const { type, login, isDeleted, edit, viewPassword } = ciph; + if ( + type !== CipherType.Login || + login.password == null || + login.password === "" || + isDeleted || + (!this.organization && !edit) || + !viewPassword + ) { + return; + } + + const hasUserName = this.isUserNameNotEmpty(ciph); + let userInput: string[] = []; + if (hasUserName) { + const atPosition = login.username.indexOf("@"); + if (atPosition > -1) { + userInput = userInput + .concat( + login.username + .substr(0, atPosition) + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/), + ) + .filter((i) => i.length >= 3); + } else { + userInput = login.username + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/) + .filter((i) => i.length >= 3); + } + } + const result = this.passwordStrengthService.getPasswordStrength( + login.password, + null, + userInput.length > 0 ? userInput : null, + ); + + if (result.score != null && result.score <= 2) { + const scoreValue = this.scoreKey(result.score); + return { + ...ciph, + score: result.score, + reportValue: scoreValue, + scoreKey: scoreValue.sortOrder, + } as ReportResult; + } + + return null; + } + protected canManageCipher(c: CipherView): boolean { // this will only ever be false from the org view; return true; diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts index dd9cef91a54..15af27ba8d0 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts @@ -100,7 +100,7 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ }; } - private async getCipher(id: CipherId | null, organization: Organization): Promise { + async getCipher(id: CipherId | null, organization: Organization): Promise { if (id == null) { return null; } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 4650ded54bb..59ba7961d82 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6303,13 +6303,13 @@ "sponsoredBitwardenFamilies": { "message": "Sponsored families" }, - "noSponsoredFamilies": { + "noSponsoredFamiliesMessage": { "message": "No sponsored families" }, - "noSponsoredFamiliesDescription": { + "nosponsoredFamiliesDetails": { "message": "Sponsored non-member families plans will display here" }, - "sponsorFreeBitwardenFamilies": { + "sponsorshipFreeBitwardenFamilies": { "message": "Members of your organization are eligible for Free Bitwarden Families. You can sponsor Free Bitwarden Families for employees who are not a member of your Bitwarden organization. Sponsoring a non-member requires an available seat within your organization." }, "sponsoredFamiliesRemoveActiveSponsorship": { @@ -6321,14 +6321,14 @@ "sponsoredFamiliesEligibleCard": { "message": "Redeem your Free Bitwarden for Families plan today to keep your data secure even when you are not at work." }, - "sponsoredFamiliesInclude": { - "message": "The Bitwarden for Families plan include" + "sponsoredFamiliesIncludeMessage": { + "message": "The Bitwarden for Families plan includes" }, "sponsoredFamiliesPremiumAccess": { "message": "Premium access for up to 6 users" }, - "sponsoredFamiliesSharedCollections": { - "message": "Shared collections for Family secrets" + "sponsoredFamiliesSharedCollectionsMessage": { + "message": "Shared collections for family members" }, "memberFamilies": { "message": "Member families" @@ -6342,6 +6342,15 @@ "membersWithSponsoredFamilies": { "message": "Members of your organization are eligible for Free Bitwarden Families. Here you can see members who have sponsored a Families organization." }, + "organizationHasMemberMessage": { + "message": "A sponsorship cannot be sent to $EMAIL$ because they are a member of your organization.", + "placeholders": { + "email": { + "content": "$1", + "example": "mail@example.com" + } + } + }, "badToken": { "message": "The link is no longer valid. Please have the sponsor resend the offer." }, @@ -6393,7 +6402,7 @@ "redeemedAccount": { "message": "Account redeemed" }, - "revokeAccount": { + "revokeAccountMessage": { "message": "Revoke account $NAME$", "placeholders": { "name": { @@ -10620,7 +10629,40 @@ "newBusinessUnit": { "message": "New business unit" }, + "newLoginNudgeTitle": { + "message": "Save time with autofill" + }, + "newLoginNudgeBody": { + "message": "Include a Website so this login appears as an autofill suggestion." + }, + "newCardNudgeTitle": { + "message": "Seamless online checkout" + }, + "newCardNudgeBody": { + "message": "With cards, easily autofill payment forms securely and accurately." + }, + "newIdentityNudgeTitle": { + "message": "Simplify creating accounts" + }, + "newIdentityNudgeBody": { + "message": "With identities, quickly autofill long registration or contact forms." + }, + "newNoteNudgeTitle": { + "message": "Keep your sensitive data safe" + }, + "newNoteNudgeBody": { + "message": "With notes, securely store sensitive data like banking or insurance details." + }, + "newSshNudgeTitle": { + "message": "Developer-friendly SSH access" + }, + "newSshNudgeBody": { + "message": "Store your keys and connect with the SSH agent for fast, encrypted authentication." + }, "restart": { "message": "Restart" + }, + "verifyProviderBankAccountWithStatementDescriptorWarning": { + "message": "Payment with a bank account is only available to customers in the United States. You will be required to verify your bank account. We will make a micro-deposit within the next 1-2 business days. Enter the statement descriptor code from this deposit on the provider's subscription page to verify the bank account. Failure to verify the bank account will result in a missed payment and your subscription being suspended." } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/user-add-edit.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/user-add-edit.component.html deleted file mode 100644 index 78d80d005c9..00000000000 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/user-add-edit.component.html +++ /dev/null @@ -1,124 +0,0 @@ - diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/user-add-edit.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/user-add-edit.component.ts deleted file mode 100644 index 82671a7e418..00000000000 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/user-add-edit.component.ts +++ /dev/null @@ -1,125 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; -import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; -import { ProviderUserInviteRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-invite.request"; -import { ProviderUserUpdateRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-update.request"; -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 { DialogService, ToastService } from "@bitwarden/components"; - -/** - * @deprecated Please use the {@link MembersDialogComponent} instead. - */ -@Component({ - selector: "provider-user-add-edit", - templateUrl: "user-add-edit.component.html", -}) -export class UserAddEditComponent implements OnInit { - @Input() name: string; - @Input() providerUserId: string; - @Input() providerId: string; - @Output() savedUser = new EventEmitter(); - @Output() deletedUser = new EventEmitter(); - - loading = true; - editMode = false; - title: string; - emails: string; - type: ProviderUserType = ProviderUserType.ServiceUser; - permissions = new PermissionsApi(); - showCustom = false; - access: "all" | "selected" = "selected"; - formPromise: Promise; - deletePromise: Promise; - userType = ProviderUserType; - - constructor( - private apiService: ApiService, - private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, - private logService: LogService, - private dialogService: DialogService, - private toastService: ToastService, - ) {} - - async ngOnInit() { - this.editMode = this.loading = this.providerUserId != null; - - if (this.editMode) { - this.editMode = true; - this.title = this.i18nService.t("editMember"); - try { - const user = await this.apiService.getProviderUser(this.providerId, this.providerUserId); - this.type = user.type; - } catch (e) { - this.logService.error(e); - } - } else { - this.title = this.i18nService.t("inviteMember"); - } - - this.loading = false; - } - - async submit() { - try { - if (this.editMode) { - const request = new ProviderUserUpdateRequest(); - request.type = this.type; - this.formPromise = this.apiService.putProviderUser( - this.providerId, - this.providerUserId, - request, - ); - } else { - const request = new ProviderUserInviteRequest(); - request.emails = this.emails.trim().split(/\s*,\s*/); - request.type = this.type; - this.formPromise = this.apiService.postProviderUserInvite(this.providerId, request); - } - await this.formPromise; - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t(this.editMode ? "editedUserId" : "invitedUsers", this.name), - }); - this.savedUser.emit(); - } catch (e) { - this.logService.error(e); - } - } - - async delete() { - if (!this.editMode) { - return; - } - - const confirmed = await this.dialogService.openSimpleDialog({ - title: this.name, - content: { key: "removeUserConfirmation" }, - type: "warning", - }); - - if (!confirmed) { - return false; - } - - try { - this.deletePromise = this.apiService.deleteProviderUser(this.providerId, this.providerUserId); - await this.deletePromise; - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("removedUserId", this.name), - }); - this.deletedUser.emit(); - } catch (e) { - this.logService.error(e); - } - } -} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts index dd9baa99948..597acb0d4f0 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts @@ -7,6 +7,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { CardComponent, SearchModule } from "@bitwarden/components"; import { DangerZoneComponent } from "@bitwarden/web-vault/app/auth/settings/account/danger-zone.component"; import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing"; +import { PaymentComponent } from "@bitwarden/web-vault/app/billing/shared/payment/payment.component"; import { VerifyBankAccountComponent } from "@bitwarden/web-vault/app/billing/shared/verify-bank-account/verify-bank-account.component"; import { OssModule } from "@bitwarden/web-vault/app/oss.module"; @@ -29,7 +30,6 @@ import { BulkConfirmDialogComponent } from "./manage/dialogs/bulk-confirm-dialog import { BulkRemoveDialogComponent } from "./manage/dialogs/bulk-remove-dialog.component"; import { EventsComponent } from "./manage/events.component"; import { MembersComponent } from "./manage/members.component"; -import { UserAddEditComponent } from "./manage/user-add-edit.component"; import { ProvidersLayoutComponent } from "./providers-layout.component"; import { ProvidersRoutingModule } from "./providers-routing.module"; import { ProvidersComponent } from "./providers.component"; @@ -53,6 +53,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr ScrollingModule, VerifyBankAccountComponent, CardComponent, + PaymentComponent, ], declarations: [ AcceptProviderComponent, @@ -65,7 +66,6 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr MembersComponent, SetupComponent, SetupProviderComponent, - UserAddEditComponent, AddEditMemberDialogComponent, AddExistingOrganizationDialogComponent, CreateClientDialogComponent, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.html index 38b4c3bc9de..4c5a35ea58d 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.html @@ -12,23 +12,50 @@

{{ "setupProviderDesc" | i18n }}

-

{{ "generalInformation" | i18n }}

-
-
- - {{ "providerName" | i18n }} - - + @if (!(requireProviderPaymentMethodDuringSetup$ | async)) { +

{{ "generalInformation" | i18n }}

+
+
+ + {{ "providerName" | i18n }} + + +
+
+ + {{ "billingEmail" | i18n }} + + {{ "providerBillingEmailHint" | i18n }} + +
-
- - {{ "billingEmail" | i18n }} - - {{ "providerBillingEmailHint" | i18n }} - + + } @else { +

{{ "billingInformation" | i18n }}

+
+
+ + {{ "providerName" | i18n }} + + +
+
+ + {{ "billingEmail" | i18n }} + + {{ "providerBillingEmailHint" | i18n }} + +
-
- +

{{ "paymentMethod" | i18n }}

+ + + } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts index ecf649b8f31..0b6483b9f48 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts @@ -3,13 +3,14 @@ import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { Subject, switchMap } from "rxjs"; +import { firstValueFrom, Subject, switchMap } from "rxjs"; import { first, takeUntil } from "rxjs/operators"; import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; import { ProviderSetupRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-setup.request"; import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; @@ -17,12 +18,14 @@ import { ProviderKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { PaymentComponent } from "@bitwarden/web-vault/app/billing/shared/payment/payment.component"; @Component({ selector: "provider-setup", templateUrl: "setup.component.html", }) export class SetupComponent implements OnInit, OnDestroy { + @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; @ViewChild(ManageTaxInformationComponent) taxInformationComponent: ManageTaxInformationComponent; loading = true; @@ -36,6 +39,10 @@ export class SetupComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); + requireProviderPaymentMethodDuringSetup$ = this.configService.getFeatureFlag$( + FeatureFlag.PM19956_RequireProviderPaymentMethodDuringSetup, + ); + constructor( private router: Router, private i18nService: I18nService, @@ -134,6 +141,14 @@ export class SetupComponent implements OnInit, OnDestroy { request.taxInfo.city = taxInformation.city; request.taxInfo.state = taxInformation.state; + const requireProviderPaymentMethodDuringSetup = await firstValueFrom( + this.requireProviderPaymentMethodDuringSetup$, + ); + + if (requireProviderPaymentMethodDuringSetup) { + request.paymentSource = await this.paymentComponent.tokenize(); + } + const provider = await this.providerApiService.postProviderSetup(this.providerId, request); this.toastService.showToast({ diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 1cc2b591412..0d59f4a6547 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -136,11 +136,13 @@ import { import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/account/account-billing-api.service.abstraction"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; +import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction"; import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service"; +import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { TaxService } from "@bitwarden/common/billing/services/tax.service"; import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service"; @@ -1063,6 +1065,11 @@ const safeProviders: SafeProvider[] = [ // subscribes to sync notifications and will update itself based on that. deps: [ApiServiceAbstraction, SyncService], }), + safeProvider({ + provide: OrganizationSponsorshipApiServiceAbstraction, + useClass: OrganizationSponsorshipApiService, + deps: [ApiServiceAbstraction], + }), safeProvider({ provide: OrganizationBillingApiServiceAbstraction, useClass: OrganizationBillingApiService, diff --git a/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts b/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts index 19e993487c2..726bd6a85e1 100644 --- a/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts +++ b/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts @@ -6,5 +6,6 @@ export class OrganizationSponsorshipCreateRequest { sponsoredEmail: string; planSponsorshipType: PlanSponsorshipType; friendlyName: string; + isAdminInitiated?: boolean; notes?: string; } diff --git a/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts b/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts index d4992e969dc..5c9ea5526a0 100644 --- a/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts +++ b/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { ExpandedTaxInfoUpdateRequest } from "../../../../billing/models/request/expanded-tax-info-update.request"; +import { TokenizedPaymentSourceRequest } from "../../../../billing/models/request/tokenized-payment-source.request"; export class ProviderSetupRequest { name: string; @@ -9,4 +10,5 @@ export class ProviderSetupRequest { token: string; key: string; taxInfo: ExpandedTaxInfoUpdateRequest; + paymentSource?: TokenizedPaymentSourceRequest; } diff --git a/libs/common/src/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction.ts b/libs/common/src/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction.ts new file mode 100644 index 00000000000..e6e395c69df --- /dev/null +++ b/libs/common/src/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction.ts @@ -0,0 +1,8 @@ +import { ListResponse } from "../../../models/response/list.response"; +import { OrganizationSponsorshipInvitesResponse } from "../../models/response/organization-sponsorship-invites.response"; + +export abstract class OrganizationSponsorshipApiServiceAbstraction { + abstract getOrganizationSponsorship( + sponsoredOrgId: string, + ): Promise>; +} diff --git a/libs/common/src/billing/models/response/organization-sponsorship-invites.response.ts b/libs/common/src/billing/models/response/organization-sponsorship-invites.response.ts new file mode 100644 index 00000000000..87a2cae4699 --- /dev/null +++ b/libs/common/src/billing/models/response/organization-sponsorship-invites.response.ts @@ -0,0 +1,31 @@ +import { BaseResponse } from "../../../models/response/base.response"; +import { PlanSponsorshipType } from "../../enums"; + +export class OrganizationSponsorshipInvitesResponse extends BaseResponse { + sponsoringOrganizationUserId: string; + friendlyName: string; + offeredToEmail: string; + planSponsorshipType: PlanSponsorshipType; + lastSyncDate?: Date; + validUntil?: Date; + toDelete = false; + isAdminInitiated: boolean; + notes: string; + statusMessage?: string; + statusClass?: string; + + constructor(response: any) { + super(response); + this.sponsoringOrganizationUserId = this.getResponseProperty("SponsoringOrganizationUserId"); + this.friendlyName = this.getResponseProperty("FriendlyName"); + this.offeredToEmail = this.getResponseProperty("OfferedToEmail"); + this.planSponsorshipType = this.getResponseProperty("PlanSponsorshipType"); + this.lastSyncDate = this.getResponseProperty("LastSyncDate"); + this.validUntil = this.getResponseProperty("ValidUntil"); + this.toDelete = this.getResponseProperty("ToDelete") ?? false; + this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated"); + this.notes = this.getResponseProperty("Notes"); + this.statusMessage = this.getResponseProperty("StatusMessage"); + this.statusClass = this.getResponseProperty("StatusClass"); + } +} diff --git a/libs/common/src/billing/services/organization/organization-sponsorship-api.service.ts b/libs/common/src/billing/services/organization/organization-sponsorship-api.service.ts new file mode 100644 index 00000000000..bb420377439 --- /dev/null +++ b/libs/common/src/billing/services/organization/organization-sponsorship-api.service.ts @@ -0,0 +1,22 @@ +import { ApiService } from "../../../abstractions/api.service"; +import { ListResponse } from "../../../models/response/list.response"; +import { OrganizationSponsorshipApiServiceAbstraction } from "../../abstractions/organizations/organization-sponsorship-api.service.abstraction"; +import { OrganizationSponsorshipInvitesResponse } from "../../models/response/organization-sponsorship-invites.response"; + +export class OrganizationSponsorshipApiService + implements OrganizationSponsorshipApiServiceAbstraction +{ + constructor(private apiService: ApiService) {} + async getOrganizationSponsorship( + sponsoredOrgId: string, + ): Promise> { + const r = await this.apiService.send( + "GET", + "/organization/sponsorship/" + sponsoredOrgId + "/sponsored", + null, + true, + true, + ); + return new ListResponse(r, OrganizationSponsorshipInvitesResponse); + } +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 0e0956b0460..3644ceefa9a 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -34,6 +34,7 @@ export enum FeatureFlag { PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features", PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method", PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", + PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup", /* Data Insights and Reporting */ CriticalApps = "pm-14466-risk-insights-critical-application", @@ -121,6 +122,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE, [FeatureFlag.PM18794_ProviderPaymentMethod]: FALSE, [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE, + [FeatureFlag.PM19956_RequireProviderPaymentMethodDuringSetup]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 70e0c3998dd..587212299df 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -206,7 +206,7 @@ export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk"); export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk"); export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk"); export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk"); -export const VAULT_NUDGES_DISK = new StateDefinition("vaultNudges", "disk"); +export const VAULT_NUDGES_DISK = new StateDefinition("vaultNudges", "disk", { web: "disk-local" }); export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition( "vaultBrowserIntroCarousel", "disk", diff --git a/libs/common/src/platform/sync/core-sync.service.ts b/libs/common/src/platform/sync/core-sync.service.ts index 1865ffb852f..4020c75f764 100644 --- a/libs/common/src/platform/sync/core-sync.service.ts +++ b/libs/common/src/platform/sync/core-sync.service.ts @@ -28,6 +28,8 @@ import { StateService } from "../abstractions/state.service"; import { MessageSender } from "../messaging"; import { StateProvider, SYNC_DISK, UserKeyDefinition } from "../state"; +import { SyncOptions } from "./sync.service"; + const LAST_SYNC_DATE = new UserKeyDefinition(SYNC_DISK, "lastSync", { deserializer: (d) => (d != null ? new Date(d) : null), clearOn: ["logout"], @@ -55,6 +57,7 @@ export abstract class CoreSyncService implements SyncService { protected readonly stateProvider: StateProvider, ) {} + abstract fullSync(forceSync: boolean, syncOptions?: SyncOptions): Promise; abstract fullSync(forceSync: boolean, allowThrowOnError?: boolean): Promise; async getLastSync(): Promise { diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts new file mode 100644 index 00000000000..ded06c8be6b --- /dev/null +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -0,0 +1,199 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { + LogoutReason, + UserDecryptionOptions, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { KeyService } from "@bitwarden/key-management"; + +import { Matrix } from "../../../spec/matrix"; +import { ApiService } from "../../abstractions/api.service"; +import { InternalOrganizationServiceAbstraction } from "../../admin-console/abstractions/organization/organization.service.abstraction"; +import { InternalPolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; +import { ProviderService } from "../../admin-console/abstractions/provider.service"; +import { Account, AccountService } from "../../auth/abstractions/account.service"; +import { AuthService } from "../../auth/abstractions/auth.service"; +import { AvatarService } from "../../auth/abstractions/avatar.service"; +import { TokenService } from "../../auth/abstractions/token.service"; +import { AuthenticationStatus } from "../../auth/enums/authentication-status"; +import { DomainSettingsService } from "../../autofill/services/domain-settings.service"; +import { BillingAccountProfileStateService } from "../../billing/abstractions"; +import { KeyConnectorService } from "../../key-management/key-connector/abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "../../key-management/master-password/abstractions/master-password.service.abstraction"; +import { SendApiService } from "../../tools/send/services/send-api.service.abstraction"; +import { InternalSendService } from "../../tools/send/services/send.service.abstraction"; +import { UserId } from "../../types/guid"; +import { CipherService } from "../../vault/abstractions/cipher.service"; +import { FolderApiServiceAbstraction } from "../../vault/abstractions/folder/folder-api.service.abstraction"; +import { InternalFolderService } from "../../vault/abstractions/folder/folder.service.abstraction"; +import { LogService } from "../abstractions/log.service"; +import { StateService } from "../abstractions/state.service"; +import { MessageSender } from "../messaging"; +import { StateProvider } from "../state"; + +import { DefaultSyncService } from "./default-sync.service"; +import { SyncResponse } from "./sync.response"; + +describe("DefaultSyncService", () => { + let masterPasswordAbstraction: MockProxy; + let accountService: MockProxy; + let apiService: MockProxy; + let domainSettingsService: MockProxy; + let folderService: MockProxy; + let cipherService: MockProxy; + let keyService: MockProxy; + let collectionService: MockProxy; + let messageSender: MockProxy; + let policyService: MockProxy; + let sendService: MockProxy; + let logService: MockProxy; + let keyConnectorService: MockProxy; + let stateService: MockProxy; + let providerService: MockProxy; + let folderApiService: MockProxy; + let organizationService: MockProxy; + let sendApiService: MockProxy; + let userDecryptionOptionsService: MockProxy; + let avatarService: MockProxy; + let logoutCallback: jest.Mock, [logoutReason: LogoutReason, userId?: UserId]>; + let billingAccountProfileStateService: MockProxy; + let tokenService: MockProxy; + let authService: MockProxy; + let stateProvider: MockProxy; + + let sut: DefaultSyncService; + + beforeEach(() => { + masterPasswordAbstraction = mock(); + accountService = mock(); + apiService = mock(); + domainSettingsService = mock(); + folderService = mock(); + cipherService = mock(); + keyService = mock(); + collectionService = mock(); + messageSender = mock(); + policyService = mock(); + sendService = mock(); + logService = mock(); + keyConnectorService = mock(); + stateService = mock(); + providerService = mock(); + folderApiService = mock(); + organizationService = mock(); + sendApiService = mock(); + userDecryptionOptionsService = mock(); + avatarService = mock(); + logoutCallback = jest.fn(); + billingAccountProfileStateService = mock(); + tokenService = mock(); + authService = mock(); + stateProvider = mock(); + + sut = new DefaultSyncService( + masterPasswordAbstraction, + accountService, + apiService, + domainSettingsService, + folderService, + cipherService, + keyService, + collectionService, + messageSender, + policyService, + sendService, + logService, + keyConnectorService, + stateService, + providerService, + folderApiService, + organizationService, + sendApiService, + userDecryptionOptionsService, + avatarService, + logoutCallback, + billingAccountProfileStateService, + tokenService, + authService, + stateProvider, + ); + }); + + const user1 = "user1" as UserId; + + describe("fullSync", () => { + beforeEach(() => { + accountService.activeAccount$ = of({ id: user1 } as Account); + Matrix.autoMockMethod(authService.authStatusFor$, () => of(AuthenticationStatus.Unlocked)); + apiService.getSync.mockResolvedValue( + new SyncResponse({ + profile: { + id: user1, + }, + folders: [], + collections: [], + ciphers: [], + sends: [], + domains: [], + policies: [], + }), + ); + Matrix.autoMockMethod(userDecryptionOptionsService.userDecryptionOptionsById$, () => + of({ hasMasterPassword: true } satisfies UserDecryptionOptions), + ); + stateProvider.getUser.mockReturnValue(mock()); + }); + + it("does a token refresh when option missing from options", async () => { + await sut.fullSync(true, { allowThrowOnError: false }); + + expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does a token refresh when boolean passed in", async () => { + await sut.fullSync(true, false); + + expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does a token refresh when skipTokenRefresh option passed in with false and allowThrowOnError also passed in", async () => { + await sut.fullSync(true, { allowThrowOnError: false, skipTokenRefresh: false }); + + expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does a token refresh when skipTokenRefresh option passed in with false by itself", async () => { + await sut.fullSync(true, { skipTokenRefresh: false }); + + expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does not do a token refresh when skipTokenRefresh passed in as true", async () => { + await sut.fullSync(true, { skipTokenRefresh: true }); + + expect(apiService.refreshIdentityToken).not.toHaveBeenCalled(); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does not do a token refresh when skipTokenRefresh passed in as true and allowThrowOnError also passed in", async () => { + await sut.fullSync(true, { allowThrowOnError: false, skipTokenRefresh: true }); + + expect(apiService.refreshIdentityToken).not.toHaveBeenCalled(); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + + it("does a token refresh when nothing passed in", async () => { + await sut.fullSync(true); + + expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); + expect(apiService.getSync).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index a6b1b974645..faf54f11912 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -54,6 +54,7 @@ import { MessageSender } from "../messaging"; import { StateProvider } from "../state"; import { CoreSyncService } from "./core-sync.service"; +import { SyncOptions } from "./sync.service"; export class DefaultSyncService extends CoreSyncService { syncInProgress = false; @@ -102,7 +103,15 @@ export class DefaultSyncService extends CoreSyncService { ); } - override async fullSync(forceSync: boolean, allowThrowOnError = false): Promise { + override async fullSync( + forceSync: boolean, + allowThrowOnErrorOrOptions?: boolean | SyncOptions, + ): Promise { + const { allowThrowOnError = false, skipTokenRefresh = false } = + typeof allowThrowOnErrorOrOptions === "boolean" + ? { allowThrowOnError: allowThrowOnErrorOrOptions } + : (allowThrowOnErrorOrOptions ?? {}); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))); this.syncStarted(); const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId)); @@ -127,7 +136,9 @@ export class DefaultSyncService extends CoreSyncService { } try { - await this.apiService.refreshIdentityToken(); + if (!skipTokenRefresh) { + await this.apiService.refreshIdentityToken(); + } const response = await this.apiService.getSync(); await this.syncProfile(response.profile); diff --git a/libs/common/src/platform/sync/sync.service.ts b/libs/common/src/platform/sync/sync.service.ts index 967e4db27a5..6ef62fc9cb8 100644 --- a/libs/common/src/platform/sync/sync.service.ts +++ b/libs/common/src/platform/sync/sync.service.ts @@ -7,6 +7,26 @@ import { } from "../../models/response/notification.response"; import { UserId } from "../../types/guid"; +/** + * A set of options for configuring how a {@link SyncService.fullSync} call should behave. + */ +export type SyncOptions = { + /** + * A boolean dictating whether or not caught errors should be rethrown. + * `true` if they can be rethrown, `false` if they should not be rethrown. + * @default false + */ + allowThrowOnError?: boolean; + /** + * A boolean dictating whether or not to do a token refresh before doing the sync. + * `true` if the refresh can be skipped, likely because one was done soon before the call to + * `fullSync`. `false` if the token refresh should be done before getting data. + * + * @default false + */ + skipTokenRefresh?: boolean; +}; + /** * A class encapsulating sync operations and data. */ @@ -47,9 +67,12 @@ export abstract class SyncService { * as long as the current user is authenticated. If `false` it will only sync if either a sync * has not happened before or the last sync date for the active user is before their account * revision date. Try to always use `false` if possible. - * - * @param allowThrowOnError A boolean dictating whether or not caught errors should be rethrown. - * `true` if they can be rethrown, `false` if they should not be rethrown. + * @param syncOptions Options for customizing how the sync call should behave. + */ + abstract fullSync(forceSync: boolean, syncOptions?: SyncOptions): Promise; + + /** + * @deprecated Use the overload taking {@link SyncOptions} instead. */ abstract fullSync(forceSync: boolean, allowThrowOnError?: boolean): Promise; diff --git a/libs/common/src/vault/tasks/services/default-task.service.spec.ts b/libs/common/src/vault/tasks/services/default-task.service.spec.ts index 4d468d09766..cb22d1296ba 100644 --- a/libs/common/src/vault/tasks/services/default-task.service.spec.ts +++ b/libs/common/src/vault/tasks/services/default-task.service.spec.ts @@ -108,6 +108,34 @@ describe("Default task service", () => { }); describe("tasks$", () => { + beforeEach(() => { + mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(true)); + mockGetAllOrgs$.mockReturnValue( + new BehaviorSubject([ + { + useRiskInsights: true, + }, + ] as Organization[]), + ); + }); + + it("should return an empty array if tasks are not enabled", async () => { + mockGetAllOrgs$.mockReturnValue( + new BehaviorSubject([ + { + useRiskInsights: false, + }, + ] as Organization[]), + ); + + const { tasks$ } = service; + + const result = await firstValueFrom(tasks$("user-id" as UserId)); + + expect(result.length).toBe(0); + expect(mockApiSend).not.toHaveBeenCalled(); + }); + it("should fetch tasks from the API when the state is null", async () => { mockApiSend.mockResolvedValue({ data: [ @@ -153,6 +181,34 @@ describe("Default task service", () => { }); describe("pendingTasks$", () => { + beforeEach(() => { + mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(true)); + mockGetAllOrgs$.mockReturnValue( + new BehaviorSubject([ + { + useRiskInsights: true, + }, + ] as Organization[]), + ); + }); + + it("should return an empty array if tasks are not enabled", async () => { + mockGetAllOrgs$.mockReturnValue( + new BehaviorSubject([ + { + useRiskInsights: false, + }, + ] as Organization[]), + ); + + const { pendingTasks$ } = service; + + const result = await firstValueFrom(pendingTasks$("user-id" as UserId)); + + expect(result.length).toBe(0); + expect(mockApiSend).not.toHaveBeenCalled(); + }); + it("should filter tasks to only pending tasks", async () => { fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, [ { diff --git a/libs/common/src/vault/tasks/services/default-task.service.ts b/libs/common/src/vault/tasks/services/default-task.service.ts index 7386102263c..a50f736f7fd 100644 --- a/libs/common/src/vault/tasks/services/default-task.service.ts +++ b/libs/common/src/vault/tasks/services/default-task.service.ts @@ -1,4 +1,14 @@ -import { combineLatest, filter, map, merge, Observable, of, Subscription, switchMap } from "rxjs"; +import { + combineLatest, + filter, + map, + merge, + Observable, + of, + Subscription, + switchMap, + distinctUntilChanged, +} from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -45,20 +55,30 @@ export class DefaultTaskService implements TaskService { .organizations$(userId) .pipe(map((orgs) => orgs.some((o) => o.useRiskInsights))), this.configService.getFeatureFlag$(FeatureFlag.SecurityTasks), - ]).pipe(map(([atLeastOneOrgEnabled, flagEnabled]) => atLeastOneOrgEnabled && flagEnabled)); + ]).pipe( + map(([atLeastOneOrgEnabled, flagEnabled]) => atLeastOneOrgEnabled && flagEnabled), + distinctUntilChanged(), + ); }); tasks$ = perUserCache$((userId) => { - return this.taskState(userId).state$.pipe( - switchMap(async (tasks) => { - if (tasks == null) { - await this.fetchTasksFromApi(userId); - return null; + return this.tasksEnabled$(userId).pipe( + switchMap((enabled) => { + if (!enabled) { + return of([]); } - return tasks; + return this.taskState(userId).state$.pipe( + switchMap(async (tasks) => { + if (tasks == null) { + await this.fetchTasksFromApi(userId); + return null; + } + return tasks; + }), + filterOutNullish(), + map((tasks) => tasks.map((t) => new SecurityTask(t))), + ); }), - filterOutNullish(), - map((tasks) => tasks.map((t) => new SecurityTask(t))), ); }); diff --git a/libs/components/src/icon-button/index.ts b/libs/components/src/icon-button/index.ts index 9da4a3162bf..cc52f263252 100644 --- a/libs/components/src/icon-button/index.ts +++ b/libs/components/src/icon-button/index.ts @@ -1 +1,2 @@ export * from "./icon-button.module"; +export { BitIconButtonComponent } from "./icon-button.component"; diff --git a/libs/vault/src/cipher-form/cipher-form.stories.ts b/libs/vault/src/cipher-form/cipher-form.stories.ts index 50577472120..9943f07292d 100644 --- a/libs/vault/src/cipher-form/cipher-form.stories.ts +++ b/libs/vault/src/cipher-form/cipher-form.stories.ts @@ -34,7 +34,9 @@ import { AsyncActionsModule, ButtonModule, ItemModule, ToastService } from "@bit import { CipherFormConfig, CipherFormGenerationService, + NudgeStatus, PasswordRepromptService, + VaultNudgesService, } from "@bitwarden/vault"; // FIXME: remove `/apps` import from `/libs` // FIXME: remove `src` and fix import @@ -47,6 +49,7 @@ import { CipherFormService } from "./abstractions/cipher-form.service"; import { TotpCaptureService } from "./abstractions/totp-capture.service"; import { CipherFormModule } from "./cipher-form.module"; import { CipherFormComponent } from "./components/cipher-form.component"; +import { NewItemNudgeComponent } from "./components/new-item-nudge/new-item-nudge.component"; import { CipherFormCacheService } from "./services/default-cipher-form-cache.service"; const defaultConfig: CipherFormConfig = { @@ -132,8 +135,23 @@ export default { component: CipherFormComponent, decorators: [ moduleMetadata({ - imports: [CipherFormModule, AsyncActionsModule, ButtonModule, ItemModule], + imports: [ + CipherFormModule, + AsyncActionsModule, + ButtonModule, + ItemModule, + NewItemNudgeComponent, + ], providers: [ + { + provide: VaultNudgesService, + useValue: { + showNudge$: new BehaviorSubject({ + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + } as NudgeStatus), + }, + }, { provide: CipherFormService, useClass: TestAddEditFormService, diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.html b/libs/vault/src/cipher-form/components/cipher-form.component.html index 6b327486c47..614c7f3dc7a 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.html +++ b/libs/vault/src/cipher-form/components/cipher-form.component.html @@ -1,3 +1,4 @@ + diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.ts b/libs/vault/src/cipher-form/components/cipher-form.component.ts index 080af489253..96e1328338b 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -45,6 +45,7 @@ import { CardDetailsSectionComponent } from "./card-details-section/card-details import { IdentitySectionComponent } from "./identity/identity.component"; import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component"; import { LoginDetailsSectionComponent } from "./login-details-section/login-details-section.component"; +import { NewItemNudgeComponent } from "./new-item-nudge/new-item-nudge.component"; import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.component"; @Component({ @@ -76,6 +77,7 @@ import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.componen NgIf, AdditionalOptionsSectionComponent, LoginDetailsSectionComponent, + NewItemNudgeComponent, ], }) export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, CipherFormContainer { diff --git a/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.html b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.html new file mode 100644 index 00000000000..5cd1246fd36 --- /dev/null +++ b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.html @@ -0,0 +1,8 @@ + + + + diff --git a/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.spec.ts b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.spec.ts new file mode 100644 index 00000000000..073c588690d --- /dev/null +++ b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.spec.ts @@ -0,0 +1,101 @@ +import { CommonModule } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherType } from "@bitwarden/sdk-internal"; + +import { VaultNudgesService, VaultNudgeType } from "../../../services/vault-nudges.service"; + +import { NewItemNudgeComponent } from "./new-item-nudge.component"; + +describe("NewItemNudgeComponent", () => { + let component: NewItemNudgeComponent; + let fixture: ComponentFixture; + + let i18nService: MockProxy; + let accountService: MockProxy; + let vaultNudgesService: MockProxy; + + beforeEach(async () => { + i18nService = mock({ t: (key: string) => key }); + accountService = mock(); + vaultNudgesService = mock(); + + await TestBed.configureTestingModule({ + imports: [NewItemNudgeComponent, CommonModule], + providers: [ + { provide: I18nService, useValue: i18nService }, + { provide: AccountService, useValue: accountService }, + { provide: VaultNudgesService, useValue: vaultNudgesService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NewItemNudgeComponent); + component = fixture.componentInstance; + component.configType = null; // Set to null for initial state + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should set nudge title and body for CipherType.Login type", async () => { + component.configType = CipherType.Login; + accountService.activeAccount$ = of({ id: "test-user-id" as UserId } as Account); + jest.spyOn(component, "checkHasSpotlightDismissed").mockResolvedValue(true); + + await component.ngOnInit(); + + expect(component.showNewItemSpotlight).toBe(true); + expect(component.nudgeTitle).toBe("newLoginNudgeTitle"); + expect(component.nudgeBody).toBe("newLoginNudgeBody"); + expect(component.dismissalNudgeType).toBe(VaultNudgeType.newLoginItemStatus); + }); + + it("should set nudge title and body for CipherType.Card type", async () => { + component.configType = CipherType.Card; + accountService.activeAccount$ = of({ id: "test-user-id" as UserId } as Account); + jest.spyOn(component, "checkHasSpotlightDismissed").mockResolvedValue(true); + + await component.ngOnInit(); + + expect(component.showNewItemSpotlight).toBe(true); + expect(component.nudgeTitle).toBe("newCardNudgeTitle"); + expect(component.nudgeBody).toBe("newCardNudgeBody"); + expect(component.dismissalNudgeType).toBe(VaultNudgeType.newCardItemStatus); + }); + + it("should not show anything if spotlight has been dismissed", async () => { + component.configType = CipherType.Identity; + accountService.activeAccount$ = of({ id: "test-user-id" as UserId } as Account); + jest.spyOn(component, "checkHasSpotlightDismissed").mockResolvedValue(false); + + await component.ngOnInit(); + + expect(component.showNewItemSpotlight).toBe(false); + expect(component.dismissalNudgeType).toBe(VaultNudgeType.newIdentityItemStatus); + }); + + it("should set showNewItemSpotlight to false when user dismisses spotlight", async () => { + component.showNewItemSpotlight = true; + component.dismissalNudgeType = VaultNudgeType.newLoginItemStatus; + component.activeUserId = "test-user-id" as UserId; + + const dismissSpy = jest.spyOn(vaultNudgesService, "dismissNudge").mockResolvedValue(); + + await component.dismissNewItemSpotlight(); + + expect(component.showNewItemSpotlight).toBe(false); + expect(dismissSpy).toHaveBeenCalledWith( + VaultNudgeType.newLoginItemStatus, + component.activeUserId, + ); + }); +}); diff --git a/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts new file mode 100644 index 00000000000..b497585d4fb --- /dev/null +++ b/libs/vault/src/cipher-form/components/new-item-nudge/new-item-nudge.component.ts @@ -0,0 +1,90 @@ +import { NgIf } from "@angular/common"; +import { Component, Input, OnInit } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherType } from "@bitwarden/sdk-internal"; + +import { SpotlightComponent } from "../../../components/spotlight/spotlight.component"; +import { VaultNudgesService, VaultNudgeType } from "../../../services/vault-nudges.service"; + +@Component({ + selector: "vault-new-item-nudge", + templateUrl: "./new-item-nudge.component.html", + standalone: true, + imports: [NgIf, SpotlightComponent], +}) +export class NewItemNudgeComponent implements OnInit { + @Input({ required: true }) configType: CipherType | null = null; + activeUserId: UserId | null = null; + showNewItemSpotlight: boolean = false; + nudgeTitle: string = ""; + nudgeBody: string = ""; + dismissalNudgeType: VaultNudgeType | null = null; + + constructor( + private i18nService: I18nService, + private accountService: AccountService, + private vaultNudgesService: VaultNudgesService, + ) {} + + async ngOnInit() { + this.activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + + switch (this.configType) { + case CipherType.Login: + this.dismissalNudgeType = VaultNudgeType.newLoginItemStatus; + this.nudgeTitle = this.i18nService.t("newLoginNudgeTitle"); + this.nudgeBody = this.i18nService.t("newLoginNudgeBody"); + break; + + case CipherType.Card: + this.dismissalNudgeType = VaultNudgeType.newCardItemStatus; + this.nudgeTitle = this.i18nService.t("newCardNudgeTitle"); + this.nudgeBody = this.i18nService.t("newCardNudgeBody"); + break; + + case CipherType.Identity: + this.dismissalNudgeType = VaultNudgeType.newIdentityItemStatus; + this.nudgeTitle = this.i18nService.t("newIdentityNudgeTitle"); + this.nudgeBody = this.i18nService.t("newIdentityNudgeBody"); + break; + + case CipherType.SecureNote: + this.dismissalNudgeType = VaultNudgeType.newNoteItemStatus; + this.nudgeTitle = this.i18nService.t("newNoteNudgeTitle"); + this.nudgeBody = this.i18nService.t("newNoteNudgeBody"); + break; + + case CipherType.SshKey: + this.dismissalNudgeType = VaultNudgeType.newSshItemStatus; + this.nudgeTitle = this.i18nService.t("newSshNudgeTitle"); + this.nudgeBody = this.i18nService.t("newSshNudgeBody"); + break; + default: + throw new Error("Unsupported cipher type"); + } + this.showNewItemSpotlight = await this.checkHasSpotlightDismissed( + this.dismissalNudgeType as VaultNudgeType, + this.activeUserId, + ); + } + + async dismissNewItemSpotlight() { + if (this.dismissalNudgeType && this.activeUserId) { + await this.vaultNudgesService.dismissNudge( + this.dismissalNudgeType, + this.activeUserId as UserId, + ); + this.showNewItemSpotlight = false; + } + } + + async checkHasSpotlightDismissed(nudgeType: VaultNudgeType, userId: UserId): Promise { + return !(await firstValueFrom(this.vaultNudgesService.showNudge$(nudgeType, userId))) + .hasSpotlightDismissed; + } +} diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html index 99f1b1e47f5..68edd265d09 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.html +++ b/libs/vault/src/cipher-view/cipher-view.component.html @@ -3,19 +3,18 @@ {{ "cardExpiredMessage" | i18n }} - - - - - {{ "changeAtRiskPassword" | i18n }} - - - - + + + + {{ "changeAtRiskPassword" | i18n }} + + + +

diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index e748fa46656..cf78e78a65f 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -12,12 +12,12 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { isCardExpired } from "@bitwarden/common/autofill/utils"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherId, CollectionId, EmergencyAccessId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; @@ -80,7 +80,6 @@ export class CipherViewComponent implements OnChanges, OnDestroy { private destroyed$: Subject = new Subject(); cardIsExpired: boolean = false; hadPendingChangePasswordTask: boolean = false; - isSecurityTasksEnabled$ = this.configService.getFeatureFlag$(FeatureFlag.SecurityTasks); constructor( private organizationService: OrganizationService, @@ -90,8 +89,8 @@ export class CipherViewComponent implements OnChanges, OnDestroy { private defaultTaskService: TaskService, private platformUtilsService: PlatformUtilsService, private changeLoginPasswordService: ChangeLoginPasswordService, - private configService: ConfigService, private cipherService: CipherService, + private logService: LogService, ) {} async ngOnChanges() { @@ -158,20 +157,15 @@ export class CipherViewComponent implements OnChanges, OnDestroy { const userId = await firstValueFrom(this.activeUserId$); - // Show Tasks for Manage and Edit permissions - // Using cipherService to see if user has access to cipher in a non-AC context to address with Edit Except Password permissions - const allCiphers = await firstValueFrom(this.cipherService.ciphers$(userId)); - const cipherServiceCipher = allCiphers[this.cipher?.id as CipherId]; - - if (cipherServiceCipher?.edit && cipherServiceCipher?.viewPassword) { - await this.checkPendingChangePasswordTasks(userId); - } - - if (this.cipher.organizationId && userId) { + if (this.cipher.organizationId) { this.organization$ = this.organizationService .organizations$(userId) .pipe(getOrganizationById(this.cipher.organizationId)) .pipe(takeUntil(this.destroyed$)); + + if (this.cipher.type === CipherType.Login) { + await this.checkPendingChangePasswordTasks(userId); + } } if (this.cipher.folderId) { @@ -182,17 +176,28 @@ export class CipherViewComponent implements OnChanges, OnDestroy { } async checkPendingChangePasswordTasks(userId: UserId): Promise { - if (!(await firstValueFrom(this.isSecurityTasksEnabled$))) { - return; + try { + // Show Tasks for Manage and Edit permissions + // Using cipherService to see if user has access to cipher in a non-AC context to address with Edit Except Password permissions + const allCiphers = await firstValueFrom(this.cipherService.ciphers$(userId)); + const cipherServiceCipher = allCiphers[this.cipher?.id as CipherId]; + + if (!cipherServiceCipher?.edit || !cipherServiceCipher?.viewPassword) { + this.hadPendingChangePasswordTask = false; + return; + } + + const tasks = await firstValueFrom(this.defaultTaskService.pendingTasks$(userId)); + + this.hadPendingChangePasswordTask = tasks?.some((task) => { + return ( + task.cipherId === this.cipher?.id && task.type === SecurityTaskType.UpdateAtRiskCredential + ); + }); + } catch (error) { + this.hadPendingChangePasswordTask = false; + this.logService.error("Failed to retrieve change password tasks for cipher", error); } - - const tasks = await firstValueFrom(this.defaultTaskService.pendingTasks$(userId)); - - this.hadPendingChangePasswordTask = tasks?.some((task) => { - return ( - task.cipherId === this.cipher?.id && task.type === SecurityTaskType.UpdateAtRiskCredential - ); - }); } launchChangePassword = async () => { diff --git a/libs/vault/src/components/copy-cipher-field.directive.spec.ts b/libs/vault/src/components/copy-cipher-field.directive.spec.ts new file mode 100644 index 00000000000..0847e7147a9 --- /dev/null +++ b/libs/vault/src/components/copy-cipher-field.directive.spec.ts @@ -0,0 +1,197 @@ +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { BitIconButtonComponent, MenuItemDirective } from "@bitwarden/components"; +import { CopyCipherFieldService } from "@bitwarden/vault"; + +import { CopyCipherFieldDirective } from "./copy-cipher-field.directive"; + +describe("CopyCipherFieldDirective", () => { + const copyFieldService = { + copy: jest.fn().mockResolvedValue(null), + totpAllowed: jest.fn().mockResolvedValue(true), + }; + + let copyCipherFieldDirective: CopyCipherFieldDirective; + + beforeEach(() => { + copyFieldService.copy.mockClear(); + copyFieldService.totpAllowed.mockClear(); + + copyCipherFieldDirective = new CopyCipherFieldDirective( + copyFieldService as unknown as CopyCipherFieldService, + ); + copyCipherFieldDirective.cipher = new CipherView(); + }); + + describe("disabled state", () => { + it("should be enabled when the field is available", async () => { + copyCipherFieldDirective.action = "username"; + copyCipherFieldDirective.cipher.login.username = "test-username"; + + await copyCipherFieldDirective.ngOnChanges(); + + expect(copyCipherFieldDirective["disabled"]).toBe(null); + }); + + it("should be disabled when the field is not available", async () => { + // create empty cipher + copyCipherFieldDirective.cipher = new CipherView(); + + copyCipherFieldDirective.action = "username"; + + await copyCipherFieldDirective.ngOnChanges(); + + expect(copyCipherFieldDirective["disabled"]).toBe(true); + }); + + it("updates icon button disabled state", async () => { + const iconButton = { + disabled: { + set: jest.fn(), + }, + }; + + copyCipherFieldDirective = new CopyCipherFieldDirective( + copyFieldService as unknown as CopyCipherFieldService, + undefined, + iconButton as unknown as BitIconButtonComponent, + ); + + copyCipherFieldDirective.action = "password"; + + await copyCipherFieldDirective.ngOnChanges(); + + expect(iconButton.disabled.set).toHaveBeenCalledWith(true); + }); + + it("updates menuItemDirective disabled state", async () => { + const menuItemDirective = { + disabled: false, + }; + + copyCipherFieldDirective = new CopyCipherFieldDirective( + copyFieldService as unknown as CopyCipherFieldService, + menuItemDirective as unknown as MenuItemDirective, + ); + + copyCipherFieldDirective.action = "totp"; + + await copyCipherFieldDirective.ngOnChanges(); + + expect(menuItemDirective.disabled).toBe(true); + }); + }); + + describe("login", () => { + beforeEach(() => { + copyCipherFieldDirective.cipher.login.username = "test-username"; + copyCipherFieldDirective.cipher.login.password = "test-password"; + copyCipherFieldDirective.cipher.login.totp = "test-totp"; + }); + + it.each([ + ["username", "test-username"], + ["password", "test-password"], + ["totp", "test-totp"], + ])("copies %s field from login to clipboard", async (action, value) => { + copyCipherFieldDirective.action = action as CopyCipherFieldDirective["action"]; + + await copyCipherFieldDirective.copy(); + + expect(copyFieldService.copy).toHaveBeenCalledWith( + value, + action, + copyCipherFieldDirective.cipher, + ); + }); + }); + + describe("identity", () => { + beforeEach(() => { + copyCipherFieldDirective.cipher.identity.username = "test-username"; + copyCipherFieldDirective.cipher.identity.email = "test-email"; + copyCipherFieldDirective.cipher.identity.phone = "test-phone"; + copyCipherFieldDirective.cipher.identity.address1 = "test-address-1"; + }); + + it.each([ + ["username", "test-username"], + ["email", "test-email"], + ["phone", "test-phone"], + ["address", "test-address-1"], + ])("copies %s field from identity to clipboard", async (action, value) => { + copyCipherFieldDirective.action = action as CopyCipherFieldDirective["action"]; + + await copyCipherFieldDirective.copy(); + + expect(copyFieldService.copy).toHaveBeenCalledWith( + value, + action, + copyCipherFieldDirective.cipher, + ); + }); + }); + + describe("card", () => { + beforeEach(() => { + copyCipherFieldDirective.cipher.card.number = "test-card-number"; + copyCipherFieldDirective.cipher.card.code = "test-card-code"; + }); + + it.each([ + ["cardNumber", "test-card-number"], + ["securityCode", "test-card-code"], + ])("copies %s field from card to clipboard", async (action, value) => { + copyCipherFieldDirective.action = action as CopyCipherFieldDirective["action"]; + + await copyCipherFieldDirective.copy(); + + expect(copyFieldService.copy).toHaveBeenCalledWith( + value, + action, + copyCipherFieldDirective.cipher, + ); + }); + }); + + describe("secure note", () => { + beforeEach(() => { + copyCipherFieldDirective.cipher.notes = "test-secure-note"; + }); + + it("copies secure note field to clipboard", async () => { + copyCipherFieldDirective.action = "secureNote"; + + await copyCipherFieldDirective.copy(); + + expect(copyFieldService.copy).toHaveBeenCalledWith( + "test-secure-note", + "secureNote", + copyCipherFieldDirective.cipher, + ); + }); + }); + + describe("ssh key", () => { + beforeEach(() => { + copyCipherFieldDirective.cipher.sshKey.privateKey = "test-private-key"; + copyCipherFieldDirective.cipher.sshKey.publicKey = "test-public-key"; + copyCipherFieldDirective.cipher.sshKey.keyFingerprint = "test-key-fingerprint"; + }); + + it.each([ + ["privateKey", "test-private-key"], + ["publicKey", "test-public-key"], + ["keyFingerprint", "test-key-fingerprint"], + ])("copies %s field from ssh key to clipboard", async (action, value) => { + copyCipherFieldDirective.action = action as CopyCipherFieldDirective["action"]; + + await copyCipherFieldDirective.copy(); + + expect(copyFieldService.copy).toHaveBeenCalledWith( + value, + action, + copyCipherFieldDirective.cipher, + ); + }); + }); +}); diff --git a/libs/vault/src/components/copy-cipher-field.directive.ts b/libs/vault/src/components/copy-cipher-field.directive.ts index 1eb96a30449..324b43f12d4 100644 --- a/libs/vault/src/components/copy-cipher-field.directive.ts +++ b/libs/vault/src/components/copy-cipher-field.directive.ts @@ -1,7 +1,7 @@ import { Directive, HostBinding, HostListener, Input, OnChanges, Optional } from "@angular/core"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { MenuItemDirective } from "@bitwarden/components"; +import { MenuItemDirective, BitIconButtonComponent } from "@bitwarden/components"; import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault"; /** @@ -33,6 +33,7 @@ export class CopyCipherFieldDirective implements OnChanges { constructor( private copyCipherFieldService: CopyCipherFieldService, @Optional() private menuItemDirective?: MenuItemDirective, + @Optional() private iconButtonComponent?: BitIconButtonComponent, ) {} @HostBinding("attr.disabled") @@ -65,6 +66,11 @@ export class CopyCipherFieldDirective implements OnChanges { ? true : null; + // When used on an icon button, update the disabled state of the button component + if (this.iconButtonComponent) { + this.iconButtonComponent.disabled.set(this.disabled ?? false); + } + // If the directive is used on a menu item, update the menu item to prevent keyboard navigation if (this.menuItemDirective) { this.menuItemDirective.disabled = this.disabled ?? false; diff --git a/libs/vault/src/services/custom-nudges-services/has-nudge.service.ts b/libs/vault/src/services/custom-nudges-services/has-nudge.service.ts index 0c14cff002f..c9077a7283b 100644 --- a/libs/vault/src/services/custom-nudges-services/has-nudge.service.ts +++ b/libs/vault/src/services/custom-nudges-services/has-nudge.service.ts @@ -18,8 +18,6 @@ export class HasNudgeService extends DefaultSingleNudgeService { private nudgeTypes: VaultNudgeType[] = [ VaultNudgeType.EmptyVaultNudge, - VaultNudgeType.HasVaultItems, - VaultNudgeType.IntroCarouselDismissal, // add additional nudge types here as needed ]; diff --git a/libs/vault/src/services/custom-nudges-services/index.ts b/libs/vault/src/services/custom-nudges-services/index.ts index 9a1f0acd420..131db023175 100644 --- a/libs/vault/src/services/custom-nudges-services/index.ts +++ b/libs/vault/src/services/custom-nudges-services/index.ts @@ -1,3 +1,4 @@ export * from "./has-items-nudge.service"; export * from "./empty-vault-nudge.service"; export * from "./has-nudge.service"; +export * from "./new-item-nudge.service"; diff --git a/libs/vault/src/services/custom-nudges-services/new-item-nudge.service.ts b/libs/vault/src/services/custom-nudges-services/new-item-nudge.service.ts new file mode 100644 index 00000000000..93ef5d81dc4 --- /dev/null +++ b/libs/vault/src/services/custom-nudges-services/new-item-nudge.service.ts @@ -0,0 +1,65 @@ +import { inject, Injectable } from "@angular/core"; +import { combineLatest, Observable, switchMap } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; + +import { DefaultSingleNudgeService } from "../default-single-nudge.service"; +import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service"; + +/** + * Custom Nudge Service Checking Nudge Status For Vault New Item Types + */ +@Injectable({ + providedIn: "root", +}) +export class NewItemNudgeService extends DefaultSingleNudgeService { + cipherService = inject(CipherService); + + nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable { + return combineLatest([ + this.getNudgeStatus$(nudgeType, userId), + this.cipherService.cipherViews$(userId), + ]).pipe( + switchMap(async ([nudgeStatus, ciphers]) => { + if (nudgeStatus.hasSpotlightDismissed) { + return nudgeStatus; + } + + let currentType: CipherType; + + switch (nudgeType) { + case VaultNudgeType.newLoginItemStatus: + currentType = CipherType.Login; + break; + case VaultNudgeType.newCardItemStatus: + currentType = CipherType.Card; + break; + case VaultNudgeType.newIdentityItemStatus: + currentType = CipherType.Identity; + break; + case VaultNudgeType.newNoteItemStatus: + currentType = CipherType.SecureNote; + break; + case VaultNudgeType.newSshItemStatus: + currentType = CipherType.SshKey; + break; + } + + const ciphersBoolean = ciphers.some((cipher) => cipher.type === currentType); + + if (ciphersBoolean) { + const dismissedStatus = { + hasSpotlightDismissed: true, + hasBadgeDismissed: true, + }; + await this.setNudgeStatus(nudgeType, dismissedStatus, userId); + return dismissedStatus; + } + + return nudgeStatus; + }), + ); + } +} diff --git a/libs/vault/src/services/vault-nudges.service.spec.ts b/libs/vault/src/services/vault-nudges.service.spec.ts index a01cac94fb1..69ddf1cdaa0 100644 --- a/libs/vault/src/services/vault-nudges.service.spec.ts +++ b/libs/vault/src/services/vault-nudges.service.spec.ts @@ -5,6 +5,7 @@ import { firstValueFrom, of } from "rxjs"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FakeStateProvider, mockAccountServiceWith } from "../../../common/spec"; @@ -46,6 +47,7 @@ describe("Vault Nudges Service", () => { provide: EmptyVaultNudgeService, useValue: mock(), }, + { provide: CipherService, useValue: mock() }, ], }); }); diff --git a/libs/vault/src/services/vault-nudges.service.ts b/libs/vault/src/services/vault-nudges.service.ts index 28198d17068..98f28af9954 100644 --- a/libs/vault/src/services/vault-nudges.service.ts +++ b/libs/vault/src/services/vault-nudges.service.ts @@ -6,7 +6,11 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { UserKeyDefinition, VAULT_NUDGES_DISK } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; -import { HasItemsNudgeService, EmptyVaultNudgeService } from "./custom-nudges-services"; +import { + HasItemsNudgeService, + EmptyVaultNudgeService, + NewItemNudgeService, +} from "./custom-nudges-services"; import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service"; export type NudgeStatus = { @@ -23,7 +27,11 @@ export enum VaultNudgeType { */ EmptyVaultNudge = "empty-vault-nudge", HasVaultItems = "has-vault-items", - IntroCarouselDismissal = "intro-carousel-dismissal", + newLoginItemStatus = "new-login-item-status", + newCardItemStatus = "new-card-item-status", + newIdentityItemStatus = "new-identity-item-status", + newNoteItemStatus = "new-note-item-status", + newSshItemStatus = "new-ssh-item-status", } export const VAULT_NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition< @@ -37,6 +45,8 @@ export const VAULT_NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition< providedIn: "root", }) export class VaultNudgesService { + private newItemNudgeService = inject(NewItemNudgeService); + /** * Custom nudge services to use for specific nudge types * Each nudge type can have its own service to determine when to show the nudge @@ -45,6 +55,11 @@ export class VaultNudgesService { private customNudgeServices: any = { [VaultNudgeType.HasVaultItems]: inject(HasItemsNudgeService), [VaultNudgeType.EmptyVaultNudge]: inject(EmptyVaultNudgeService), + [VaultNudgeType.newLoginItemStatus]: this.newItemNudgeService, + [VaultNudgeType.newCardItemStatus]: this.newItemNudgeService, + [VaultNudgeType.newIdentityItemStatus]: this.newItemNudgeService, + [VaultNudgeType.newNoteItemStatus]: this.newItemNudgeService, + [VaultNudgeType.newSshItemStatus]: this.newItemNudgeService, }; /** diff --git a/package-lock.json b/package-lock.json index 671951c0349..25322b844b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -231,7 +231,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2025.4.2", + "version": "2025.5.0", "hasInstallScript": true, "license": "GPL-3.0" },