1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-09 21:20:27 +00:00

Merge branch 'main' into autofill/pm-19255

This commit is contained in:
Colton Hurst
2025-05-01 16:53:10 -04:00
82 changed files with 1924 additions and 856 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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
<https://github.com/bitwarden/server/blob/master/TRADEMARK_GUIDELINES.md>.
<https://github.com/bitwarden/server/blob/main/TRADEMARK_GUIDELINES.md>.

View File

@@ -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
<https://github.com/bitwarden/server/blob/master/TRADEMARK_GUIDELINES.md>.
<https://github.com/bitwarden/server/blob/main/TRADEMARK_GUIDELINES.md>.
3. TERMINATION

View File

@@ -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

View File

@@ -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."
}
}
}

View File

@@ -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,

View File

@@ -32,6 +32,7 @@ export const InlineMenuAccountCreationFieldType = {
Text: "text",
Email: "email",
Password: "password",
Totp: "totp",
} as const;
export type InlineMenuAccountCreationFieldTypes =

View File

@@ -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;

View File

@@ -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;
}
/**

View File

@@ -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<SyncOptions>;
}[] = [
{
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<FullSyncFinishedMessage>();
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({

View File

@@ -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<FullSyncMessage>("doFullSync");
@@ -60,9 +61,20 @@ export class ForegroundSyncService extends CoreSyncService {
);
}
async fullSync(forceSync: boolean, allowThrowOnError: boolean = false): Promise<boolean> {
async fullSync(
forceSync: boolean,
allowThrowOnErrorOrOptions?: boolean | SyncOptions,
): Promise<boolean> {
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);
}

View File

@@ -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",

View File

@@ -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<void> {
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,

View File

@@ -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)),
);
}

View File

@@ -15,12 +15,11 @@
<bit-no-items [icon]="vaultIcon">
<ng-container slot="title">{{ "yourVaultIsEmpty" | i18n }}</ng-container>
<ng-container slot="description">
<p bitTypography="body2" class="tw-mx-6">{{ "emptyVaultDescription" | i18n }}</p>
<p bitTypography="body2" class="tw-mx-6 tw-mt-2">{{ "emptyVaultDescription" | i18n }}</p>
</ng-container>
<app-new-item-dropdown
slot="button"
[initialValues]="newItemItemValues$ | async"
></app-new-item-dropdown>
<a slot="button" bitButton buttonType="secondary" [routerLink]="['/add-cipher']">
{{ "newLogin" | i18n }}
</a>
</bit-no-items>
</div>

View File

@@ -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

View File

@@ -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)

View File

@@ -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",

View File

@@ -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."
}
}

View File

@@ -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",
},
];
}

View File

@@ -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"

View File

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

View File

@@ -88,5 +88,9 @@
<i class="bwi bwi-sticky-note tw-mr-1" aria-hidden="true"></i>
{{ "typeSecureNote" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.SshKey)">
<i class="bwi bwi-key tw-mr-1" aria-hidden="true"></i>
{{ "typeSshKey" | i18n }}
</button>
</bit-menu>
</ng-template>

View File

@@ -6,7 +6,6 @@
(onCipherClicked)="viewCipher($event)"
(onCipherRightClicked)="viewCipherMenu($event)"
(onAddCipher)="addCipher($event)"
(onAddCipherOptions)="addCipherOptions()"
>
</app-vault-items-v2>
<div class="details" *ngIf="!!action">

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -1,12 +1,12 @@
<p align="center">
<img src="https://raw.githubusercontent.com/bitwarden/brand/master/screenshots/web-vault-macbook.png" alt="" width="600" height="358" />
<img src="https://raw.githubusercontent.com/bitwarden/brand/main/screenshots/web-vault.png" alt="" width="600" height="358" />
</p>
<p align="center">
The Bitwarden web project is an Angular application that powers the web vault (https://vault.bitwarden.com/).
</p>
<p align="center">
<a href="https://github.com/bitwarden/clients/actions/workflows/build-web.yml?query=branch:master" target="_blank">
<img src="https://github.com/bitwarden/clients/actions/workflows/build-web.yml/badge.svg?branch=master" alt="Github Workflow build on master" />
<a href="https://github.com/bitwarden/clients/actions/workflows/build-web.yml?query=branch:main" target="_blank">
<img src="https://github.com/bitwarden/clients/actions/workflows/build-web.yml/badge.svg?branch=main" alt="Github Workflow build on main" />
</a>
<a href="https://crowdin.com/project/bitwarden-web" target="_blank">
<img src="https://d322cqt584bo4o.cloudfront.net/bitwarden-web/localized.svg" alt="Crowdin" />

View File

@@ -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<LoginView>,
card: {},
} as CipherView;
} as Partial<CipherView> 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<TaskService>() },
{ provide: LogService, useValue: mock<LogService>() },
],
})
.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();
});

View File

@@ -34,7 +34,15 @@
</div>
<ng-container bitDialogFooter>
<button bitButton bitFormButton type="button" buttonType="primary" (click)="save()">
<button
bitButton
bitFormButton
type="button"
buttonType="primary"
[loading]="loading"
[disabled]="loading"
(click)="save()"
>
{{ "save" | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" [bitDialogClose]="false">

View File

@@ -1,5 +1,5 @@
import { DialogRef } from "@angular/cdk/dialog";
import { Component } from "@angular/core";
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import {
AbstractControl,
FormBuilder,
@@ -10,32 +10,30 @@ import {
ValidationErrors,
Validators,
} from "@angular/forms";
import { firstValueFrom, map } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PlanSponsorshipType } from "@bitwarden/common/billing/enums";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule, DialogModule, DialogService, FormFieldModule } from "@bitwarden/components";
import { OrgKey } from "@bitwarden/common/types/key";
import {
ButtonModule,
DialogModule,
DialogService,
FormFieldModule,
ToastService,
} from "@bitwarden/components";
interface RequestSponsorshipForm {
sponsorshipEmail: FormControl<string | null>;
sponsorshipNote: FormControl<string | null>;
}
export interface AddSponsorshipDialogResult {
action: AddSponsorshipDialogAction;
value: Partial<AddSponsorshipFormValue> | null;
}
interface AddSponsorshipFormValue {
sponsorshipEmail: string;
sponsorshipNote: string;
status: string;
}
enum AddSponsorshipDialogAction {
Saved = "saved",
Canceled = "canceled",
interface AddSponsorshipDialogParams {
organizationId: string;
organizationKey: OrgKey;
}
@Component({
@@ -53,54 +51,82 @@ enum AddSponsorshipDialogAction {
export class AddSponsorshipDialogComponent {
sponsorshipForm: FormGroup<RequestSponsorshipForm>;
loading = false;
organizationId: string;
organizationKey: OrgKey;
constructor(
private dialogRef: DialogRef<AddSponsorshipDialogResult>,
private dialogRef: DialogRef,
private formBuilder: FormBuilder,
private accountService: AccountService,
private i18nService: I18nService,
private organizationUserApiService: OrganizationUserApiService,
private toastService: ToastService,
private apiService: ApiService,
private encryptService: EncryptService,
@Inject(DIALOG_DATA) protected dialogParams: AddSponsorshipDialogParams,
) {
this.organizationId = this.dialogParams?.organizationId;
this.organizationKey = this.dialogParams.organizationKey;
this.sponsorshipForm = this.formBuilder.group<RequestSponsorshipForm>({
sponsorshipEmail: new FormControl<string | null>("", {
validators: [Validators.email, Validators.required],
asyncValidators: [this.validateNotCurrentUserEmail.bind(this)],
asyncValidators: [this.isOrganizationMember.bind(this)],
updateOn: "change",
}),
sponsorshipNote: new FormControl<string | null>("", {}),
});
}
static open(dialogService: DialogService): DialogRef<AddSponsorshipDialogResult> {
return dialogService.open<AddSponsorshipDialogResult>(AddSponsorshipDialogComponent);
static open(dialogService: DialogService, config: DialogConfig<AddSponsorshipDialogParams>) {
return dialogService.open(AddSponsorshipDialogComponent, {
...config,
data: config.data,
} as unknown as DialogConfig<unknown, DialogRef>);
}
protected async save() {
if (this.sponsorshipForm.invalid) {
return;
}
this.loading = true;
// TODO: This is a mockup implementation - needs to be updated with actual API integration
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
const formValue = this.sponsorshipForm.getRawValue();
const dialogValue: Partial<AddSponsorshipFormValue> = {
status: "Sent",
sponsorshipEmail: formValue.sponsorshipEmail ?? "",
sponsorshipNote: formValue.sponsorshipNote ?? "",
};
try {
const notes = this.sponsorshipForm.value.sponsorshipNote || "";
const email = this.sponsorshipForm.value.sponsorshipEmail || "";
this.dialogRef.close({
action: AddSponsorshipDialogAction.Saved,
value: dialogValue,
});
const encryptedNotes = await this.encryptService.encryptString(notes, this.organizationKey);
const isAdminInitiated = true;
await this.apiService.postCreateSponsorship(this.organizationId, {
sponsoredEmail: email,
planSponsorshipType: PlanSponsorshipType.FamiliesForEnterprise,
friendlyName: email,
isAdminInitiated,
notes: encryptedNotes.encryptedString,
});
this.toastService.showToast({
variant: "success",
title: undefined,
message: this.i18nService.t("sponsorshipCreated"),
});
await this.resetForm();
} catch (e: any) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: e?.message || this.i18nService.t("unexpectedError"),
});
}
this.loading = false;
this.dialogRef.close();
}
protected close = () => {
this.dialogRef.close({ action: AddSponsorshipDialogAction.Canceled, value: null });
};
private async resetForm() {
this.sponsorshipForm.reset();
}
get sponsorshipEmailControl() {
return this.sponsorshipForm.controls.sponsorshipEmail;
@@ -110,24 +136,21 @@ export class AddSponsorshipDialogComponent {
return this.sponsorshipForm.controls.sponsorshipNote;
}
private async validateNotCurrentUserEmail(
control: AbstractControl,
): Promise<ValidationErrors | null> {
private async isOrganizationMember(control: AbstractControl): Promise<ValidationErrors | null> {
const value = control.value;
if (!value) {
return null;
}
const currentUserEmail = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email ?? "")),
const users = await this.organizationUserApiService.getAllMiniUserDetails(this.organizationId);
const userExists = users.data.some(
(member) => member.email.toLowerCase() === value.toLowerCase(),
);
if (!currentUserEmail) {
return null;
}
if (value.toLowerCase() === currentUserEmail.toLowerCase()) {
return { currentUserEmail: true };
if (userExists) {
return {
isOrganizationMember: {
message: this.i18nService.t("organizationHasMemberMessage", value),
},
};
}
return null;

View File

@@ -5,19 +5,95 @@
</button>
</app-header>
<bit-tab-group [(selectedIndex)]="tabIndex">
<bit-tab [label]="'sponsoredBitwardenFamilies' | i18n">
<app-organization-sponsored-families
[sponsoredFamilies]="sponsoredFamilies"
(removeSponsorshipEvent)="removeSponsorhip($event)"
></app-organization-sponsored-families>
</bit-tab>
<bit-container>
<ng-container>
<p bitTypography="body1">
{{ "sponsorshipFreeBitwardenFamilies" | i18n }}
</p>
<div bitTypography="body1">
{{ "sponsoredFamiliesIncludeMessage" | i18n }}:
<ul class="tw-list-outside">
<li>{{ "sponsoredFamiliesPremiumAccess" | i18n }}</li>
<li>{{ "sponsoredFamiliesSharedCollectionsMessage" | i18n }}</li>
</ul>
</div>
<bit-tab [label]="'memberFamilies' | i18n">
<app-organization-member-families
[memberFamilies]="sponsoredFamilies"
></app-organization-member-families>
</bit-tab>
</bit-tab-group>
<h2 bitTypography="h2" class="">{{ "sponsoredBitwardenFamilies" | i18n }}</h2>
<p class="tw-px-4" bitTypography="body2">{{ "sponsoredFamiliesRemoveActiveSponsorship" | i18n }}</p>
@if (loading()) {
<ng-container>
<i class="bwi bwi-spinner bwi-spin tw-text-muted" title="{{ 'loading' | i18n }}"></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
}
@if (!loading() && sponsoredFamilies?.length > 0) {
<ng-container>
<bit-table>
<ng-container header>
<tr>
<th bitCell>{{ "recipient" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
<th bitCell>{{ "notes" | i18n }}</th>
<th bitCell></th>
</tr>
</ng-container>
<ng-template body alignContent="middle">
@for (o of sponsoredFamilies; let i = $index; track i) {
<ng-container>
<tr bitRow>
<td bitCell>{{ o.friendlyName }}</td>
<td bitCell [class]="o.statusClass">{{ o.statusMessage }}</td>
<td bitCell>{{ o.notes }}</td>
<td bitCell>
<button
type="button"
bitIconButton="bwi-ellipsis-v"
buttonType="main"
[bitMenuTriggerFor]="appListDropdown"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #appListDropdown>
<button
type="button"
bitMenuItem
[attr.aria-label]="'resendEmailLabel' | i18n"
(click)="resendEmail(o)"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<hr class="m-0" />
<button
type="button"
bitMenuItem
[attr.aria-label]="'revokeAccountMessage' | i18n"
(click)="removeSponsorship(o)"
>
<i aria-hidden="true" class="bwi bwi-close tw-text-danger"></i>
<span class="tw-text-danger pl-1">{{ "remove" | i18n }}</span>
</button>
</bit-menu>
</td>
</tr>
</ng-container>
}
</ng-template>
</bit-table>
<hr class="mt-0" />
</ng-container>
} @else if (!loading()) {
<div class="tw-my-5 tw-py-5 tw-flex tw-flex-col tw-items-center">
<img class="tw-w-32" src="./../../../images/search.svg" alt="Search" />
<h4 class="mt-3" bitTypography="h4">{{ "noSponsoredFamiliesMessage" | i18n }}</h4>
<p bitTypography="body2">{{ "nosponsoredFamiliesDetails" | i18n }}</p>
</div>
}
@if (!loading() && sponsoredFamilies.length > 0) {
<p bitTypography="body2">{{ "sponsoredFamiliesRemoveActiveSponsorship" | i18n }}</p>
}
</ng-container>
</bit-container>

View File

@@ -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<boolean>(true);
tabIndex = 0;
sponsoredFamilies: SponsoredFamily[] = [];
sponsoredFamilies: OrganizationSponsorshipInvitesResponse[] = [];
organizationId = "";
organizationKey$: Observable<OrgKey>;
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<Record<OrganizationId, OrgKey>>,
),
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<AddSponsorshipDialogResult> =
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",
};
}
}

View File

@@ -1,47 +0,0 @@
<bit-container>
<ng-container>
<p bitTypography="body1">
{{ "membersWithSponsoredFamilies" | i18n }}
</p>
<h2 bitTypography="h2" class="">{{ "memberFamilies" | i18n }}</h2>
@if (loading) {
<ng-container>
<i class="bwi bwi-spinner bwi-spin tw-text-muted" title="{{ 'loading' | i18n }}"></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
}
@if (!loading && memberFamilies?.length > 0) {
<ng-container>
<bit-table>
<ng-container header>
<tr>
<th bitCell>{{ "member" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
<th bitCell></th>
</tr>
</ng-container>
<ng-template body alignContent="middle">
@for (o of memberFamilies; let i = $index; track i) {
<ng-container>
<tr bitRow>
<td bitCell>{{ o.sponsorshipEmail }}</td>
<td bitCell class="tw-text-success">{{ o.status }}</td>
</tr>
</ng-container>
}
</ng-template>
</bit-table>
<hr class="mt-0" />
</ng-container>
} @else {
<div class="tw-my-5 tw-py-5 tw-flex tw-flex-col tw-items-center">
<img class="tw-w-32" src="./../../../images/search.svg" alt="Search" />
<h4 class="mt-3" bitTypography="h4">{{ "noMemberFamilies" | i18n }}</h4>
<p bitTypography="body2">{{ "noMemberFamiliesDescription" | i18n }}</p>
</div>
}
</ng-container>
</bit-container>

View File

@@ -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<void>();
constructor(private platformUtilsService: PlatformUtilsService) {}
async ngOnInit() {
this.loading = false;
}
ngOnDestroy(): void {
this._destroy.next();
this._destroy.complete();
}
get isSelfHosted(): boolean {
return this.platformUtilsService.isSelfHost();
}
}

View File

@@ -1,87 +0,0 @@
<bit-container>
<ng-container>
<p bitTypography="body1">
{{ "sponsorFreeBitwardenFamilies" | i18n }}
</p>
<div bitTypography="body1">
{{ "sponsoredFamiliesInclude" | i18n }}:
<ul class="tw-list-outside">
<li>{{ "sponsoredFamiliesPremiumAccess" | i18n }}</li>
<li>{{ "sponsoredFamiliesSharedCollections" | i18n }}</li>
</ul>
</div>
<h2 bitTypography="h2" class="">{{ "sponsoredBitwardenFamilies" | i18n }}</h2>
@if (loading) {
<ng-container>
<i class="bwi bwi-spinner bwi-spin tw-text-muted" title="{{ 'loading' | i18n }}"></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
}
@if (!loading && sponsoredFamilies?.length > 0) {
<ng-container>
<bit-table>
<ng-container header>
<tr>
<th bitCell>{{ "recipient" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
<th bitCell>{{ "notes" | i18n }}</th>
<th bitCell></th>
</tr>
</ng-container>
<ng-template body alignContent="middle">
@for (o of sponsoredFamilies; let i = $index; track i) {
<ng-container>
<tr bitRow>
<td bitCell>{{ o.sponsorshipEmail }}</td>
<td bitCell class="tw-text-success">{{ o.status }}</td>
<td bitCell>{{ o.sponsorshipNote }}</td>
<td bitCell>
<button
type="button"
bitIconButton="bwi-ellipsis-v"
buttonType="main"
[bitMenuTriggerFor]="appListDropdown"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #appListDropdown>
<button
type="button"
bitMenuItem
[attr.aria-label]="'resendEmailLabel' | i18n"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<hr class="m-0" />
<button
type="button"
bitMenuItem
[attr.aria-label]="'revokeAccount' | i18n"
(click)="remove(o)"
>
<i aria-hidden="true" class="bwi bwi-close tw-text-danger"></i>
<span class="tw-text-danger pl-1">{{ "remove" | i18n }}</span>
</button>
</bit-menu>
</td>
</tr>
</ng-container>
}
</ng-template>
</bit-table>
<hr class="mt-0" />
</ng-container>
} @else {
<div class="tw-my-5 tw-py-5 tw-flex tw-flex-col tw-items-center">
<img class="tw-w-32" src="./../../../images/search.svg" alt="Search" />
<h4 class="mt-3" bitTypography="h4">{{ "noSponsoredFamilies" | i18n }}</h4>
<p bitTypography="body2">{{ "noSponsoredFamiliesDescription" | i18n }}</p>
</div>
}
</ng-container>
</bit-container>

View File

@@ -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<SponsoredFamily>();
private _destroy = new Subject<void>();
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();
}
}

View File

@@ -10,10 +10,10 @@
{{ "sponsoredFamiliesEligible" | i18n }}
</p>
<div bitTypography="body1">
{{ "sponsoredFamiliesInclude" | i18n }}:
{{ "sponsoredFamiliesIncludeMessage" | i18n }}:
<ul class="tw-list-outside">
<li>{{ "sponsoredFamiliesPremiumAccess" | i18n }}</li>
<li>{{ "sponsoredFamiliesSharedCollections" | i18n }}</li>
<li>{{ "sponsoredFamiliesSharedCollectionsMessage" | i18n }}</li>
</ul>
</div>
<form [formGroup]="sponsorshipForm" [bitSubmit]="submit" *ngIf="anyOrgsAvailable$ | async">

View File

@@ -32,7 +32,7 @@
type="button"
bitMenuItem
(click)="revokeSponsorship()"
[attr.aria-label]="'revokeAccount' | i18n: sponsoringOrg.familySponsorshipFriendlyName"
[attr.aria-label]="'revokeAccountMessage' | i18n: sponsoringOrg.familySponsorshipFriendlyName"
>
<span class="tw-text-danger">{{ "remove" | i18n }}</span>
</button>

View File

@@ -81,7 +81,7 @@
<!-- Bank Account -->
<ng-container *ngIf="showBankAccount && usingBankAccount">
<bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
{{ "verifyBankAccountWithStatementDescriptorWarning" | i18n }}
{{ bankAccountWarning }}
</bit-callout>
<div class="tw-grid tw-grid-cols-2 tw-gap-4 tw-mb-4" formGroupName="bankInformation">
<bit-form-field class="tw-col-span-1" disableMargin>

View File

@@ -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<void>;
@Input() private bankAccountWarningOverride?: string;
@Output() submitted = new EventEmitter<PaymentMethodType>();
private destroy$ = new Subject<void>();
@@ -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");
}
}
}

View File

@@ -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,

View File

@@ -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 ||

View File

@@ -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;

View File

@@ -100,7 +100,7 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ
};
}
private async getCipher(id: CipherId | null, organization: Organization): Promise<Cipher | null> {
async getCipher(id: CipherId | null, organization: Organization): Promise<Cipher | null> {
if (id == null) {
return null;
}

View File

@@ -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."
}
}

View File

@@ -1,124 +0,0 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="userAddEditTitle">
<div class="modal-dialog modal-dialog-scrollable modal-lg" role="document">
<form
class="modal-content"
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
>
<div class="modal-header">
<h2 class="modal-title" id="userAddEditTitle">
{{ title }}
<small class="text-muted" *ngIf="name">{{ name }}</small>
</h2>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
<div class="modal-body" *ngIf="!loading">
<ng-container *ngIf="!editMode">
<p>{{ "providerInviteUserDesc" | i18n }}</p>
<div class="form-group mb-4">
<label for="emails">{{ "email" | i18n }}</label>
<input
id="emails"
class="form-control"
type="text"
name="Emails"
[(ngModel)]="emails"
required
appAutoFocus
/>
<small class="text-muted">{{ "inviteMultipleEmailDesc" | i18n: "20" }}</small>
</div>
</ng-container>
<h3>
{{ "userType" | i18n }}
<a
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMore' | i18n }}"
href="https://bitwarden.com/help/provider-users/"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</h3>
<div class="form-check mt-2 form-check-block">
<input
class="form-check-input"
type="radio"
name="userType"
id="userTypeServiceUser"
[value]="userType.ServiceUser"
[(ngModel)]="type"
/>
<label class="form-check-label" for="userTypeServiceUser">
{{ "serviceUser" | i18n }}
<small>{{ "serviceUserDesc" | i18n }}</small>
</label>
</div>
<div class="form-check mt-2 form-check-block">
<input
class="form-check-input"
type="radio"
name="userType"
id="userTypeProviderAdmin"
[value]="userType.ProviderAdmin"
[(ngModel)]="type"
/>
<label class="form-check-label" for="userTypeProviderAdmin">
{{ "providerAdmin" | i18n }}
<small>{{ "providerAdminDesc" | i18n }}</small>
</label>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "cancel" | i18n }}
</button>
<div class="ml-auto">
<button
#deleteBtn
type="button"
(click)="delete()"
class="btn btn-outline-danger"
appA11yTitle="{{ 'delete' | i18n }}"
*ngIf="editMode"
[disabled]="$any(deleteBtn).loading"
[appApiAction]="deletePromise"
>
<i
class="bwi bwi-trash bwi-lg bwi-fw"
[hidden]="$any(deleteBtn).loading"
aria-hidden="true"
></i>
<i
class="bwi bwi-spinner bwi-spin bwi-lg bwi-fw"
[hidden]="!$any(deleteBtn).loading"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
</button>
</div>
</div>
</form>
</div>
</div>

View File

@@ -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<any>;
deletePromise: Promise<any>;
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);
}
}
}

View File

@@ -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,

View File

@@ -12,23 +12,50 @@
</div>
<p>{{ "setupProviderDesc" | i18n }}</p>
<form [formGroup]="formGroup" [bitSubmit]="submit">
<h2 class="tw-mt-5">{{ "generalInformation" | i18n }}</h2>
<div class="tw-grid tw-grid-flow-col tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "providerName" | i18n }}</bit-label>
<input type="text" bitInput formControlName="name" />
</bit-form-field>
@if (!(requireProviderPaymentMethodDuringSetup$ | async)) {
<h2 class="tw-mt-5">{{ "generalInformation" | i18n }}</h2>
<div class="tw-grid tw-grid-flow-col tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "providerName" | i18n }}</bit-label>
<input type="text" bitInput formControlName="name" />
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "billingEmail" | i18n }}</bit-label>
<input type="email" bitInput formControlName="billingEmail" />
<bit-hint>{{ "providerBillingEmailHint" | i18n }}</bit-hint>
</bit-form-field>
</div>
</div>
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "billingEmail" | i18n }}</bit-label>
<input type="email" bitInput formControlName="billingEmail" />
<bit-hint>{{ "providerBillingEmailHint" | i18n }}</bit-hint>
</bit-form-field>
<app-manage-tax-information />
} @else {
<h2 class="tw-mt-5">{{ "billingInformation" | i18n }}</h2>
<div class="tw-grid tw-grid-flow-col tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "providerName" | i18n }}</bit-label>
<input type="text" bitInput formControlName="name" />
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field>
<bit-label>{{ "billingEmail" | i18n }}</bit-label>
<input type="email" bitInput formControlName="billingEmail" />
<bit-hint>{{ "providerBillingEmailHint" | i18n }}</bit-hint>
</bit-form-field>
</div>
</div>
</div>
<app-manage-tax-information />
<h2 class="tw-mt-5">{{ "paymentMethod" | i18n }}</h2>
<app-payment
[showAccountCredit]="false"
[bankAccountWarningOverride]="
'verifyProviderBankAccountWithStatementDescriptorWarning' | i18n
"
/>
<app-manage-tax-information />
}
<button class="tw-mt-8" bitButton bitFormButton buttonType="primary" type="submit">
{{ "submit" | i18n }}
</button>

View File

@@ -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<void>();
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({

View File

@@ -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,

View File

@@ -6,5 +6,6 @@ export class OrganizationSponsorshipCreateRequest {
sponsoredEmail: string;
planSponsorshipType: PlanSponsorshipType;
friendlyName: string;
isAdminInitiated?: boolean;
notes?: string;
}

View File

@@ -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;
}

View File

@@ -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<ListResponse<OrganizationSponsorshipInvitesResponse>>;
}

View File

@@ -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");
}
}

View File

@@ -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<ListResponse<OrganizationSponsorshipInvitesResponse>> {
const r = await this.apiService.send(
"GET",
"/organization/sponsorship/" + sponsoredOrgId + "/sponsored",
null,
true,
true,
);
return new ListResponse(r, OrganizationSponsorshipInvitesResponse);
}
}

View File

@@ -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,

View File

@@ -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",

View File

@@ -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<Date>(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<boolean>;
abstract fullSync(forceSync: boolean, allowThrowOnError?: boolean): Promise<boolean>;
async getLastSync(): Promise<Date> {

View File

@@ -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<InternalMasterPasswordServiceAbstraction>;
let accountService: MockProxy<AccountService>;
let apiService: MockProxy<ApiService>;
let domainSettingsService: MockProxy<DomainSettingsService>;
let folderService: MockProxy<InternalFolderService>;
let cipherService: MockProxy<CipherService>;
let keyService: MockProxy<KeyService>;
let collectionService: MockProxy<CollectionService>;
let messageSender: MockProxy<MessageSender>;
let policyService: MockProxy<InternalPolicyService>;
let sendService: MockProxy<InternalSendService>;
let logService: MockProxy<LogService>;
let keyConnectorService: MockProxy<KeyConnectorService>;
let stateService: MockProxy<StateService>;
let providerService: MockProxy<ProviderService>;
let folderApiService: MockProxy<FolderApiServiceAbstraction>;
let organizationService: MockProxy<InternalOrganizationServiceAbstraction>;
let sendApiService: MockProxy<SendApiService>;
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
let avatarService: MockProxy<AvatarService>;
let logoutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason, userId?: UserId]>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
let tokenService: MockProxy<TokenService>;
let authService: MockProxy<AuthService>;
let stateProvider: MockProxy<StateProvider>;
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);
});
});
});

View File

@@ -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<boolean> {
override async fullSync(
forceSync: boolean,
allowThrowOnErrorOrOptions?: boolean | SyncOptions,
): Promise<boolean> {
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);

View File

@@ -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<boolean>;
/**
* @deprecated Use the overload taking {@link SyncOptions} instead.
*/
abstract fullSync(forceSync: boolean, allowThrowOnError?: boolean): Promise<boolean>;

