1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-18 18:33:50 +00:00

Merge branch 'main' of github.com:bitwarden/clients into feature/PM-30737-Migrate-DeleteAccount

This commit is contained in:
Isaac Ivins
2026-02-03 15:50:22 -05:00
37 changed files with 1205 additions and 156 deletions

View File

@@ -3035,10 +3035,6 @@
"custom": {
"message": "Custom"
},
"sendPasswordDescV3": {
"message": "Add an optional password for recipients to access this Send.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"createSend": {
"message": "New Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
@@ -6144,5 +6140,9 @@
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
},
"sendPasswordHelperText": {
"message": "Individuals will need to enter the password to view this Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
}
}

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -28,7 +26,7 @@ export default class WebRequestBackground {
this.webRequest.onAuthRequired.addListener(
(async (
details: chrome.webRequest.OnAuthRequiredDetails,
callback: (response: chrome.webRequest.BlockingResponse) => void,
callback: (response: chrome.webRequest.BlockingResponse | null) => void,
) => {
if (!details.url || this.pendingAuthRequests.has(details.requestId)) {
if (callback) {
@@ -51,16 +49,16 @@ export default class WebRequestBackground {
);
this.webRequest.onCompleted.addListener((details) => this.completeAuthRequest(details), {
urls: ["http://*/*"],
urls: ["http://*/*", "https://*/*"],
});
this.webRequest.onErrorOccurred.addListener((details) => this.completeAuthRequest(details), {
urls: ["http://*/*"],
urls: ["http://*/*", "https://*/*"],
});
}
private async resolveAuthCredentials(
domain: string,
success: (response: chrome.webRequest.BlockingResponse) => void,
success: (response: chrome.webRequest.BlockingResponse | null) => void,
// eslint-disable-next-line
error: Function,
) {
@@ -82,7 +80,7 @@ export default class WebRequestBackground {
const ciphers = await this.cipherService.getAllDecryptedForUrl(
domain,
activeUserId,
null,
undefined,
UriMatchStrategy.Host,
);
if (ciphers == null || ciphers.length !== 1) {
@@ -90,10 +88,17 @@ export default class WebRequestBackground {
return;
}
const username = ciphers[0].login?.username;
const password = ciphers[0].login?.password;
if (username == null || password == null) {
error();
return;
}
success({
authCredentials: {
username: ciphers[0].login.username,
password: ciphers[0].login.password,
username,
password,
},
});
} catch {

View File

@@ -512,9 +512,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.0"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "camino"

View File

@@ -27,7 +27,7 @@ ashpd = "=0.12.0"
base64 = "=0.22.1"
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "a641316227227f8777fdf56ac9fa2d6b5f7fe662" }
byteorder = "=1.5.0"
bytes = "=1.11.0"
bytes = "=1.11.1"
cbc = "=0.1.2"
chacha20poly1305 = "=0.10.1"
core-foundation = "=0.10.1"

View File

@@ -137,10 +137,6 @@
"message": "Send details",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendPasswordDescV3": {
"message": "Add an optional password for recipients to access this Send.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendTypeTextToShare": {
"message": "Text to share"
},
@@ -4590,5 +4586,9 @@
},
"whyAmISeeingThis": {
"message": "Why am I seeing this?"
},
"sendPasswordHelperText": {
"message": "Individuals will need to enter the password to view this Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
}
}

View File

@@ -6,65 +6,79 @@
(onCipherClicked)="viewCipher($event)"
(onCipherRightClicked)="viewCipherMenu($event)"
(onAddCipher)="addCipher($event)"
[showPremiumCallout]="showPremiumCallout$ | async"
>
</app-vault-items-v2>
<div class="details" *ngIf="!!action">
<app-vault-item-footer
id="footer"
#footer
[cipher]="cipher"
[action]="action"
(onEdit)="editCipher($event)"
(onRestore)="restoreCipher()"
(onClone)="cloneCipher($event)"
(onDelete)="deleteCipher()"
(onCancel)="cancelCipher($event)"
(onArchiveToggle)="refreshCurrentCipher()"
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
></app-vault-item-footer>
<div class="content">
<div class="inner-content">
<div class="box">
<app-cipher-view *ngIf="action === 'view'" [cipher]="cipher" [collections]="collections">
</app-cipher-view>
<vault-cipher-form
#vaultForm
*ngIf="action === 'add' || action === 'edit' || action === 'clone'"
formId="cipherForm"
[config]="config"
(cipherSaved)="savedCipher($event)"
[submitBtn]="footer?.submitBtn"
(formStatusChange$)="formStatusChanged($event)"
>
<bit-item slot="attachment-button">
<button
bit-item-content
type="button"
(click)="openAttachmentsDialog()"
[disabled]="formDisabled"
@if (!!action) {
<div class="details">
<app-vault-item-footer
id="footer"
#footer
[cipher]="cipher"
[action]="action"
(onEdit)="editCipher($event)"
(onRestore)="restoreCipher()"
(onClone)="cloneCipher($event)"
(onDelete)="deleteCipher()"
(onCancel)="cancelCipher($event)"
(onArchiveToggle)="refreshCurrentCipher()"
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
></app-vault-item-footer>
<div class="content">
<div class="inner-content">
<div class="box">
@if (action === "view") {
<app-cipher-view [cipher]="cipher" [collections]="collections"> </app-cipher-view>
}
@if (action === "add" || action === "edit" || action === "clone") {
<vault-cipher-form
#vaultForm
formId="cipherForm"
[config]="config"
(cipherSaved)="savedCipher($event)"
[submitBtn]="footer?.submitBtn"
(formStatusChange$)="formStatusChanged($event)"
>
<div class="tw-flex tw-items-center tw-gap-2">
{{ "attachments" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
<bit-item slot="attachment-button">
<button
bit-item-content
type="button"
(click)="openAttachmentsDialog()"
[disabled]="formDisabled"
>
<div class="tw-flex tw-items-center tw-gap-2">
{{ "attachments" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
}
</div>
</div>
</div>
</div>
</div>
<div
id="logo"
class="logo"
*ngIf="action !== 'add' && action !== 'edit' && action !== 'view' && action !== 'clone'"
>
<div class="content">
<div class="inner-content">
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
}
@if (!["add", "edit", "view", "clone"].includes(action)) {
<div id="logo" class="logo">
<div class="content">
<div class="inner-content">
@if (activeFilter.isArchived && !(hasArchivedCiphers$ | async)) {
<bit-no-items [icon]="itemTypesIcon">
<div slot="title">
{{ "noItemsInArchive" | i18n }}
</div>
<p slot="description" bitTypography="body2" class="tw-max-w-md tw-text-center">
{{ "noItemsInArchiveDesc" | i18n }}
</p>
</bit-no-items>
} @else {
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
}
</div>
</div>
</div>
</div>
}
</div>
<ng-template #folderAddEdit></ng-template>

View File

@@ -18,6 +18,7 @@ import { filter, map, take } from "rxjs/operators";
import { CollectionService } from "@bitwarden/admin-console/common";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service";
import { ItemTypes } from "@bitwarden/assets/svg";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -58,6 +59,7 @@ import {
ToastService,
CopyClickListener,
COPY_CLICK_LISTENER,
NoItemsModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
@@ -112,6 +114,7 @@ const BroadcasterSubscriptionId = "VaultComponent";
ButtonModule,
PremiumBadgeComponent,
VaultItemsV2Component,
NoItemsModule,
],
providers: [
{
@@ -154,7 +157,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
type: CipherType | null = null;
folderId: string | null | undefined = null;
collectionId: string | null = null;
organizationId: string | null = null;
organizationId: OrganizationId | null = null;
myVaultOnly = false;
addType: CipherType | undefined = undefined;
addOrganizationId: string | null = null;
@@ -168,9 +171,19 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
cipher: CipherView | null = new CipherView();
collections: CollectionView[] | null = null;
config: CipherFormConfig | null = null;
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
showPremiumCallout$: Observable<boolean> = this.userId$.pipe(
switchMap((userId) =>
combineLatest([
this.routedVaultFilterBridgeService.activeFilter$,
this.cipherArchiveService.showSubscriptionEndedMessaging$(userId),
]).pipe(map(([activeFilter, showMessaging]) => activeFilter.isArchived && showMessaging)),
),
);
/** Tracks the disabled status of the edit cipher form */
protected formDisabled: boolean = false;
protected itemTypesIcon = ItemTypes;
private organizations$: Observable<Organization[]> = this.accountService.activeAccount$.pipe(
map((a) => a?.id),
@@ -178,10 +191,9 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
switchMap((id) => this.organizationService.organizations$(id)),
);
protected canAccessAttachments$ = this.accountService.activeAccount$.pipe(
filter((account): account is Account => !!account),
switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
protected hasArchivedCiphers$ = this.userId$.pipe(
switchMap((userId) =>
this.cipherArchiveService.archivedCiphers$(userId).pipe(map((ciphers) => ciphers.length > 0)),
),
);

View File

@@ -9,7 +9,6 @@ import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angul
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
@@ -32,7 +31,6 @@ import { SearchBarService } from "../../../app/layout/search/search-bar.service"
})
export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultItemsComponent<C> {
readonly showPremiumCallout = input<boolean>(false);
readonly organizationId = input<OrganizationId | undefined>(undefined);
protected CipherViewLikeUtils = CipherViewLikeUtils;
@@ -55,7 +53,7 @@ export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultIt
}
async navigateToGetPremium() {
await this.premiumUpgradePromptService.promptForPremium(this.organizationId());
await this.premiumUpgradePromptService.promptForPremium();
}
trackByFn(index: number, c: C): string {

View File

@@ -7,7 +7,6 @@
(onCipherRightClicked)="viewCipherMenu($event)"
(onAddCipher)="addCipher($event)"
[showPremiumCallout]="showPremiumCallout$ | async"
[organizationId]="organizationId"
>
</app-vault-items-v2>
@if (!!action) {

View File

@@ -1,6 +1,25 @@
// FIXME: Update this file to be type safe and remove this and next line
import {
MasterPasswordAuthenticationData,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
// @ts-strict-ignore
export class EmergencyAccessPasswordRequest {
newMasterPasswordHash: string;
key: string;
// This will eventually be changed to be an actual constructor, once all callers are updated.
// The body of this request will be changed to carry the authentication data and unlock data.
// https://bitwarden.atlassian.net/browse/PM-23234
static newConstructor(
authenticationData: MasterPasswordAuthenticationData,
unlockData: MasterPasswordUnlockData,
): EmergencyAccessPasswordRequest {
const request = new EmergencyAccessPasswordRequest();
request.newMasterPasswordHash = authenticationData.masterPasswordAuthenticationHash;
request.key = unlockData.masterKeyWrappedUserKey;
return request;
}
}

View File

@@ -7,8 +7,17 @@ import { of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import {
MasterKeyWrappedUserKey,
MasterPasswordAuthenticationData,
MasterPasswordAuthenticationHash,
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { UserKeyResponse } from "@bitwarden/common/models/response/user-key.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -18,7 +27,13 @@ import { UserId } from "@bitwarden/common/types/guid";
import { UserKey, MasterKey, UserPrivateKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { newGuid } from "@bitwarden/guid";
import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import {
Argon2KdfConfig,
DEFAULT_KDF_CONFIG,
KdfType,
KeyService,
PBKDF2KdfConfig,
} from "@bitwarden/key-management";
import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type";
import { EmergencyAccessType } from "../enums/emergency-access-type";
@@ -42,6 +57,8 @@ describe("EmergencyAccessService", () => {
let cipherService: MockProxy<CipherService>;
let logService: MockProxy<LogService>;
let emergencyAccessService: EmergencyAccessService;
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
let configService: MockProxy<ConfigService>;
const mockNewUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("trustedPublicKey")];
@@ -54,6 +71,8 @@ describe("EmergencyAccessService", () => {
encryptService = mock<EncryptService>();
cipherService = mock<CipherService>();
logService = mock<LogService>();
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
configService = mock<ConfigService>();
emergencyAccessService = new EmergencyAccessService(
emergencyAccessApiService,
@@ -62,6 +81,8 @@ describe("EmergencyAccessService", () => {
encryptService,
cipherService,
logService,
masterPasswordService,
configService,
);
});
@@ -215,7 +236,13 @@ describe("EmergencyAccessService", () => {
});
});
describe("takeover", () => {
/**
* @deprecated This 'describe' to be removed in PM-28143. When you remove this, check also if there are any imports/properties
* in the test setup above that are now un-used and can also be removed.
*/
describe("takeover [PM27086_UpdateAuthenticationApisForInputPassword flag DISABLED]", () => {
const PM27086_UpdateAuthenticationApisForInputPasswordEnabled = false;
const params = {
id: "emergencyAccessId",
masterPassword: "mockPassword",
@@ -242,6 +269,10 @@ describe("EmergencyAccessService", () => {
);
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(
PM27086_UpdateAuthenticationApisForInputPasswordEnabled,
);
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse);
keyService.userPrivateKey$.mockReturnValue(of(userPrivateKey));
@@ -450,6 +481,180 @@ describe("EmergencyAccessService", () => {
});
});
describe("takeover [PM27086_UpdateAuthenticationApisForInputPassword flag ENABLED]", () => {
// Mock feature flag value
const PM27086_UpdateAuthenticationApisForInputPasswordEnabled = true;
// Mock sut method params
const id = "emergency-access-id";
const masterPassword = "mockPassword";
const email = "user@example.com";
const activeUserId = newGuid() as UserId;
// Mock method data
const kdfConfig = DEFAULT_KDF_CONFIG;
const takeoverResponse = {
keyEncrypted: "EncryptedKey",
kdf: kdfConfig.kdfType,
kdfIterations: kdfConfig.iterations,
} as EmergencyAccessTakeoverResponse;
const activeUserPrivateKey = new Uint8Array(64) as UserPrivateKey;
let mockGrantorUserKey: UserKey;
let salt: MasterPasswordSalt;
let authenticationData: MasterPasswordAuthenticationData;
let unlockData: MasterPasswordUnlockData;
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(
PM27086_UpdateAuthenticationApisForInputPasswordEnabled,
);
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValue(takeoverResponse);
keyService.userPrivateKey$.mockReturnValue(of(activeUserPrivateKey));
const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64));
encryptService.decapsulateKeyUnsigned.mockResolvedValue(mockDecryptedGrantorUserKey);
mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey;
salt = email as MasterPasswordSalt;
masterPasswordService.emailToSalt.mockReturnValue(salt);
authenticationData = {
salt,
kdf: kdfConfig,
masterPasswordAuthenticationHash:
"masterPasswordAuthenticationHash" as MasterPasswordAuthenticationHash,
};
unlockData = {
salt,
kdf: kdfConfig,
masterKeyWrappedUserKey: "masterKeyWrappedUserKey" as MasterKeyWrappedUserKey,
} as MasterPasswordUnlockData;
masterPasswordService.makeMasterPasswordAuthenticationData.mockResolvedValue(
authenticationData,
);
masterPasswordService.makeMasterPasswordUnlockData.mockResolvedValue(unlockData);
});
it("should throw if active user private key is not found", async () => {
// Arrange
keyService.userPrivateKey$.mockReturnValue(of(null));
// Act
const promise = emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
// Assert
await expect(promise).rejects.toThrow(
"Active user does not have a private key, cannot complete a takeover.",
);
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
});
it("should throw if the grantor user key cannot be decrypted via the active user private key", async () => {
// Arrange
encryptService.decapsulateKeyUnsigned.mockResolvedValue(null);
// Act
const promise = emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
// Assert
await expect(promise).rejects.toThrow("Failed to decrypt grantor key");
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
});
it("should use PBKDF2 if takeover response contains KdfType.PBKDF2_SHA256", async () => {
// Act
await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
// Assert
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
masterPassword,
kdfConfig, // default config (PBKDF2)
salt,
);
});
it("should use Argon2 if takeover response contains KdfType.Argon2id", async () => {
// Arrange
const argon2TakeoverResponse = {
keyEncrypted: "EncryptedKey",
kdf: KdfType.Argon2id,
kdfIterations: 3,
kdfMemory: 64,
kdfParallelism: 4,
} as EmergencyAccessTakeoverResponse;
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValue(
argon2TakeoverResponse,
);
const expectedKdfConfig = new Argon2KdfConfig(
argon2TakeoverResponse.kdfIterations,
argon2TakeoverResponse.kdfMemory,
argon2TakeoverResponse.kdfParallelism,
);
// Act
await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
// Assert
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
masterPassword,
expectedKdfConfig,
salt,
);
expect(masterPasswordService.makeMasterPasswordAuthenticationData).not.toHaveBeenCalledWith(
masterPassword,
kdfConfig, // default config (PBKDF2)
salt,
);
});
it("should call makeMasterPasswordAuthenticationData and makeMasterPasswordUnlockData with the correct parameters", async () => {
// Act
await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
// Assert
const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData);
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
masterPassword,
kdfConfig,
salt,
);
expect(masterPasswordService.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
masterPassword,
kdfConfig,
salt,
mockGrantorUserKey,
);
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
id,
request,
);
});
it("should call the API method to change the grantor's master password", async () => {
// Act
await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
// Assert
const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData);
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledTimes(1);
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
id,
request,
);
});
});
describe("getRotatedData", () => {
const allowedStatuses = [
EmergencyAccessStatusType.Confirmed,

View File

@@ -4,11 +4,19 @@ import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import {
EncryptedString,
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import {
MasterPasswordAuthenticationData,
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
@@ -56,6 +64,8 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide
private encryptService: EncryptService,
private cipherService: CipherService,
private logService: LogService,
private masterPasswordService: MasterPasswordServiceAbstraction,
private configService: ConfigService,
) {}
/**
@@ -270,7 +280,7 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide
* Intended for grantee.
* @param id emergency access id
* @param masterPassword new master password
* @param email email address of grantee (must be consistent or login will fail)
* @param email email address of grantor (must be consistent or login will fail)
* @param activeUserId the user id of the active user
*/
async takeover(id: string, masterPassword: string, email: string, activeUserId: UserId) {
@@ -309,6 +319,36 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide
break;
}
// When you unwind the flag in PM-28143, also remove the ConfigService if it is un-used.
const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword,
);
if (newApisWithInputPasswordFlagEnabled) {
const salt: MasterPasswordSalt = this.masterPasswordService.emailToSalt(email);
const authenticationData: MasterPasswordAuthenticationData =
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
masterPassword,
config,
salt,
);
const unlockData: MasterPasswordUnlockData =
await this.masterPasswordService.makeMasterPasswordUnlockData(
masterPassword,
config,
salt,
grantorUserKey,
);
const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData);
await this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
return; // EARLY RETURN for flagged logic
}
const masterKey = await this.keyService.makeMasterKey(masterPassword, email, config);
const masterKeyHash = await this.keyService.hashMasterKey(masterPassword, masterKey);

View File

@@ -5645,10 +5645,6 @@
"sendTypeText": {
"message": "Text"
},
"sendPasswordDescV3": {
"message": "Add an optional password for recipients to access this Send.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"createSend": {
"message": "New Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
@@ -12782,5 +12778,12 @@
},
"invalidSendPassword": {
"message": "Invalid Send password"
},
"sendPasswordHelperText": {
"message": "Individuals will need to enter the password to view this Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"perUser": {
"message": "per user"
}
}

View File

@@ -1,10 +1,14 @@
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { ButtonType } from "@bitwarden/components";
import { BitwardenIcon, ButtonType } from "@bitwarden/components";
export type SubscriptionPricingCardDetails = {
title: string;
tagline: string;
price?: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
button: {
text: string;
type: ButtonType;
icon?: { type: BitwardenIcon; position: "before" | "after" };
};
features: string[];
};

View File

@@ -1092,7 +1092,7 @@ describe("Cipher Service", () => {
await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId);
expect(apiSpy).toHaveBeenCalled();
expect(apiSpy).toHaveBeenCalledWith({ ids: testCipherIds, organizationId: orgId });
});
it("should use SDK to delete multiple ciphers when feature flag is enabled", async () => {

View File

@@ -1422,7 +1422,7 @@ export class CipherService implements CipherServiceAbstraction {
return;
}
const request = new CipherBulkDeleteRequest(ids);
const request = new CipherBulkDeleteRequest(ids, orgId);
if (asAdmin) {
await this.apiService.deleteManyCiphersAdmin(request);
} else {

View File

@@ -1,4 +1,5 @@
export { ButtonType, ButtonLikeAbstraction } from "./shared/button-like.abstraction";
export { BitwardenIcon } from "./shared/icon";
export * from "./a11y";
export * from "./anon-layout";
export * from "./async-actions";

View File

@@ -2,6 +2,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { FieldType, SecureNoteType, CipherType } from "@bitwarden/common/vault/enums";
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
import * as sdkInternal from "@bitwarden/sdk-internal";
import { APICredentialsData } from "../spec-data/onepassword-1pux/api-credentials";
import { BankAccountData } from "../spec-data/onepassword-1pux/bank-account";
@@ -25,11 +26,14 @@ import { SanitizedExport } from "../spec-data/onepassword-1pux/sanitized-export"
import { SecureNoteData } from "../spec-data/onepassword-1pux/secure-note";
import { ServerData } from "../spec-data/onepassword-1pux/server";
import { SoftwareLicenseData } from "../spec-data/onepassword-1pux/software-license";
import { SSH_KeyData } from "../spec-data/onepassword-1pux/ssh-key";
import { SSNData } from "../spec-data/onepassword-1pux/ssn";
import { WirelessRouterData } from "../spec-data/onepassword-1pux/wireless-router";
import { OnePassword1PuxImporter } from "./onepassword-1pux-importer";
jest.mock("@bitwarden/sdk-internal");
function validateCustomField(fields: FieldView[], fieldName: string, expectedValue: any) {
expect(fields).toBeDefined();
const customField = fields.find((f) => f.name === fieldName);
@@ -669,6 +673,37 @@ describe("1Password 1Pux Importer", () => {
validateCustomField(cipher.fields, "medication notes", "multiple times a day");
});
it("should parse category 114 - SSH Key", async () => {
// Mock the SDK import_ssh_key function to return converted OpenSSH format
const mockConvertedKey = {
privateKey:
"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACCWsp3FFVVCMGZ23hscRkDPfGzKZ8z1V/ZB9nzbdDFRswAAAJh8F3bYfBd2\n2AAAAAtzc2gtZWQyNTUxOQAAACCWsp3FFVVCMGZ23hscRkDPfGzKZ8z1V/ZB9nzbdDFRsw\nAAAEA59QYE22f+VFHhiyH1Vfqiwz7xLEt1zCuk8M8Ng5LpKpayncUVVUKwZ3beGxxGQM98\nbMpnzPVX9kH2fNt0MVGzAAAAE3Rlc3RAZXhhbXBsZS5jb20BAgMEBQ==\n-----END OPENSSH PRIVATE KEY-----\n",
publicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJayncUVVUKwZ3beGxxGQM98bMpnzPVX9kH2fNt0MVGz",
fingerprint: "SHA256:/9qSxXuic8kaVBhwv3c8PuetiEpaOgIp7xHNCbcSuN8",
} as sdkInternal.SshKeyView;
jest.spyOn(sdkInternal, "import_ssh_key").mockReturnValue(mockConvertedKey);
const importer = new OnePassword1PuxImporter();
const jsonString = JSON.stringify(SSH_KeyData);
const result = await importer.parse(jsonString);
expect(result != null).toBe(true);
const cipher = result.ciphers.shift();
expect(cipher.type).toEqual(CipherType.SshKey);
expect(cipher.name).toEqual("Some SSH Key");
expect(cipher.notes).toEqual("SSH Key Note");
// Verify that import_ssh_key was called with the PKCS#8 key from 1Password
expect(sdkInternal.import_ssh_key).toHaveBeenCalledWith(
"-----BEGIN PRIVATE KEY-----\nMFECAQEwBQYDK2VwBCIEIDn1BgTbZ/5UUeGLIfVV+qLBOvEsS3XMK6Twzw2Dkukq\ngSEAlrKdxRVVQrBndt4bHEZAz3xsymfM9Vf2QfZ823QxUbM=\n-----END PRIVATE KEY-----\n",
);
// Verify the key was converted to OpenSSH format
expect(cipher.sshKey.privateKey).toEqual(mockConvertedKey.privateKey);
expect(cipher.sshKey.publicKey).toEqual(mockConvertedKey.publicKey);
expect(cipher.sshKey.keyFingerprint).toEqual(mockConvertedKey.fingerprint);
});
it("should create folders", async () => {
const importer = new OnePassword1PuxImporter();
const result = await importer.parse(SanitizedExportJson);

View File

@@ -8,6 +8,8 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
import { import_ssh_key } from "@bitwarden/sdk-internal";
import { ImportResult } from "../../models/import-result";
import { BaseImporter } from "../base-importer";
@@ -80,6 +82,10 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
cipher.type = CipherType.Identity;
cipher.identity = new IdentityView();
break;
case Category.SSH_Key:
cipher.type = CipherType.SshKey;
cipher.sshKey = new SshKeyView();
break;
default:
break;
}
@@ -316,6 +322,19 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
default:
break;
}
} else if (cipher.type === CipherType.SshKey) {
if (valueKey === "sshKey") {
// Use sshKey.metadata.privateKey instead of the sshKey.privateKey field.
// The sshKey.privateKey field doesn't have a consistent format for every item.
const { privateKey } = field.value.sshKey.metadata;
// Convert SSH key from PKCS#8 (1Password format) to OpenSSH format using SDK
// Note: 1Password does not store password-protected SSH keys, so no password handling needed for now
const parsedKey = import_ssh_key(privateKey);
cipher.sshKey.privateKey = parsedKey.privateKey;
cipher.sshKey.publicKey = parsedKey.publicKey;
cipher.sshKey.keyFingerprint = parsedKey.fingerprint;
return;
}
}
if (valueKey === "email") {

View File

@@ -49,6 +49,7 @@ export const Category = Object.freeze({
EmailAccount: "111",
API_Credential: "112",
MedicalRecord: "113",
SSH_Key: "114",
} as const);
/**
@@ -133,6 +134,7 @@ export interface Value {
creditCardType?: string | null;
creditCardNumber?: string | null;
reference?: string | null;
sshKey?: SSHKey | null;
}
export interface Email {
@@ -147,6 +149,19 @@ export interface Address {
zip: string;
state: string;
}
export interface SSHKey {
privateKey: string;
metadata: SSHKeyMetadata;
}
export interface SSHKeyMetadata {
privateKey: string;
publicKey: string;
fingerprint: string;
keyType: string;
}
export interface InputTraits {
keyboard: string;
correction: string;

View File

@@ -0,0 +1,83 @@
import { ExportData } from "../../onepassword/types/onepassword-1pux-importer-types";
export const SSH_KeyData: ExportData = {
accounts: [
{
attrs: {
accountName: "1Password Customer",
name: "1Password Customer",
avatar: "",
email: "username123123123@gmail.com",
uuid: "TRIZ3XV4JJFRXJ3BARILLTUA6E",
domain: "https://my.1password.com/",
},
vaults: [
{
attrs: {
uuid: "pqcgbqjxr4tng2hsqt5ffrgwju",
desc: "Just test entries",
avatar: "ke7i5rxnjrh3tj6uesstcosspu.png",
name: "T's Test Vault",
type: "U",
},
items: [
{
uuid: "kf7wevmfiqmbgyao42plvgrasy",
favIndex: 0,
createdAt: 1724868152,
updatedAt: 1724868152,
state: "active",
categoryUuid: "114",
details: {
loginFields: [],
notesPlain: "SSH Key Note",
sections: [
{
title: "SSH Key Section",
fields: [
{
title: "private key",
id: "private_key",
value: {
sshKey: {
privateKey:
"-----BEGIN PRIVATE KEY-----\nMFECAQEwBQYDK2VwBCIEIDn1BgTbZ/5UUeGLIfVV+qLBOvEsS3XMK6Twzw2Dkukq\ngSEAlrKdxRVVQrBndt4bHEZAz3xsymfM9Vf2QfZ823QxUbM=\n-----END PRIVATE KEY-----\n",
metadata: {
privateKey:
"-----BEGIN PRIVATE KEY-----\nMFECAQEwBQYDK2VwBCIEIDn1BgTbZ/5UUeGLIfVV+qLBOvEsS3XMK6Twzw2Dkukq\ngSEAlrKdxRVVQrBndt4bHEZAz3xsymfM9Vf2QfZ823QxUbM=\n-----END PRIVATE KEY-----\n",
publicKey:
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJayncUVVUKwZ3beGxxGQM98bMpnzPVX9kH2fNt0MVGz",
fingerprint: "SHA256:/9qSxXuic8kaVBhwv3c8PuetiEpaOgIp7xHNCbcSuN8",
keyType: "ed25519",
},
},
},
guarded: true,
multiline: false,
dontGenerate: false,
inputTraits: {
keyboard: "default",
correction: "default",
capitalization: "default",
},
},
],
hideAddAnotherField: true,
},
],
passwordHistory: [],
},
overview: {
subtitle: "SHA256:/9qSxXuic8kaVBhwv3c8PuetiEpaOgIp7xHNCbcSuN8",
icons: null,
title: "Some SSH Key",
url: "",
watchtowerExclusions: null,
},
},
],
},
],
},
],
};

View File

@@ -16,7 +16,7 @@
{{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD
</h2>
<span bitTypography="h3">&nbsp;</span>
<span bitTypography="body1" class="tw-text-main">/ {{ term }}</span>
<span bitTypography="body1" class="tw-text-main tw-font-normal">/ {{ term }}</span>
}
</div>
<button
@@ -38,21 +38,36 @@
<!-- Password Manager Section -->
<div id="password-manager" class="tw-border-b tw-border-secondary-100 tw-pb-2">
<div class="tw-flex tw-justify-between tw-mb-1">
<h3 bitTypography="h5" class="tw-text-muted">{{ "passwordManager" | i18n }}</h3>
<h3 bitTypography="h5" class="tw-text-muted tw-font-semibold">
{{ "passwordManager" | i18n }}
</h3>
</div>
<!-- Password Manager Members -->
<div id="password-manager-members" class="tw-flex tw-justify-between">
<div class="tw-flex-1">
@let passwordManagerSeats = cart.passwordManager.seats;
<div bitTypography="body1" class="tw-text-muted">
{{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.translationKey | i18n }} x
{{ passwordManagerSeats.cost | currency: "USD" : "symbol" }}
/
{{ term }}
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
{{ passwordManagerSeats.quantity }}
{{
translateWithParams(
passwordManagerSeats.translationKey,
passwordManagerSeats.translationParams
)
}}
@if (!passwordManagerSeats.hideBreakdown) {
x
{{ passwordManagerSeats.cost | currency: "USD" : "symbol" }}
/
{{ term }}
}
</div>
</div>
<div bitTypography="body1" class="tw-text-muted" data-testid="password-manager-total">
<div
bitTypography="body1"
class="tw-text-muted tw-font-normal"
data-testid="password-manager-total"
>
{{ passwordManagerSeatsTotal() | currency: "USD" : "symbol" }}
</div>
</div>
@@ -62,13 +77,25 @@
@if (additionalStorage) {
<div id="additional-storage" class="tw-flex tw-justify-between">
<div class="tw-flex-1">
<div bitTypography="body1" class="tw-text-muted">
{{ additionalStorage.quantity }} {{ additionalStorage.translationKey | i18n }} x
{{ additionalStorage.cost | currency: "USD" : "symbol" }} /
{{ term }}
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
{{ additionalStorage.quantity }}
{{
translateWithParams(
additionalStorage.translationKey,
additionalStorage.translationParams
)
}}
@if (!additionalStorage.hideBreakdown) {
x {{ additionalStorage.cost | currency: "USD" : "symbol" }} /
{{ term }}
}
</div>
</div>
<div bitTypography="body1" class="tw-text-muted" data-testid="additional-storage-total">
<div
bitTypography="body1"
class="tw-text-muted tw-font-normal"
data-testid="additional-storage-total"
>
{{ additionalStorageTotal() | currency: "USD" : "symbol" }}
</div>
</div>
@@ -80,19 +107,30 @@
@if (secretsManagerSeats) {
<div id="secrets-manager" class="tw-border-b tw-border-secondary-100 tw-py-2">
<div class="tw-flex tw-justify-between">
<h3 bitTypography="h5" class="tw-text-muted">{{ "secretsManager" | i18n }}</h3>
<div bitTypography="h5" class="tw-text-muted tw-font-semibold">
{{ "secretsManager" | i18n }}
</div>
</div>
<!-- Secrets Manager Members -->
<div id="secrets-manager-members" class="tw-flex tw-justify-between">
<div bitTypography="body1" class="tw-text-muted">
{{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.translationKey | i18n }} x
{{ secretsManagerSeats.cost | currency: "USD" : "symbol" }}
/ {{ term }}
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
{{ secretsManagerSeats.quantity }}
{{
translateWithParams(
secretsManagerSeats.translationKey,
secretsManagerSeats.translationParams
)
}}
@if (!secretsManagerSeats.hideBreakdown) {
x
{{ secretsManagerSeats.cost | currency: "USD" : "symbol" }}
/ {{ term }}
}
</div>
<div
bitTypography="body1"
class="tw-text-muted"
class="tw-text-muted tw-font-normal"
data-testid="secrets-manager-seats-total"
>
{{ secretsManagerSeatsTotal() | currency: "USD" : "symbol" }}
@@ -103,12 +141,20 @@
@let additionalServiceAccounts = cart.secretsManager?.additionalServiceAccounts;
@if (additionalServiceAccounts) {
<div id="additional-service-accounts" class="tw-flex tw-justify-between">
<div bitTypography="body1" class="tw-text-muted">
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
{{ additionalServiceAccounts.quantity }}
{{ additionalServiceAccounts.translationKey | i18n }} x
{{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }}
/
{{ term }}
{{
translateWithParams(
additionalServiceAccounts.translationKey,
additionalServiceAccounts.translationParams
)
}}
@if (!additionalServiceAccounts.hideBreakdown) {
x
{{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }}
/
{{ term }}
}
</div>
<div
bitTypography="body1"
@@ -129,19 +175,41 @@
class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-py-2"
data-testid="discount-section"
>
<h3 bitTypography="h5" class="tw-text-success-600">{{ discountLabel() }}</h3>
<div bitTypography="body1" class="tw-text-success-600">{{ discountLabel() }}</div>
<div bitTypography="body1" class="tw-text-success-600" data-testid="discount-amount">
-{{ discountAmount() | currency: "USD" : "symbol" }}
</div>
</div>
}
<!-- Credit -->
@if (creditAmount() > 0) {
<div
id="credit-section"
class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-py-2"
data-testid="credit-section"
>
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
{{ translateWithParams(cart.credit!.translationKey, cart.credit!.translationParams) }}
</div>
<div
bitTypography="body1"
class="tw-text-muted tw-font-normal"
data-testid="credit-amount"
>
-{{ creditAmount() | currency: "USD" : "symbol" }}
</div>
</div>
}
<!-- Estimated Tax -->
<div
id="estimated-tax-section"
class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-pt-2 tw-pb-0.5"
>
<h3 bitTypography="h5" class="tw-text-muted">{{ "estimatedTax" | i18n }}</h3>
<h3 bitTypography="h5" class="tw-text-muted tw-font-semibold">
{{ "estimatedTax" | i18n }}
</h3>
<div bitTypography="body1" class="tw-text-muted" data-testid="estimated-tax">
{{ estimatedTax() | currency: "USD" : "symbol" }}
</div>
@@ -149,7 +217,7 @@
<!-- Total -->
<div id="total-section" class="tw-flex tw-justify-between tw-items-center tw-pt-2">
<h3 bitTypography="h5" class="tw-text-muted">{{ "total" | i18n }}</h3>
<h3 bitTypography="h5" class="tw-text-muted tw-font-semibold">{{ "total" | i18n }}</h3>
<div bitTypography="body1" class="tw-text-muted" data-testid="final-total">
{{ total() | currency: "USD" : "symbol" }} / {{ term | i18n }}
</div>

View File

@@ -25,8 +25,10 @@ behavior across Bitwarden applications.
- [With Secrets Manager](#with-secrets-manager)
- [With Secrets Manager and Additional Service Accounts](#with-secrets-manager-and-additional-service-accounts)
- [All Products](#all-products)
- [With Account Credit](#with-account-credit)
- [With Percent Discount](#with-percent-discount)
- [With Amount Discount](#with-amount-discount)
- [With Discount and Credit](#with-discount-and-credit)
- [Custom Header Template](#custom-header-template)
- [Premium Plan](#premium-plan)
- [Families Plan](#families-plan)
@@ -85,9 +87,16 @@ export type Cart = {
};
cadence: "annually" | "monthly"; // Billing period for entire cart
discount?: Discount; // Optional cart-level discount
credit?: Credit; // Optional account credit
estimatedTax: number; // Tax amount
};
export type Credit = {
translationKey: string; // Translation key for credit label
translationParams?: Array<string | number>; // Optional params for translation
value: number; // Credit amount to subtract from subtotal
};
import { DiscountTypes, DiscountType } from "@bitwarden/pricing";
export type Discount = {
@@ -330,6 +339,33 @@ Show a cart with all available products:
</billing-cart-summary>
```
### With Account Credit
Show cart with account credit applied:
<Canvas of={CartSummaryStories.WithCredit} />
```html
<billing-cart-summary
[cart]="{
passwordManager: {
seats: {
quantity: 5,
translationKey: 'members',
cost: 50.00
}
},
cadence: 'monthly',
credit: {
translationKey: 'accountCredit',
value: 25.00
},
estimatedTax: 10.00
}"
>
</billing-cart-summary>
```
### With Percent Discount
Show cart with percentage-based discount:
@@ -396,6 +432,42 @@ Show cart with fixed amount discount:
</billing-cart-summary>
```
### With Discount and Credit
Show cart with both discount and credit applied:
<Canvas of={CartSummaryStories.WithDiscountAndCredit} />
```html
<billing-cart-summary
[cart]="{
passwordManager: {
seats: {
quantity: 5,
translationKey: 'members',
cost: 50.00
},
additionalStorage: {
quantity: 2,
translationKey: 'additionalStorageGB',
cost: 10.00
}
},
cadence: 'annually',
discount: {
type: 'percent-off',
value: 15
},
credit: {
translationKey: 'accountCredit',
value: 50.00
},
estimatedTax: 15.00
}"
>
</billing-cart-summary>
```
### Custom Header Template
Show cart with custom header template:
@@ -466,10 +538,12 @@ Show cart with families plan:
- **Collapsible Interface**: Users can toggle between a summary view showing only the total and a
detailed view showing all line items
- **Line Item Grouping**: Organizes items by product category (Password Manager, Secrets Manager)
- **Dynamic Calculations**: Automatically calculates subtotals, discounts, taxes, and totals using
Angular signals and computed values
- **Dynamic Calculations**: Automatically calculates subtotals, discounts, credits, taxes, and
totals using Angular signals and computed values
- **Discount Support**: Displays both percentage-based and fixed-amount discounts with green success
styling
- **Credit Support**: Shows account credit deductions with clear labeling using i18n translation
keys
- **Custom Header Templates**: Optional header input allows for custom header designs while
maintaining cart functionality
- **Flexible Structure**: Accommodates different combinations of products, add-ons, and discounts

View File

@@ -89,6 +89,8 @@ describe("CartSummaryComponent", () => {
return "Premium membership";
case "discount":
return "discount";
case "accountCredit":
return "accountCredit";
default:
return key;
}
@@ -253,6 +255,126 @@ describe("CartSummaryComponent", () => {
});
});
describe("hideBreakdown Property", () => {
it("should hide cost breakdown when hideBreakdown is true for password manager seats", () => {
// Arrange
const cartWithHiddenBreakdown: Cart = {
...mockCart,
passwordManager: {
seats: {
quantity: 5,
translationKey: "members",
cost: 50,
hideBreakdown: true,
},
},
};
fixture.componentRef.setInput("cart", cartWithHiddenBreakdown);
fixture.detectChanges();
const pmLineItem = fixture.debugElement.query(
By.css('[id="password-manager-members"] .tw-flex-1 .tw-text-muted'),
);
// Act / Assert
expect(pmLineItem.nativeElement.textContent).toContain("5 Members");
});
it("should show cost breakdown when hideBreakdown is false for password manager seats", () => {
// Arrange / Act
const pmLineItem = fixture.debugElement.query(
By.css('[id="password-manager-members"] .tw-flex-1 .tw-text-muted'),
);
// Assert
expect(pmLineItem.nativeElement.textContent).toContain("5 Members x $50.00 / month");
});
it("should hide cost breakdown for additional storage when hideBreakdown is true", () => {
// Arrange
const cartWithHiddenBreakdown: Cart = {
...mockCart,
passwordManager: {
...mockCart.passwordManager,
additionalStorage: {
quantity: 2,
translationKey: "additionalStorageGB",
cost: 10,
hideBreakdown: true,
},
},
};
fixture.componentRef.setInput("cart", cartWithHiddenBreakdown);
fixture.detectChanges();
const storageItem = fixture.debugElement.query(By.css("[id='additional-storage']"));
const storageLineItem = storageItem.query(By.css(".tw-flex-1 .tw-text-muted"));
const storageTotal = storageItem.query(By.css("[data-testid='additional-storage-total']"));
// Act / Assert
expect(storageLineItem.nativeElement.textContent).toContain("2 Additional storage GB");
expect(storageTotal.nativeElement.textContent).toContain("$20.00");
});
it("should hide cost breakdown for secrets manager seats when hideBreakdown is true", () => {
// Arrange
const cartWithHiddenBreakdown: Cart = {
...mockCart,
secretsManager: {
seats: {
quantity: 3,
translationKey: "secretsManagerSeats",
cost: 30,
hideBreakdown: true,
},
additionalServiceAccounts: mockCart.secretsManager!.additionalServiceAccounts,
},
};
fixture.componentRef.setInput("cart", cartWithHiddenBreakdown);
fixture.detectChanges();
const smLineItem = fixture.debugElement.query(
By.css('[id="secrets-manager-members"] .tw-text-muted'),
);
const smTotal = fixture.debugElement.query(
By.css('[data-testid="secrets-manager-seats-total"]'),
);
// Act / Assert
expect(smLineItem.nativeElement.textContent).toContain("3 Secrets Manager seats");
expect(smTotal.nativeElement.textContent).toContain("$90.00");
});
it("should hide cost breakdown for additional service accounts when hideBreakdown is true", () => {
// Arrange
const cartWithHiddenBreakdown: Cart = {
...mockCart,
secretsManager: {
seats: mockCart.secretsManager!.seats,
additionalServiceAccounts: {
quantity: 2,
translationKey: "additionalServiceAccountsV2",
cost: 6,
hideBreakdown: true,
},
},
};
fixture.componentRef.setInput("cart", cartWithHiddenBreakdown);
fixture.detectChanges();
const saLineItem = fixture.debugElement.query(
By.css('[id="additional-service-accounts"] .tw-text-muted'),
);
const saTotal = fixture.debugElement.query(
By.css('[data-testid="additional-service-accounts-total"]'),
);
// Act / Assert
expect(saLineItem.nativeElement.textContent).toContain("2 Additional machine accounts");
expect(saTotal.nativeElement.textContent).toContain("$12.00");
});
});
describe("Discount Display", () => {
it("should not display discount section when no discount is present", () => {
// Arrange / Act
@@ -336,6 +458,94 @@ describe("CartSummaryComponent", () => {
expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal);
});
});
describe("Credit Display", () => {
it("should not display credit section when no credit is present", () => {
// Arrange / Act
const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]'));
// Assert
expect(creditSection).toBeFalsy();
});
it("should display credit correctly", () => {
// Arrange
const cartWithCredit: Cart = {
...mockCart,
credit: {
translationKey: "accountCredit",
value: 25.0,
},
};
fixture.componentRef.setInput("cart", cartWithCredit);
fixture.detectChanges();
const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]'));
const creditLabel = creditSection.query(By.css("h3"));
const creditAmount = creditSection.query(By.css('[data-testid="credit-amount"]'));
// Act / Assert
expect(creditSection).toBeTruthy();
expect(creditLabel.nativeElement.textContent.trim()).toBe("accountCredit");
expect(creditAmount.nativeElement.textContent).toContain("-$25.00");
});
it("should apply credit to total calculation", () => {
// Arrange
const cartWithCredit: Cart = {
...mockCart,
credit: {
translationKey: "accountCredit",
value: 50.0,
},
};
fixture.componentRef.setInput("cart", cartWithCredit);
fixture.detectChanges();
// Subtotal = 372, credit = 50, tax = 9.6
// Total = 372 - 50 + 9.6 = 331.6
const expectedTotal = "$331.60";
const topTotal = fixture.debugElement.query(By.css("h2"));
const bottomTotal = fixture.debugElement.query(By.css("[data-testid='final-total']"));
// Act / Assert
expect(topTotal.nativeElement.textContent).toContain(expectedTotal);
expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal);
});
it("should display and apply both discount and credit correctly", () => {
// Arrange
const cartWithBoth: Cart = {
...mockCart,
discount: {
type: DiscountTypes.PercentOff,
value: 10,
},
credit: {
translationKey: "accountCredit",
value: 30.0,
},
};
fixture.componentRef.setInput("cart", cartWithBoth);
fixture.detectChanges();
// Subtotal = 372, discount = 37.2 (10%), credit = 30, tax = 9.6
// Total = 372 - 37.2 - 30 + 9.6 = 314.4
const expectedTotal = "$314.40";
const discountSection = fixture.debugElement.query(
By.css('[data-testid="discount-section"]'),
);
const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]'));
const topTotal = fixture.debugElement.query(By.css("h2"));
const bottomTotal = fixture.debugElement.query(By.css("[data-testid='final-total']"));
// Act / Assert
expect(discountSection).toBeTruthy();
expect(creditSection).toBeTruthy();
expect(topTotal.nativeElement.textContent).toContain(expectedTotal);
expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal);
});
});
});
describe("CartSummaryComponent - Custom Header Template", () => {
@@ -424,6 +634,8 @@ describe("CartSummaryComponent - Custom Header Template", () => {
return "Collapse purchase details";
case "discount":
return "discount";
case "accountCredit":
return "accountCredit";
default:
return key;
}

View File

@@ -57,6 +57,8 @@ export default {
return "Your next charge is for";
case "dueOn":
return "due on";
case "premiumSubscriptionCredit":
return "Premium subscription credit";
default:
return key;
}
@@ -341,3 +343,92 @@ export const WithAmountDiscount: Story = {
} satisfies Cart,
},
};
export const WithHiddenBreakdown: Story = {
name: "Hidden Cost Breakdown",
args: {
cart: {
passwordManager: {
seats: {
quantity: 5,
translationKey: "members",
cost: 50.0,
hideBreakdown: true,
},
additionalStorage: {
quantity: 2,
translationKey: "additionalStorageGB",
cost: 10.0,
hideBreakdown: true,
},
},
secretsManager: {
seats: {
quantity: 3,
translationKey: "members",
cost: 30.0,
hideBreakdown: true,
},
additionalServiceAccounts: {
quantity: 2,
translationKey: "additionalServiceAccountsV2",
cost: 6.0,
hideBreakdown: true,
},
},
cadence: "monthly",
estimatedTax: 19.2,
} satisfies Cart,
},
};
export const WithCredit: Story = {
name: "With Account Credit",
args: {
cart: {
passwordManager: {
seats: {
quantity: 5,
translationKey: "members",
cost: 50.0,
},
},
cadence: "monthly",
credit: {
translationKey: "premiumSubscriptionCredit",
value: 25.0,
},
estimatedTax: 10.0,
} satisfies Cart,
},
};
export const WithDiscountAndCredit: Story = {
name: "With Both Discount and Credit",
args: {
cart: {
passwordManager: {
seats: {
quantity: 5,
translationKey: "members",
cost: 50.0,
},
additionalStorage: {
quantity: 2,
translationKey: "additionalStorageGB",
cost: 10.0,
},
},
cadence: "annually",
discount: {
type: DiscountTypes.PercentOff,
value: 15,
},
credit: {
translationKey: "premiumSubscriptionCredit",
value: 50.0,
},
estimatedTax: 15.0,
} satisfies Cart,
},
};

View File

@@ -142,11 +142,22 @@ export class CartSummaryComponent {
return getLabel(this.i18nService, discount);
});
/**
* Calculates the credit amount from the cart credit
*/
readonly creditAmount = computed<number>(() => {
const { credit } = this.cart();
if (!credit) {
return 0;
}
return credit.value;
});
/**
* Calculates the total of all line items including discount and tax
*/
readonly total = computed<number>(
() => this.subtotal() - this.discountAmount() + this.estimatedTax(),
() => this.subtotal() - this.discountAmount() - this.creditAmount() + this.estimatedTax(),
);
/**
@@ -154,6 +165,16 @@ export class CartSummaryComponent {
*/
readonly total$ = toObservable(this.total);
/**
* Translates a key with optional parameters
*/
translateWithParams(key: string, params?: Array<string | number>): string {
if (!params || params.length === 0) {
return this.i18nService.t(key);
}
return this.i18nService.t(key, ...params);
}
/**
* Toggles the expanded/collapsed state of the cart items
*/

View File

@@ -22,22 +22,24 @@
@if (price(); as priceValue) {
<div class="tw-mb-6">
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
<!-- Show no decimals for whole numbers (e.g. $5), but always show 2 decimals when present (e.g. $120.50) -->
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
priceValue.amount | currency: "$"
priceValue.amount
| currency: "$" : true : (priceValue.amount % 1 === 0 ? "1.0-0" : "1.2-2")
}}</span>
<span bitTypography="helper" class="tw-text-muted">
/ {{ priceValue.cadence }}
/ {{ priceValue.cadence | i18n }}
@if (priceValue.showPerUser) {
per user
{{ "perUser" | i18n }}
}
</span>
</div>
</div>
}
<!-- Button space (always reserved) -->
<div class="tw-mb-6 tw-h-12">
@if (button(); as buttonConfig) {
<!-- Button -->
@if (button(); as buttonConfig) {
<div class="tw-mb-6 tw-h-12">
<button
bitButton
[buttonType]="buttonConfig.type"
@@ -46,19 +48,19 @@
(click)="buttonClick.emit()"
type="button"
>
@if (buttonConfig.icon?.position === "before") {
<i class="bwi {{ buttonConfig.icon.type }} tw-me-2" aria-hidden="true"></i>
@if (buttonConfig.icon && buttonConfig.icon.position === "before") {
<bit-icon [name]="buttonConfig.icon.type" class="tw-me-2" aria-hidden="true"></bit-icon>
}
{{ buttonConfig.text }}
@if (
buttonConfig.icon &&
(buttonConfig.icon.position === "after" || !buttonConfig.icon.position)
) {
<i class="bwi {{ buttonConfig.icon.type }} tw-ms-2" aria-hidden="true"></i>
<bit-icon [name]="buttonConfig.icon.type" class="tw-ms-2" aria-hidden="true"></bit-icon>
}
</button>
}
</div>
</div>
}
<!-- Features List -->
<div class="tw-flex-grow">
@@ -67,10 +69,12 @@
<ul class="tw-list-none tw-p-0 tw-m-0">
@for (feature of featureList; track feature) {
<li class="tw-flex tw-items-start tw-gap-2 tw-mb-2 last:tw-mb-0">
<i
class="bwi bwi-check tw-text-primary-600 tw-mt-0.5 tw-flex-shrink-0"
<bit-icon
name="bwi-check"
class="tw-text-primary-600 tw-mt-0.5 tw-flex-shrink-0"
aria-hidden="true"
></i>
>
</bit-icon>
<span bitTypography="helper" class="tw-text-muted tw-leading-relaxed">{{
feature
}}</span>

View File

@@ -39,7 +39,7 @@ import { PricingCardComponent } from "@bitwarden/pricing";
| Input | Type | Description |
| ------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tagline` | `string` | **Required.** Descriptive text below title (max 2 lines) |
| `price` | `{ amount: number; cadence: "monthly" \| "annually"; showPerUser?: boolean }` | **Optional.** Price information. If omitted, no price is shown |
| `price` | `{ amount: number; cadence: "month" \| "monthly" \| "year" \| "annually"; showPerUser?: boolean }` | **Optional.** Price information. If omitted, no price is shown |
| `button` | `{ type: ButtonType; text: string; disabled?: boolean; icon?: { type: string; position: "before" \| "after" } }` | **Optional.** Button configuration with optional icon. If omitted, no button is shown. Icon uses `bwi-*` classes, position defaults to "after" |
| `features` | `string[]` | **Optional.** List of features with checkmarks |
| `activeBadge` | `{ text: string; variant?: BadgeVariant }` | **Optional.** Active plan badge using proper Badge component, positioned on the same line as title, aligned to the right. If omitted, no badge is shown |
@@ -182,6 +182,58 @@ For coming soon or unavailable plans:
</billing-pricing-card>
```
### With Button Icons
Add icons to buttons for enhanced visual communication:
<Canvas of={PricingCardStories.WithButtonIcon} />
```html
<!-- Icon after text (default) -->
<billing-pricing-card
title="Premium Plan"
tagline="Upgrade for advanced features"
[price]="{ amount: 10, cadence: 'monthly' }"
[button]="{
text: 'Upgrade Now',
type: 'primary',
icon: { type: 'bwi-external-link', position: 'after' }
}"
[features]="premiumFeatures"
>
</billing-pricing-card>
<!-- Icon before text -->
<billing-pricing-card
title="Business Plan"
tagline="Add more features to your plan"
[price]="{ amount: 5, cadence: 'monthly', showPerUser: true }"
[button]="{
text: 'Add Features',
type: 'secondary',
icon: { type: 'bwi-plus', position: 'before' }
}"
[features]="businessFeatures"
>
</billing-pricing-card>
```
### Active Plan Badge
Show which plan is currently active:
<Canvas of={PricingCardStories.ActivePlan} />
```html
<billing-pricing-card
title="Free Plan"
tagline="Your current plan with essential features"
[features]="freeFeatures"
[activeBadge]="{ text: 'Active plan' }"
>
</billing-pricing-card>
```
### Pricing Grid Layout
Multiple cards displayed together:

View File

@@ -2,6 +2,7 @@ import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { BadgeVariant, ButtonType, SvgModule, TypographyModule } from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
@@ -69,6 +70,29 @@ describe("PricingCardComponent", () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PricingCardComponent, TestHostComponent, SvgModule, TypographyModule, CommonModule],
providers: [
{
provide: I18nService,
useValue: {
t: (key: string) => {
switch (key) {
case "month":
return "month";
case "monthly":
return "monthly";
case "year":
return "year";
case "annually":
return "annually";
case "perUser":
return "per user";
default:
return key;
}
},
},
},
],
}).compileComponents();
// For signal inputs, we need to set required inputs through the host component
@@ -151,7 +175,7 @@ describe("PricingCardComponent", () => {
it("should display bwi-check icons for features", () => {
hostFixture.detectChanges();
const compiled = hostFixture.nativeElement;
const icons = compiled.querySelectorAll("i.bwi-check");
const icons = compiled.querySelectorAll("bit-icon[name='bwi-check']");
expect(icons.length).toBe(3); // One for each feature
});

View File

@@ -1,15 +1,42 @@
import { Meta, StoryObj } from "@storybook/angular";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { TypographyModule } from "@bitwarden/components";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SvgModule, TypographyModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { PricingCardComponent } from "./pricing-card.component";
export default {
title: "Billing/Pricing Card",
component: PricingCardComponent,
moduleMetadata: {
imports: [TypographyModule],
},
decorators: [
moduleMetadata({
imports: [PricingCardComponent, SvgModule, TypographyModule, I18nPipe],
providers: [
{
provide: I18nService,
useValue: {
t: (key: string) => {
switch (key) {
case "month":
return "month";
case "monthly":
return "monthly";
case "year":
return "year";
case "annually":
return "annually";
case "perUser":
return "per user";
default:
return key;
}
},
},
},
],
}),
],
args: {
tagline: "Everything you need for secure password management across all your devices",
},
@@ -83,7 +110,7 @@ export const WithoutFeatures: Story = {
}),
args: {
tagline: "Advanced security and management for your organization",
price: { amount: 3, cadence: "monthly" },
price: { amount: 3, cadence: "month" },
button: { text: "Contact Sales", type: "primary" },
},
};
@@ -150,7 +177,7 @@ export const LongTagline: Story = {
args: {
tagline:
"Comprehensive password management solution for teams and organizations that need advanced security features, detailed reporting, and enterprise-grade administration tools that scale with your business",
price: { amount: 5, cadence: "monthly", showPerUser: true },
price: { amount: 5, cadence: "month", showPerUser: true },
button: { text: "Start Business Trial", type: "primary" },
features: [
"Everything in Premium",
@@ -274,7 +301,7 @@ export const WithoutButton: Story = {
}),
args: {
tagline: "This plan will be available soon with exciting new features",
price: { amount: 15, cadence: "monthly" },
price: { amount: 15, cadence: "month" },
features: ["Advanced security features", "Enhanced collaboration tools", "Premium support"],
},
};

View File

@@ -4,12 +4,15 @@ import { ChangeDetectionStrategy, Component, input, output } from "@angular/core
import {
BadgeModule,
BadgeVariant,
BitwardenIcon,
ButtonModule,
ButtonType,
CardComponent,
IconModule,
SvgModule,
TypographyModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
/**
* A reusable UI-only component that displays pricing information in a card format.
@@ -20,20 +23,29 @@ import {
selector: "billing-pricing-card",
templateUrl: "./pricing-card.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [BadgeModule, ButtonModule, SvgModule, TypographyModule, CurrencyPipe, CardComponent],
imports: [
BadgeModule,
ButtonModule,
SvgModule,
IconModule,
TypographyModule,
CurrencyPipe,
CardComponent,
I18nPipe,
],
})
export class PricingCardComponent {
readonly tagline = input.required<string>();
readonly price = input<{
amount: number;
cadence: "monthly" | "annually";
cadence: "month" | "monthly" | "year" | "annually";
showPerUser?: boolean;
}>();
readonly button = input<{
type: ButtonType;
text: string;
disabled?: boolean;
icon?: { type: string; position: "before" | "after" };
icon?: { type: BitwardenIcon; position: "before" | "after" };
}>();
readonly features = input<string[]>();
readonly activeBadge = input<{ text: string; variant?: BadgeVariant }>();

View File

@@ -1,10 +1,14 @@
import { Discount } from "@bitwarden/pricing";
import { Credit } from "./credit";
export type CartItem = {
translationKey: string;
translationParams?: Array<string | number>;
quantity: number;
cost: number;
discount?: Discount;
hideBreakdown?: boolean;
};
export type Cart = {
@@ -18,5 +22,6 @@ export type Cart = {
};
cadence: "annually" | "monthly";
discount?: Discount;
credit?: Credit;
estimatedTax: number;
};

View File

@@ -0,0 +1,5 @@
export type Credit = {
translationKey: string;
translationParams?: Array<string | number>;
value: number;
};

View File

@@ -61,6 +61,9 @@
@if (sendDetailsForm.get("authType").value === AuthType.Email) {
<bit-hint class="tw-mt-2">{{ "emailVerificationDesc" | i18n }}</bit-hint>
}
@if (sendDetailsForm.get("authType").value === AuthType.Password) {
<bit-hint class="tw-mt-2">{{ "sendPasswordHelperText" | i18n }}</bit-hint>
}
</bit-form-field>
@if (sendDetailsForm.get("authType").value === AuthType.Password) {
@@ -108,7 +111,6 @@
></button>
}
</div>
<bit-hint>{{ "sendPasswordDescV3" | i18n }}</bit-hint>
</bit-form-field>
}

View File

@@ -312,7 +312,7 @@ export class SendDetailsComponent implements OnInit {
const emails = control.value.split(",").map((e: string) => e.trim());
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const invalidEmails = emails.filter((e: string) => e.length > 0 && !emailRegex.test(e));
return invalidEmails.length > 0 ? { email: true } : null;
return invalidEmails.length > 0 ? { multipleEmails: true } : null;
};
}

8
package-lock.json generated
View File

@@ -160,7 +160,7 @@
"path-browserify": "1.0.1",
"postcss": "8.5.6",
"postcss-loader": "8.2.0",
"prettier": "3.7.3",
"prettier": "3.8.1",
"prettier-plugin-tailwindcss": "0.7.1",
"process": "0.11.10",
"remark-gfm": "4.0.1",
@@ -36683,9 +36683,9 @@
}
},
"node_modules/prettier": {
"version": "3.7.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz",
"integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==",
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {

View File

@@ -127,7 +127,7 @@
"path-browserify": "1.0.1",
"postcss": "8.5.6",
"postcss-loader": "8.2.0",
"prettier": "3.7.3",
"prettier": "3.8.1",
"prettier-plugin-tailwindcss": "0.7.1",
"process": "0.11.10",
"remark-gfm": "4.0.1",