diff --git a/.github/renovate.json5 b/.github/renovate.json5
index ee97f16b0a9..91b4ac86328 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -149,6 +149,8 @@
{
matchPackageNames: [
"@angular-eslint/schematics",
+ "@typescript-eslint/rule-tester",
+ "@typescript-eslint/utils",
"angular-eslint",
"eslint-config-prettier",
"eslint-import-resolver-typescript",
@@ -313,8 +315,6 @@
"@storybook/angular",
"@storybook/manager-api",
"@storybook/theming",
- "@typescript-eslint/utils",
- "@typescript-eslint/rule-tester",
"@types/react",
"autoprefixer",
"bootstrap",
diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml
index 4ca6dc25aab..630e1e55682 100644
--- a/.github/workflows/build-web.yml
+++ b/.github/workflows/build-web.yml
@@ -196,7 +196,7 @@ jobs:
}
- name: Set up QEMU emulators
- uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
+ uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
diff --git a/LICENSE.txt b/LICENSE.txt
index 55bf3b3f736..8ad59f788b3 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -5,13 +5,13 @@ specifies another license. Bitwarden Licensed code is found only in the
/bitwarden_license directory.
GPL v3.0:
-https://github.com/bitwarden/web/blob/master/LICENSE_GPL.txt
+https://github.com/bitwarden/clients/blob/main/LICENSE_GPL.txt
Bitwarden License v1.0:
-https://github.com/bitwarden/web/blob/master/LICENSE_BITWARDEN.txt
+https://github.com/bitwarden/clients/blob/main/LICENSE_BITWARDEN.txt
No grant of any rights in the trademarks, service marks, or logos of Bitwarden is
made (except as may be necessary to comply with the notice requirements as
applicable), and use of any Bitwarden trademarks must comply with Bitwarden
Trademark Guidelines
-.
+.
diff --git a/LICENSE_BITWARDEN.txt b/LICENSE_BITWARDEN.txt
index 08e09f28639..938946a09a1 100644
--- a/LICENSE_BITWARDEN.txt
+++ b/LICENSE_BITWARDEN.txt
@@ -56,7 +56,7 @@ such Open Source Software only.
logos of any Contributor (except as may be necessary to comply with the notice
requirements in Section 2.3), and use of any Bitwarden trademarks must comply with
Bitwarden Trademark Guidelines
-.
+.
3. TERMINATION
diff --git a/apps/browser/README.md b/apps/browser/README.md
index c99d0844a09..fdeb1307567 100644
--- a/apps/browser/README.md
+++ b/apps/browser/README.md
@@ -1,4 +1,4 @@
-[](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml?query=branch:master)
+[](https://github.com/bitwarden/clients/actions/workflows/build-browser.yml?query=branch:main)
[](https://crowdin.com/project/bitwarden-browser)
[](https://gitter.im/bitwarden/Lobby)
@@ -15,7 +15,7 @@
The Bitwarden browser extension is written using the Web Extension API and Angular.
-
+
## Documentation
diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index 5c9e829e82f..6e1e2ef57ac 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -5248,5 +5248,35 @@
},
"hasItemsVaultNudgeBody": {
"message": "Autofill items for the current page\nFavorite items for easy access\nSearch your vault for something else"
+ },
+ "newLoginNudgeTitle": {
+ "message": "Save time with autofill"
+ },
+ "newLoginNudgeBody": {
+ "message": "Include a Website so this login appears as an autofill suggestion."
+ },
+ "newCardNudgeTitle": {
+ "message": "Seamless online checkout"
+ },
+ "newCardNudgeBody": {
+ "message": "With cards, easily autofill payment forms securely and accurately."
+ },
+ "newIdentityNudgeTitle": {
+ "message": "Simplify creating accounts"
+ },
+ "newIdentityNudgeBody": {
+ "message": "With identities, quickly autofill long registration or contact forms."
+ },
+ "newNoteNudgeTitle": {
+ "message": "Keep your sensitive data safe"
+ },
+ "newNoteNudgeBody": {
+ "message": "With notes, securely store sensitive data like banking or insurance details."
+ },
+ "newSshNudgeTitle": {
+ "message": "Developer-friendly SSH access"
+ },
+ "newSshNudgeBody": {
+ "message": "Store your keys and connect with the SSH agent for fast, encrypted authentication."
}
-}
\ No newline at end of file
+}
diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts
index 4e2e773a0c7..ab5dd4abb8f 100644
--- a/apps/browser/src/autofill/background/overlay.background.ts
+++ b/apps/browser/src/autofill/background/overlay.background.ts
@@ -661,20 +661,23 @@ export class OverlayBackground implements OverlayBackgroundInterface {
return this.inlineMenuFido2Credentials.has(credentialId);
}
+ /**
+ * When focused field data contains account creation field type of totp
+ * and there are totp fields in the current frame for page details return true
+ *
+ * @returns boolean
+ */
private isTotpFieldForCurrentField(): boolean {
if (!this.focusedFieldData) {
return false;
}
- const { tabId, frameId } = this.focusedFieldData;
- const pageDetailsMap = this.pageDetailsForTab[tabId];
- if (!pageDetailsMap || !pageDetailsMap.has(frameId)) {
+ const totpFields = this.getTotpFields();
+ if (!totpFields) {
return false;
}
- const pageDetail = pageDetailsMap.get(frameId);
return (
- pageDetail?.details?.fields?.every((field) =>
- this.inlineMenuFieldQualificationService.isTotpField(field),
- ) || false
+ totpFields.length > 0 &&
+ this.focusedFieldData?.accountCreationFieldType === InlineMenuAccountCreationFieldType.Totp
);
}
@@ -1399,7 +1402,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
const pageDetailsMap = this.pageDetailsForTab[currentTabId];
const pageDetails = pageDetailsMap?.get(currentFrameId);
- const fields = pageDetails.details.fields;
+ const fields = pageDetails?.details?.fields || [];
const totpFields = fields.filter((f) =>
this.inlineMenuFieldQualificationService.isTotpField(f),
);
@@ -1679,7 +1682,12 @@ export class OverlayBackground implements OverlayBackgroundInterface {
!this.focusedFieldMatchesFillType(
focusedFieldData?.inlineMenuFillType,
previousFocusedFieldData,
- )
+ ) ||
+ // a TOTP field was just focused to - or unfocused from — a non-TOTP field
+ // may want to generalize this logic if cipher inline menu types exceed [general cipher, TOTP]
+ [focusedFieldData, previousFocusedFieldData].filter(
+ (fd) => fd?.accountCreationFieldType === InlineMenuAccountCreationFieldType.Totp,
+ ).length === 1
) {
const updateAllCipherTypes = !this.focusedFieldMatchesFillType(
CipherType.Login,
diff --git a/apps/browser/src/autofill/enums/autofill-overlay.enum.ts b/apps/browser/src/autofill/enums/autofill-overlay.enum.ts
index 66ad0da546d..9cc457f3c1a 100644
--- a/apps/browser/src/autofill/enums/autofill-overlay.enum.ts
+++ b/apps/browser/src/autofill/enums/autofill-overlay.enum.ts
@@ -32,6 +32,7 @@ export const InlineMenuAccountCreationFieldType = {
Text: "text",
Email: "email",
Password: "password",
+ Totp: "totp",
} as const;
export type InlineMenuAccountCreationFieldTypes =
diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts
index 55260cc1149..dc8f45d104b 100644
--- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts
+++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts
@@ -1128,6 +1128,11 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
* @param autofillFieldData - Autofill field data captured from the form field element.
*/
private qualifyAccountCreationFieldType(autofillFieldData: AutofillField) {
+ if (this.inlineMenuFieldQualificationService.isTotpField(autofillFieldData)) {
+ autofillFieldData.accountCreationFieldType = InlineMenuAccountCreationFieldType.Totp;
+ return;
+ }
+
if (!this.inlineMenuFieldQualificationService.isUsernameField(autofillFieldData)) {
autofillFieldData.accountCreationFieldType = InlineMenuAccountCreationFieldType.Password;
return;
diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts
index 0423078fd1c..da46ceb0864 100644
--- a/apps/browser/src/autofill/services/autofill.service.ts
+++ b/apps/browser/src/autofill/services/autofill.service.ts
@@ -931,28 +931,37 @@ export default class AutofillService implements AutofillServiceInterface {
}
if (!passwordFields.length) {
- // No password fields on this page. Let's try to just fuzzy fill the username.
- pageDetails.fields.forEach((f) => {
- if (
- !options.skipUsernameOnlyFill &&
- f.viewable &&
- (f.type === "text" || f.type === "email" || f.type === "tel") &&
- AutofillService.fieldIsFuzzyMatch(f, AutoFillConstants.UsernameFieldNames)
- ) {
- usernames.push(f);
+ // If there are no passwords, username or TOTP fields may be present.
+ // username and TOTP fields are mutually exclusive
+ pageDetails.fields.forEach((field) => {
+ if (!field.viewable) {
+ return;
}
- if (
+ const isFillableTotpField =
options.allowTotpAutofill &&
- f.viewable &&
- (f.type === "text" || f.type === "number") &&
- (AutofillService.fieldIsFuzzyMatch(f, [
+ ["number", "tel", "text"].some((t) => t === field.type) &&
+ (AutofillService.fieldIsFuzzyMatch(field, [
...AutoFillConstants.TotpFieldNames,
...AutoFillConstants.AmbiguousTotpFieldNames,
]) ||
- f.autoCompleteType === "one-time-code")
- ) {
- totps.push(f);
+ field.autoCompleteType === "one-time-code");
+
+ const isFillableUsernameField =
+ !options.skipUsernameOnlyFill &&
+ ["email", "tel", "text"].some((t) => t === field.type) &&
+ AutofillService.fieldIsFuzzyMatch(field, AutoFillConstants.UsernameFieldNames);
+
+ // Prefer more uniquely keyworded fields first.
+ switch (true) {
+ case isFillableTotpField:
+ totps.push(field);
+ return;
+ case isFillableUsernameField:
+ usernames.push(field);
+ return;
+ default:
+ return;
}
});
}
@@ -2903,52 +2912,46 @@ export default class AutofillService implements AutofillServiceInterface {
/**
* Accepts a field and returns true if the field contains a
* value that matches any of the names in the provided list.
+ *
+ * Returns boolean and attr of value that was matched as a tuple if showMatch is set to true.
+ *
* @param {AutofillField} field
* @param {string[]} names
- * @returns {boolean}
+ * @param {boolean} showMatch
+ * @returns {boolean | [boolean, { attr: string; value: string }?]}
*/
- static fieldIsFuzzyMatch(field: AutofillField, names: string[]): boolean {
- if (AutofillService.hasValue(field.htmlID) && this.fuzzyMatch(names, field.htmlID)) {
- return true;
- }
- if (AutofillService.hasValue(field.htmlName) && this.fuzzyMatch(names, field.htmlName)) {
- return true;
- }
- if (
- AutofillService.hasValue(field["label-tag"]) &&
- this.fuzzyMatch(names, field["label-tag"])
- ) {
- return true;
- }
- if (AutofillService.hasValue(field.placeholder) && this.fuzzyMatch(names, field.placeholder)) {
- return true;
- }
- if (
- AutofillService.hasValue(field["label-left"]) &&
- this.fuzzyMatch(names, field["label-left"])
- ) {
- return true;
- }
- if (
- AutofillService.hasValue(field["label-top"]) &&
- this.fuzzyMatch(names, field["label-top"])
- ) {
- return true;
- }
- if (
- AutofillService.hasValue(field["label-aria"]) &&
- this.fuzzyMatch(names, field["label-aria"])
- ) {
- return true;
- }
- if (
- AutofillService.hasValue(field.dataSetValues) &&
- this.fuzzyMatch(names, field.dataSetValues)
- ) {
- return true;
- }
+ static fieldIsFuzzyMatch(
+ field: AutofillField,
+ names: string[],
+ showMatch: true,
+ ): [boolean, { attr: string; value: string }?];
+ static fieldIsFuzzyMatch(field: AutofillField, names: string[]): boolean;
+ static fieldIsFuzzyMatch(
+ field: AutofillField,
+ names: string[],
+ showMatch: boolean = false,
+ ): boolean | [boolean, { attr: string; value: string }?] {
+ const attrs = [
+ "htmlID",
+ "htmlName",
+ "label-tag",
+ "placeholder",
+ "label-left",
+ "label-top",
+ "label-aria",
+ "dataSetValues",
+ ];
- return false;
+ for (const attr of attrs) {
+ const value = field[attr];
+ if (!AutofillService.hasValue(value)) {
+ continue;
+ }
+ if (this.fuzzyMatch(names, value)) {
+ return showMatch ? [true, { attr, value }] : true;
+ }
+ }
+ return showMatch ? [false] : false;
}
/**
diff --git a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts
index f5daff93815..34ee4fa0f77 100644
--- a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts
+++ b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts
@@ -8,6 +8,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils";
+import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service";
import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
@@ -80,7 +81,72 @@ describe("ForegroundSyncService", () => {
const fullSyncPromise = sut.fullSync(true, false);
expect(sut.syncInProgress).toBe(true);
- const requestId = getAndAssertRequestId({ forceSync: true, allowThrowOnError: false });
+ const requestId = getAndAssertRequestId({
+ forceSync: true,
+ options: { allowThrowOnError: false, skipTokenRefresh: false },
+ });
+
+ // Pretend the sync has finished
+ messages.next({ successfully: true, errorMessage: null, requestId: requestId });
+
+ const result = await fullSyncPromise;
+
+ expect(sut.syncInProgress).toBe(false);
+ expect(result).toBe(true);
+ });
+
+ const testData: {
+ input: boolean | SyncOptions | undefined;
+ normalized: Required;
+ }[] = [
+ {
+ input: undefined,
+ normalized: { allowThrowOnError: false, skipTokenRefresh: false },
+ },
+ {
+ input: true,
+ normalized: { allowThrowOnError: true, skipTokenRefresh: false },
+ },
+ {
+ input: false,
+ normalized: { allowThrowOnError: false, skipTokenRefresh: false },
+ },
+ {
+ input: { allowThrowOnError: false },
+ normalized: { allowThrowOnError: false, skipTokenRefresh: false },
+ },
+ {
+ input: { allowThrowOnError: true },
+ normalized: { allowThrowOnError: true, skipTokenRefresh: false },
+ },
+ {
+ input: { allowThrowOnError: false, skipTokenRefresh: false },
+ normalized: { allowThrowOnError: false, skipTokenRefresh: false },
+ },
+ {
+ input: { allowThrowOnError: true, skipTokenRefresh: false },
+ normalized: { allowThrowOnError: true, skipTokenRefresh: false },
+ },
+ {
+ input: { allowThrowOnError: true, skipTokenRefresh: true },
+ normalized: { allowThrowOnError: true, skipTokenRefresh: true },
+ },
+ {
+ input: { allowThrowOnError: false, skipTokenRefresh: true },
+ normalized: { allowThrowOnError: false, skipTokenRefresh: true },
+ },
+ ];
+
+ it.each(testData)("normalize input $input options correctly", async ({ input, normalized }) => {
+ const messages = new Subject();
+ messageListener.messages$.mockReturnValue(messages);
+ const fullSyncPromise = sut.fullSync(true, input);
+ expect(sut.syncInProgress).toBe(true);
+
+ const requestId = getAndAssertRequestId({
+ forceSync: true,
+ options: normalized,
+ });
// Pretend the sync has finished
messages.next({ successfully: true, errorMessage: null, requestId: requestId });
@@ -97,7 +163,10 @@ describe("ForegroundSyncService", () => {
const fullSyncPromise = sut.fullSync(false, false);
expect(sut.syncInProgress).toBe(true);
- const requestId = getAndAssertRequestId({ forceSync: false, allowThrowOnError: false });
+ const requestId = getAndAssertRequestId({
+ forceSync: false,
+ options: { allowThrowOnError: false, skipTokenRefresh: false },
+ });
// Pretend the sync has finished
messages.next({
@@ -118,7 +187,10 @@ describe("ForegroundSyncService", () => {
const fullSyncPromise = sut.fullSync(true, true);
expect(sut.syncInProgress).toBe(true);
- const requestId = getAndAssertRequestId({ forceSync: true, allowThrowOnError: true });
+ const requestId = getAndAssertRequestId({
+ forceSync: true,
+ options: { allowThrowOnError: true, skipTokenRefresh: false },
+ });
// Pretend the sync has finished
messages.next({
diff --git a/apps/browser/src/platform/sync/foreground-sync.service.ts b/apps/browser/src/platform/sync/foreground-sync.service.ts
index a6ed7281851..ce776f53685 100644
--- a/apps/browser/src/platform/sync/foreground-sync.service.ts
+++ b/apps/browser/src/platform/sync/foreground-sync.service.ts
@@ -14,6 +14,7 @@ import {
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
import { CoreSyncService } from "@bitwarden/common/platform/sync/internal";
+import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -22,7 +23,7 @@ import { InternalFolderService } from "@bitwarden/common/vault/abstractions/fold
import { FULL_SYNC_FINISHED } from "./sync-service.listener";
-export type FullSyncMessage = { forceSync: boolean; allowThrowOnError: boolean; requestId: string };
+export type FullSyncMessage = { forceSync: boolean; options: SyncOptions; requestId: string };
export const DO_FULL_SYNC = new CommandDefinition("doFullSync");
@@ -60,9 +61,20 @@ export class ForegroundSyncService extends CoreSyncService {
);
}
- async fullSync(forceSync: boolean, allowThrowOnError: boolean = false): Promise {
+ async fullSync(
+ forceSync: boolean,
+ allowThrowOnErrorOrOptions?: boolean | SyncOptions,
+ ): Promise {
this.syncInProgress = true;
try {
+ // Normalize options
+ const options =
+ typeof allowThrowOnErrorOrOptions === "boolean"
+ ? { allowThrowOnError: allowThrowOnErrorOrOptions, skipTokenRefresh: false }
+ : {
+ allowThrowOnError: allowThrowOnErrorOrOptions?.allowThrowOnError ?? false,
+ skipTokenRefresh: allowThrowOnErrorOrOptions?.skipTokenRefresh ?? false,
+ };
const requestId = Utils.newGuid();
const syncCompletedPromise = firstValueFrom(
this.messageListener.messages$(FULL_SYNC_FINISHED).pipe(
@@ -79,10 +91,10 @@ export class ForegroundSyncService extends CoreSyncService {
}),
),
);
- this.messageSender.send(DO_FULL_SYNC, { forceSync, allowThrowOnError, requestId });
+ this.messageSender.send(DO_FULL_SYNC, { forceSync, options, requestId });
const result = await syncCompletedPromise;
- if (allowThrowOnError && result.errorMessage != null) {
+ if (options.allowThrowOnError && result.errorMessage != null) {
throw new Error(result.errorMessage);
}
diff --git a/apps/browser/src/platform/sync/sync-service.listener.spec.ts b/apps/browser/src/platform/sync/sync-service.listener.spec.ts
index 51f97e9f879..9682e2cdb57 100644
--- a/apps/browser/src/platform/sync/sync-service.listener.spec.ts
+++ b/apps/browser/src/platform/sync/sync-service.listener.spec.ts
@@ -27,11 +27,18 @@ describe("SyncServiceListener", () => {
const emissionPromise = firstValueFrom(listener);
syncService.fullSync.mockResolvedValueOnce(value);
- messages.next({ forceSync: true, allowThrowOnError: false, requestId: "1" });
+ messages.next({
+ forceSync: true,
+ options: { allowThrowOnError: false, skipTokenRefresh: false },
+ requestId: "1",
+ });
await emissionPromise;
- expect(syncService.fullSync).toHaveBeenCalledWith(true, false);
+ expect(syncService.fullSync).toHaveBeenCalledWith(true, {
+ allowThrowOnError: false,
+ skipTokenRefresh: false,
+ });
expect(messageSender.send).toHaveBeenCalledWith(FULL_SYNC_FINISHED, {
successfully: value,
errorMessage: null,
@@ -45,11 +52,18 @@ describe("SyncServiceListener", () => {
const emissionPromise = firstValueFrom(listener);
syncService.fullSync.mockRejectedValueOnce(new Error("SyncError"));
- messages.next({ forceSync: true, allowThrowOnError: false, requestId: "1" });
+ messages.next({
+ forceSync: true,
+ options: { allowThrowOnError: false, skipTokenRefresh: false },
+ requestId: "1",
+ });
await emissionPromise;
- expect(syncService.fullSync).toHaveBeenCalledWith(true, false);
+ expect(syncService.fullSync).toHaveBeenCalledWith(true, {
+ allowThrowOnError: false,
+ skipTokenRefresh: false,
+ });
expect(messageSender.send).toHaveBeenCalledWith(FULL_SYNC_FINISHED, {
successfully: false,
errorMessage: "SyncError",
diff --git a/apps/browser/src/platform/sync/sync-service.listener.ts b/apps/browser/src/platform/sync/sync-service.listener.ts
index b7171528648..4274eafcf6a 100644
--- a/apps/browser/src/platform/sync/sync-service.listener.ts
+++ b/apps/browser/src/platform/sync/sync-service.listener.ts
@@ -9,6 +9,7 @@ import {
MessageSender,
isExternalMessage,
} from "@bitwarden/common/platform/messaging";
+import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DO_FULL_SYNC } from "./foreground-sync.service";
@@ -34,15 +35,15 @@ export class SyncServiceListener {
listener$(): Observable {
return this.messageListener.messages$(DO_FULL_SYNC).pipe(
filter((message) => isExternalMessage(message)),
- concatMap(async ({ forceSync, allowThrowOnError, requestId }) => {
- await this.doFullSync(forceSync, allowThrowOnError, requestId);
+ concatMap(async ({ forceSync, options, requestId }) => {
+ await this.doFullSync(forceSync, options, requestId);
}),
);
}
- private async doFullSync(forceSync: boolean, allowThrowOnError: boolean, requestId: string) {
+ private async doFullSync(forceSync: boolean, options: SyncOptions, requestId: string) {
try {
- const result = await this.syncService.fullSync(forceSync, allowThrowOnError);
+ const result = await this.syncService.fullSync(forceSync, options);
this.messageSender.send(FULL_SYNC_FINISHED, {
successfully: result,
errorMessage: null,
diff --git a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts
index fa4137d9849..ed78d9433f1 100644
--- a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts
+++ b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts
@@ -1,7 +1,7 @@
import { CommonModule } from "@angular/common";
import { Component, inject } from "@angular/core";
import { RouterModule } from "@angular/router";
-import { map, of, switchMap } from "rxjs";
+import { map, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
@@ -20,21 +20,7 @@ export class AtRiskPasswordCalloutComponent {
private activeAccount$ = inject(AccountService).activeAccount$.pipe(getUserId);
protected pendingTasks$ = this.activeAccount$.pipe(
- switchMap((userId) =>
- this.taskService.tasksEnabled$(userId).pipe(
- switchMap((enabled) => {
- if (!enabled) {
- return of([]);
- }
- return this.taskService
- .pendingTasks$(userId)
- .pipe(
- map((tasks) =>
- tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential),
- ),
- );
- }),
- ),
- ),
+ switchMap((userId) => this.taskService.pendingTasks$(userId)),
+ map((tasks) => tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential)),
);
}
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html
index 7d04f23795e..894f27245b2 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html
+++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html
@@ -15,12 +15,11 @@
{{ "yourVaultIsEmpty" | i18n }}
- {{ "emptyVaultDescription" | i18n }}
+ {{ "emptyVaultDescription" | i18n }}
-
+
+ {{ "newLogin" | i18n }}
+
diff --git a/apps/cli/README.md b/apps/cli/README.md
index d39c0e39c8f..2b13270cdba 100644
--- a/apps/cli/README.md
+++ b/apps/cli/README.md
@@ -1,4 +1,4 @@
-[](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml?query=branch:master)
+[](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml?query=branch:main)
[](https://gitter.im/bitwarden/Lobby)
# Bitwarden Command-line Interface
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 6578699369b..ee13f451641 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -1,4 +1,4 @@
-[](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml?query=branch:master)
+[](https://github.com/bitwarden/clients/actions/workflows/build-desktop.yml?query=branch:main)
[](https://crowdin.com/project/bitwarden-desktop)
[](https://gitter.im/bitwarden/Lobby)
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index aaca0f56b1a..f392e42ab56 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@bitwarden/desktop",
"description": "A secure and free password manager for all of your devices.",
- "version": "2025.4.2",
+ "version": "2025.5.0",
"keywords": [
"bitwarden",
"password",
diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json
index 81e3a94ff4d..2350e0df4c7 100644
--- a/apps/desktop/src/locales/en/messages.json
+++ b/apps/desktop/src/locales/en/messages.json
@@ -3712,5 +3712,35 @@
},
"move": {
"message": "Move"
+ },
+ "newLoginNudgeTitle": {
+ "message": "Save time with autofill"
+ },
+ "newLoginNudgeBody": {
+ "message": "Include a Website so this login appears as an autofill suggestion."
+ },
+ "newCardNudgeTitle": {
+ "message": "Seamless online checkout"
+ },
+ "newCardNudgeBody": {
+ "message": "With cards, easily autofill payment forms securely and accurately."
+ },
+ "newIdentityNudgeTitle": {
+ "message": "Simplify creating accounts"
+ },
+ "newIdentityNudgeBody": {
+ "message": "With identities, quickly autofill long registration or contact forms."
+ },
+ "newNoteNudgeTitle": {
+ "message": "Keep your sensitive data safe"
+ },
+ "newNoteNudgeBody": {
+ "message": "With notes, securely store sensitive data like banking or insurance details."
+ },
+ "newSshNudgeTitle": {
+ "message": "Developer-friendly SSH access"
+ },
+ "newSshNudgeBody": {
+ "message": "Store your keys and connect with the SSH agent for fast, encrypted authentication."
}
}
diff --git a/apps/desktop/src/main/menu/menu.file.ts b/apps/desktop/src/main/menu/menu.file.ts
index 712e579515e..25db5b695e7 100644
--- a/apps/desktop/src/main/menu/menu.file.ts
+++ b/apps/desktop/src/main/menu/menu.file.ts
@@ -103,6 +103,12 @@ export class FileMenu extends FirstMenu implements IMenubarMenu {
click: () => this.sendMessage("newSecureNote"),
accelerator: "CmdOrCtrl+Shift+S",
},
+ {
+ id: "typeSshKey",
+ label: this.localize("typeSshKey"),
+ click: () => this.sendMessage("newSshKey"),
+ accelerator: "CmdOrCtrl+Shift+K",
+ },
];
}
diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json
index f6449bd9626..b3a33dc75e3 100644
--- a/apps/desktop/src/package-lock.json
+++ b/apps/desktop/src/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@bitwarden/desktop",
- "version": "2025.4.2",
+ "version": "2025.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@bitwarden/desktop",
- "version": "2025.4.2",
+ "version": "2025.5.0",
"license": "GPL-3.0",
"dependencies": {
"@bitwarden/desktop-napi": "file:../desktop_native/napi"
diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json
index 45a6f6b90af..c180ed8c744 100644
--- a/apps/desktop/src/package.json
+++ b/apps/desktop/src/package.json
@@ -2,7 +2,7 @@
"name": "@bitwarden/desktop",
"productName": "Bitwarden",
"description": "A secure and free password manager for all of your devices.",
- "version": "2025.4.2",
+ "version": "2025.5.0",
"author": "Bitwarden Inc. (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"license": "GPL-3.0",
diff --git a/apps/desktop/src/vault/app/vault/vault-items-v2.component.html b/apps/desktop/src/vault/app/vault/vault-items-v2.component.html
index ff35e00fb0f..63e648e3cf3 100644
--- a/apps/desktop/src/vault/app/vault/vault-items-v2.component.html
+++ b/apps/desktop/src/vault/app/vault/vault-items-v2.component.html
@@ -88,5 +88,9 @@
{{ "typeSecureNote" | i18n }}
+
diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.html b/apps/desktop/src/vault/app/vault/vault-v2.component.html
index 12f52502984..00e225f41d1 100644
--- a/apps/desktop/src/vault/app/vault/vault-v2.component.html
+++ b/apps/desktop/src/vault/app/vault/vault-v2.component.html
@@ -6,7 +6,6 @@
(onCipherClicked)="viewCipher($event)"
(onCipherRightClicked)="viewCipherMenu($event)"
(onAddCipher)="addCipher($event)"
- (onAddCipherOptions)="addCipherOptions()"
>
diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts
index 7e799899418..05c6c5e261e 100644
--- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts
+++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts
@@ -208,6 +208,9 @@ export class VaultV2Component implements OnInit, OnDestroy {
case "newSecureNote":
await this.addCipher(CipherType.SecureNote).catch(() => {});
break;
+ case "newSshKey":
+ await this.addCipher(CipherType.SshKey).catch(() => {});
+ break;
case "focusSearch":
(document.querySelector("#search") as HTMLInputElement)?.select();
detectChanges = false;
@@ -531,28 +534,14 @@ export class VaultV2Component implements OnInit, OnDestroy {
this.action = "add";
this.prefillCipherFromFilter();
await this.go().catch(() => {});
- }
- addCipherOptions() {
- const menu: RendererMenuItem[] = [
- {
- label: this.i18nService.t("typeLogin"),
- click: () => this.addCipherWithChangeDetection(CipherType.Login),
- },
- {
- label: this.i18nService.t("typeCard"),
- click: () => this.addCipherWithChangeDetection(CipherType.Card),
- },
- {
- label: this.i18nService.t("typeIdentity"),
- click: () => this.addCipherWithChangeDetection(CipherType.Identity),
- },
- {
- label: this.i18nService.t("typeSecureNote"),
- click: () => this.addCipherWithChangeDetection(CipherType.SecureNote),
- },
- ];
- invokeMenu(menu);
+ if (type === CipherType.SshKey) {
+ this.toastService.showToast({
+ variant: "success",
+ title: "",
+ message: this.i18nService.t("sshKeyGenerated"),
+ });
+ }
}
async savedCipher(cipher: CipherView) {
diff --git a/apps/desktop/src/vault/app/vault/vault.component.ts b/apps/desktop/src/vault/app/vault/vault.component.ts
index a21a285a428..6c0d5ef81d0 100644
--- a/apps/desktop/src/vault/app/vault/vault.component.ts
+++ b/apps/desktop/src/vault/app/vault/vault.component.ts
@@ -151,6 +151,9 @@ export class VaultComponent implements OnInit, OnDestroy {
case "newSecureNote":
await this.addCipher(CipherType.SecureNote);
break;
+ case "newSshKey":
+ await this.addCipher(CipherType.SshKey);
+ break;
case "focusSearch":
(document.querySelector("#search") as HTMLInputElement).select();
detectChanges = false;
@@ -470,6 +473,14 @@ export class VaultComponent implements OnInit, OnDestroy {
this.cipherId = null;
this.prefillNewCipherFromFilter();
this.go();
+
+ if (type === CipherType.SshKey) {
+ this.toastService.showToast({
+ variant: "success",
+ title: "",
+ message: this.i18nService.t("sshKeyGenerated"),
+ });
+ }
}
addCipherOptions() {
diff --git a/apps/web/README.md b/apps/web/README.md
index f43a9dc1614..c5e03eebb59 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -1,12 +1,12 @@
-
+
The Bitwarden web project is an Angular application that powers the web vault (https://vault.bitwarden.com/).
-
-
+
+
diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts
index 3ffb54f5b1c..b341fc4f8e4 100644
--- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts
+++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts
@@ -8,14 +8,16 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
-import { UserId } from "@bitwarden/common/types/guid";
+import { UserId, EmergencyAccessId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
+import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { DialogService, DialogRef, DIALOG_DATA } from "@bitwarden/components";
import { ChangeLoginPasswordService } from "@bitwarden/vault";
@@ -28,14 +30,15 @@ describe("EmergencyViewDialogComponent", () => {
const open = jest.fn();
const close = jest.fn();
+ const emergencyAccessId = "emergency-access-id" as EmergencyAccessId;
const mockCipher = {
id: "cipher1",
name: "Cipher",
type: CipherType.Login,
- login: { uris: [] },
+ login: { uris: [] } as Partial,
card: {},
- } as CipherView;
+ } as Partial as CipherView;
const accountService: FakeAccountService = mockAccountServiceWith(Utils.newGuid() as UserId);
@@ -56,6 +59,7 @@ describe("EmergencyViewDialogComponent", () => {
{ provide: DIALOG_DATA, useValue: { cipher: mockCipher } },
{ provide: AccountService, useValue: accountService },
{ provide: TaskService, useValue: mock() },
+ { provide: LogService, useValue: mock() },
],
})
.overrideComponent(EmergencyViewDialogComponent, {
@@ -94,18 +98,24 @@ describe("EmergencyViewDialogComponent", () => {
});
it("opens dialog", () => {
- EmergencyViewDialogComponent.open({ open } as unknown as DialogService, { cipher: mockCipher });
+ EmergencyViewDialogComponent.open({ open } as unknown as DialogService, {
+ cipher: mockCipher,
+ emergencyAccessId,
+ });
expect(open).toHaveBeenCalled();
});
it("closes the dialog", () => {
- EmergencyViewDialogComponent.open({ open } as unknown as DialogService, { cipher: mockCipher });
+ EmergencyViewDialogComponent.open({ open } as unknown as DialogService, {
+ cipher: mockCipher,
+ emergencyAccessId,
+ });
fixture.detectChanges();
const cancelButton = fixture.debugElement.queryAll(By.css("button")).pop();
- cancelButton.nativeElement.click();
+ cancelButton!.nativeElement.click();
expect(close).toHaveBeenCalled();
});
diff --git a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html
index 2dbcc577e54..405211d6ecb 100644
--- a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html
+++ b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html
@@ -34,7 +34,15 @@
-
+
diff --git a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts
index af43e5a4bc1..c141eaebd78 100644
--- a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts
+++ b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts
@@ -1,62 +1,259 @@
import { DialogRef } from "@angular/cdk/dialog";
-import { Component, OnInit } from "@angular/core";
-import { Router } from "@angular/router";
-import { firstValueFrom } from "rxjs";
+import { formatDate } from "@angular/common";
+import { Component, OnInit, signal } from "@angular/core";
+import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
+import { ActivatedRoute } from "@angular/router";
+import { firstValueFrom, map, Observable, switchMap } from "rxjs";
-import { DialogService } from "@bitwarden/components";
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
+import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
+import { OrganizationSponsorshipInvitesResponse } from "@bitwarden/common/billing/models/response/organization-sponsorship-invites.response";
+import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
+import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
+import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
+import { StateProvider } from "@bitwarden/common/platform/state";
+import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
+import { OrgKey } from "@bitwarden/common/types/key";
+import { DialogService, ToastService } from "@bitwarden/components";
+import { KeyService } from "@bitwarden/key-management";
-import { FreeFamiliesPolicyService } from "../services/free-families-policy.service";
-
-import {
- AddSponsorshipDialogComponent,
- AddSponsorshipDialogResult,
-} from "./add-sponsorship-dialog.component";
-import { SponsoredFamily } from "./types/sponsored-family";
+import { AddSponsorshipDialogComponent } from "./add-sponsorship-dialog.component";
@Component({
selector: "app-free-bitwarden-families",
templateUrl: "free-bitwarden-families.component.html",
})
export class FreeBitwardenFamiliesComponent implements OnInit {
+ loading = signal(true);
tabIndex = 0;
- sponsoredFamilies: SponsoredFamily[] = [];
+ sponsoredFamilies: OrganizationSponsorshipInvitesResponse[] = [];
+
+ organizationId = "";
+ organizationKey$: Observable;
+
+ private locale: string = "";
constructor(
- private router: Router,
+ private route: ActivatedRoute,
private dialogService: DialogService,
- private freeFamiliesPolicyService: FreeFamiliesPolicyService,
- ) {}
+ private apiService: ApiService,
+ private encryptService: EncryptService,
+ private keyService: KeyService,
+ private platformUtilsService: PlatformUtilsService,
+ private i18nService: I18nService,
+ private logService: LogService,
+ private toastService: ToastService,
+ private organizationSponsorshipApiService: OrganizationSponsorshipApiServiceAbstraction,
+ private stateProvider: StateProvider,
+ ) {
+ this.organizationId = this.route.snapshot.params.organizationId || "";
+ this.organizationKey$ = this.stateProvider.activeUserId$.pipe(
+ switchMap(
+ (userId) =>
+ this.keyService.orgKeys$(userId as UserId) as Observable>,
+ ),
+ map((organizationKeysById) => organizationKeysById[this.organizationId as OrganizationId]),
+ takeUntilDestroyed(),
+ );
+ }
async ngOnInit() {
- await this.preventAccessToFreeFamiliesPage();
+ this.locale = await firstValueFrom(this.i18nService.locale$);
+ await this.loadSponsorships();
+
+ this.loading.set(false);
+ }
+
+ async loadSponsorships() {
+ if (!this.organizationId) {
+ return;
+ }
+
+ const [response, orgKey] = await Promise.all([
+ this.organizationSponsorshipApiService.getOrganizationSponsorship(this.organizationId),
+ firstValueFrom(this.organizationKey$),
+ ]);
+
+ if (!orgKey) {
+ this.logService.error("Organization key not found");
+ return;
+ }
+
+ const organizationFamilies = response.data;
+
+ this.sponsoredFamilies = await Promise.all(
+ organizationFamilies.map(async (family) => {
+ let decryptedNote = "";
+ try {
+ decryptedNote = await this.encryptService.decryptString(
+ new EncString(family.notes),
+ orgKey,
+ );
+ } catch (e) {
+ this.logService.error(e);
+ }
+
+ const { statusMessage, statusClass } = this.getStatus(
+ this.isSelfHosted,
+ family.toDelete,
+ family.validUntil,
+ family.lastSyncDate,
+ this.locale,
+ );
+
+ const newFamily = {
+ ...family,
+ notes: decryptedNote,
+ statusMessage: statusMessage || "",
+ statusClass: statusClass || "tw-text-success",
+ status: statusMessage || "",
+ };
+
+ return new OrganizationSponsorshipInvitesResponse(newFamily);
+ }),
+ );
}
async addSponsorship() {
- const addSponsorshipDialogRef: DialogRef =
- AddSponsorshipDialogComponent.open(this.dialogService);
+ const addSponsorshipDialogRef: DialogRef = AddSponsorshipDialogComponent.open(
+ this.dialogService,
+ {
+ data: {
+ organizationId: this.organizationId,
+ organizationKey: await firstValueFrom(this.organizationKey$),
+ },
+ },
+ );
- const dialogRef = await firstValueFrom(addSponsorshipDialogRef.closed);
+ await firstValueFrom(addSponsorshipDialogRef.closed);
- if (dialogRef?.value) {
- this.sponsoredFamilies = [dialogRef.value, ...this.sponsoredFamilies];
+ await this.loadSponsorships();
+ }
+
+ async removeSponsorship(sponsorship: OrganizationSponsorshipInvitesResponse) {
+ try {
+ await this.doRevokeSponsorship(sponsorship);
+ } catch (e) {
+ this.logService.error(e);
}
}
- removeSponsorhip(sponsorship: any) {
- const index = this.sponsoredFamilies.findIndex(
- (e) => e.sponsorshipEmail == sponsorship.sponsorshipEmail,
- );
- this.sponsoredFamilies.splice(index, 1);
+ get isSelfHosted(): boolean {
+ return this.platformUtilsService.isSelfHost();
}
- private async preventAccessToFreeFamiliesPage() {
- const showFreeFamiliesPage = await firstValueFrom(
- this.freeFamiliesPolicyService.showFreeFamilies$,
- );
+ async resendEmail(sponsorship: OrganizationSponsorshipInvitesResponse) {
+ await this.apiService.postResendSponsorshipOffer(sponsorship.sponsoringOrganizationUserId);
+ this.toastService.showToast({
+ variant: "success",
+ title: undefined,
+ message: this.i18nService.t("emailSent"),
+ });
+ }
- if (!showFreeFamiliesPage) {
- await this.router.navigate(["/"]);
+ private async doRevokeSponsorship(sponsorship: OrganizationSponsorshipInvitesResponse) {
+ const content = sponsorship.validUntil
+ ? this.i18nService.t(
+ "updatedRevokeSponsorshipConfirmationForAcceptedSponsorship",
+ sponsorship.friendlyName,
+ formatDate(sponsorship.validUntil, "MM/dd/yyyy", this.locale),
+ )
+ : this.i18nService.t(
+ "updatedRevokeSponsorshipConfirmationForSentSponsorship",
+ sponsorship.friendlyName,
+ );
+
+ const confirmed = await this.dialogService.openSimpleDialog({
+ title: `${this.i18nService.t("removeSponsorship")}?`,
+ content,
+ acceptButtonText: { key: "remove" },
+ type: "warning",
+ });
+
+ if (!confirmed) {
return;
}
+
+ await this.apiService.deleteRevokeSponsorship(sponsorship.sponsoringOrganizationUserId);
+
+ this.toastService.showToast({
+ variant: "success",
+ title: undefined,
+ message: this.i18nService.t("reclaimedFreePlan"),
+ });
+
+ await this.loadSponsorships();
+ }
+
+ private getStatus(
+ selfHosted: boolean,
+ toDelete?: boolean,
+ validUntil?: Date,
+ lastSyncDate?: Date,
+ locale: string = "",
+ ): { statusMessage: string; statusClass: "tw-text-success" | "tw-text-danger" } {
+ /*
+ * Possible Statuses:
+ * Requested (self-hosted only)
+ * Sent
+ * Active
+ * RequestRevoke
+ * RevokeWhenExpired
+ */
+
+ if (toDelete && validUntil) {
+ // They want to delete but there is a valid until date which means there is an active sponsorship
+ return {
+ statusMessage: this.i18nService.t(
+ "revokeWhenExpired",
+ formatDate(validUntil, "MM/dd/yyyy", locale),
+ ),
+ statusClass: "tw-text-danger",
+ };
+ }
+
+ if (toDelete) {
+ // They want to delete and we don't have a valid until date so we can
+ // this should only happen on a self-hosted install
+ return {
+ statusMessage: this.i18nService.t("requestRemoved"),
+ statusClass: "tw-text-danger",
+ };
+ }
+
+ if (validUntil) {
+ // They don't want to delete and they have a valid until date
+ // that means they are actively sponsoring someone
+ return {
+ statusMessage: this.i18nService.t("active"),
+ statusClass: "tw-text-success",
+ };
+ }
+
+ if (selfHosted && lastSyncDate) {
+ // We are on a self-hosted install and it has been synced but we have not gotten
+ // a valid until date so we can't know if they are actively sponsoring someone
+ return {
+ statusMessage: this.i18nService.t("sent"),
+ statusClass: "tw-text-success",
+ };
+ }
+
+ if (!selfHosted) {
+ // We are in cloud and all other status checks have been false therefore we have
+ // sent the request but it hasn't been accepted yet
+ return {
+ statusMessage: this.i18nService.t("sent"),
+ statusClass: "tw-text-success",
+ };
+ }
+
+ // We are on a self-hosted install and we have not synced yet
+ return {
+ statusMessage: this.i18nService.t("requested"),
+ statusClass: "tw-text-success",
+ };
}
}
diff --git a/apps/web/src/app/billing/members/organization-member-families.component.html b/apps/web/src/app/billing/members/organization-member-families.component.html
deleted file mode 100644
index c5b7283d9d9..00000000000
--- a/apps/web/src/app/billing/members/organization-member-families.component.html
+++ /dev/null
@@ -1,47 +0,0 @@
-
-
-
- {{ "membersWithSponsoredFamilies" | i18n }}
-
-
- {{ "memberFamilies" | i18n }}
-
- @if (loading) {
-
-
- {{ "loading" | i18n }}
-
- }
-
- @if (!loading && memberFamilies?.length > 0) {
-
-
-
-
- | {{ "member" | i18n }} |
- {{ "status" | i18n }} |
- |
-
-
-
- @for (o of memberFamilies; let i = $index; track i) {
-
-
- | {{ o.sponsorshipEmail }} |
- {{ o.status }} |
-
-
- }
-
-
-
-
- } @else {
-
-

-
{{ "noMemberFamilies" | i18n }}
-
{{ "noMemberFamiliesDescription" | i18n }}
-
- }
-
-
diff --git a/apps/web/src/app/billing/members/organization-member-families.component.ts b/apps/web/src/app/billing/members/organization-member-families.component.ts
deleted file mode 100644
index 52c95646a11..00000000000
--- a/apps/web/src/app/billing/members/organization-member-families.component.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { Component, Input, OnDestroy, OnInit } from "@angular/core";
-import { Subject } from "rxjs";
-
-import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
-
-import { SponsoredFamily } from "./types/sponsored-family";
-
-@Component({
- selector: "app-organization-member-families",
- templateUrl: "organization-member-families.component.html",
-})
-export class OrganizationMemberFamiliesComponent implements OnInit, OnDestroy {
- tabIndex = 0;
- loading = false;
-
- @Input() memberFamilies: SponsoredFamily[] = [];
-
- private _destroy = new Subject();
-
- constructor(private platformUtilsService: PlatformUtilsService) {}
-
- async ngOnInit() {
- this.loading = false;
- }
-
- ngOnDestroy(): void {
- this._destroy.next();
- this._destroy.complete();
- }
-
- get isSelfHosted(): boolean {
- return this.platformUtilsService.isSelfHost();
- }
-}
diff --git a/apps/web/src/app/billing/members/organization-sponsored-families.component.html b/apps/web/src/app/billing/members/organization-sponsored-families.component.html
deleted file mode 100644
index 7db96deb4ab..00000000000
--- a/apps/web/src/app/billing/members/organization-sponsored-families.component.html
+++ /dev/null
@@ -1,87 +0,0 @@
-
-
-
- {{ "sponsorFreeBitwardenFamilies" | i18n }}
-
-
- {{ "sponsoredFamiliesInclude" | i18n }}:
-
- - {{ "sponsoredFamiliesPremiumAccess" | i18n }}
- - {{ "sponsoredFamiliesSharedCollections" | i18n }}
-
-
-
- {{ "sponsoredBitwardenFamilies" | i18n }}
-
- @if (loading) {
-
-
- {{ "loading" | i18n }}
-
- }
-
- @if (!loading && sponsoredFamilies?.length > 0) {
-
-
-
-
- | {{ "recipient" | i18n }} |
- {{ "status" | i18n }} |
- {{ "notes" | i18n }} |
- |
-
-
-
- @for (o of sponsoredFamilies; let i = $index; track i) {
-
-
- | {{ o.sponsorshipEmail }} |
- {{ o.status }} |
- {{ o.sponsorshipNote }} |
-
-
-
-
-
- {{ "resendInvitation" | i18n }}
-
-
-
-
-
-
- {{ "remove" | i18n }}
-
-
- |
-
-
- }
-
-
-
-
- } @else {
-
-

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