View File

@@ -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, [
{

View File

@@ -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))),
);
});

View File

@@ -1 +1,2 @@
export * from "./icon-button.module";
export { BitIconButtonComponent } from "./icon-button.component";

View File

@@ -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,

View File

@@ -1,3 +1,4 @@
<vault-new-item-nudge *ngIf="!loading" [configType]="config.cipherType"> </vault-new-item-nudge>
<form [id]="formId" [formGroup]="cipherForm" [bitSubmit]="submit">
<!-- TODO: Should we show a loading spinner here? Or emit a ready event for the container to handle loading state -->
<ng-container *ngIf="!loading">

View File

@@ -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 {

View File

@@ -0,0 +1,8 @@
<ng-container *ngIf="showNewItemSpotlight">
<bit-spotlight
[title]="nudgeTitle"
[subtitle]="nudgeBody"
(onDismiss)="dismissNewItemSpotlight()"
>
</bit-spotlight>
</ng-container>

View File

@@ -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<NewItemNudgeComponent>;
let i18nService: MockProxy<I18nService>;
let accountService: MockProxy<AccountService>;
let vaultNudgesService: MockProxy<VaultNudgesService>;
beforeEach(async () => {
i18nService = mock<I18nService>({ t: (key: string) => key });
accountService = mock<AccountService>();
vaultNudgesService = mock<VaultNudgesService>();
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,
);
});
});

View File

@@ -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<boolean> {
return !(await firstValueFrom(this.vaultNudgesService.showNudge$(nudgeType, userId)))
.hasSpotlightDismissed;
}
}

View File

@@ -3,19 +3,18 @@
{{ "cardExpiredMessage" | i18n }}
</bit-callout>
<ng-container *ngIf="isSecurityTasksEnabled$ | async">
<bit-callout
*ngIf="cipher?.login.uris.length > 0 && hadPendingChangePasswordTask"
type="warning"
[title]="''"
>
<i class="bwi bwi-exclamation-triangle tw-text-warning" aria-hidden="true"></i>
<a bitLink href="#" appStopClick (click)="launchChangePassword()">
{{ "changeAtRiskPassword" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</bit-callout>
</ng-container>
<bit-callout
*ngIf="cipher?.login.uris.length > 0 && hadPendingChangePasswordTask"
type="warning"
[title]="''"
>
<i class="bwi bwi-exclamation-triangle tw-text-warning" aria-hidden="true"></i>
<a bitLink href="#" appStopClick (click)="launchChangePassword()">
{{ "changeAtRiskPassword" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</bit-callout>
<!-- HELPER TEXT -->
<p
class="tw-text-sm tw-text-muted"
@@ -40,9 +39,7 @@
*ngIf="hasLogin"
[cipher]="cipher"
[activeUserId]="activeUserId$ | async"
[hadPendingChangePasswordTask]="
hadPendingChangePasswordTask && (isSecurityTasksEnabled$ | async)
"
[hadPendingChangePasswordTask]="hadPendingChangePasswordTask"
(handleChangePassword)="launchChangePassword()"
></app-login-credentials-view>

View File

@@ -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<void> = 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<void> {
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 () => {

View File

@@ -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,
);
});
});
});

View File

@@ -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;

View File

@@ -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
];

View File

@@ -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";

View File

@@ -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<NudgeStatus> {
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;
}),
);
}
}

View File

@@ -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<EmptyVaultNudgeService>(),
},
{ provide: CipherService, useValue: mock<CipherService>() },
],
});
});

View File

@@ -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,
};
/**

2
package-lock.json generated
View File

@@ -231,7 +231,7 @@
},
"apps/desktop": {
"name": "@bitwarden/desktop",
"version": "2025.4.2",
"version": "2025.5.0",
"hasInstallScript": true,
"license": "GPL-3.0"
},