1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-07 12:13:45 +00:00

Merge branch 'main' into autofill/pm-21845

This commit is contained in:
Colton Hurst
2025-05-30 14:27:38 -04:00
79 changed files with 842 additions and 572 deletions

View File

@@ -309,7 +309,7 @@ jobs:
- name: Scan Docker image
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
id: container-scan
uses: anchore/scan-action@869c549e657a088dc0441b08ce4fc0ecdac2bb65 # v5.3.0
uses: anchore/scan-action@2c901ab7378897c01b8efaa2d0c9bf519cc64b9e # v6.2.0
with:
image: ${{ steps.image-name.outputs.name }}
fail-build: false

View File

@@ -0,0 +1,33 @@
import { argsToTemplate, StoryObj } from "@storybook/angular";
type RenderArgType<T> = StoryObj<T>["args"];
export const formatArgsForCodeSnippet = <ComponentType extends Record<string, any>>(
args: RenderArgType<ComponentType>,
) => {
const nonNullArgs = Object.entries(args as ComponentType).filter(
([_, value]) => value !== null && value !== undefined,
);
const functionArgs = nonNullArgs.filter(([_, value]) => typeof value === "function");
const argsToFormat = nonNullArgs.filter(([_, value]) => typeof value !== "function");
const argsToTemplateIncludeKeys = [...functionArgs].map(
([key, _]) => key as keyof RenderArgType<ComponentType>,
);
const formattedNonFunctionArgs = argsToFormat
.map(([key, value]) => {
if (typeof value === "boolean") {
return `[${key}]="${value}"`;
}
if (Array.isArray(value)) {
const formattedArray = value.map((v) => `'${v}'`).join(", ");
return `[${key}]="[${formattedArray}]"`;
}
return `${key}="${value}"`;
})
.join(" ");
return `${formattedNonFunctionArgs} ${argsToTemplate(args as ComponentType, { include: argsToTemplateIncludeKeys })}`;
};

View File

@@ -41,7 +41,12 @@ const preview: Preview = {
order: ["Documentation", ["Introduction", "Colors", "Icons"], "Component Library"],
},
},
docs: { source: { type: "dynamic", excludeDecorators: true } },
docs: {
source: {
type: "dynamic",
excludeDecorators: true,
},
},
backgrounds: {
disable: true,
},

View File

@@ -69,8 +69,9 @@ describe("NotificationBackground", () => {
const accountService = mock<AccountService>();
const organizationService = mock<OrganizationService>();
const userId = "testId" as UserId;
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({
id: "testId" as UserId,
id: userId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
@@ -1141,8 +1142,11 @@ describe("NotificationBackground", () => {
convertAddLoginQueueMessageToCipherViewSpy.mockReturnValueOnce(cipherView);
editItemSpy.mockResolvedValueOnce(undefined);
cipherEncryptSpy.mockResolvedValueOnce({
...cipherView,
id: "testId",
cipher: {
...cipherView,
id: "testId",
},
encryptedFor: userId,
});
sendMockExtensionMessage(message, sender);
@@ -1188,6 +1192,13 @@ describe("NotificationBackground", () => {
folderExistsSpy.mockResolvedValueOnce(true);
convertAddLoginQueueMessageToCipherViewSpy.mockReturnValueOnce(cipherView);
editItemSpy.mockResolvedValueOnce(undefined);
cipherEncryptSpy.mockResolvedValueOnce({
cipher: {
...cipherView,
id: "testId",
},
encryptedFor: userId,
});
const errorMessage = "fetch error";
createWithServerSpy.mockImplementation(() => {
throw new Error(errorMessage);

View File

@@ -719,9 +719,10 @@ export default class NotificationBackground {
return;
}
const cipher = await this.cipherService.encrypt(newCipher, activeUserId);
const encrypted = await this.cipherService.encrypt(newCipher, activeUserId);
const { cipher } = encrypted;
try {
await this.cipherService.createWithServer(cipher);
await this.cipherService.createWithServer(encrypted);
await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", {
itemName: newCipher?.name && String(newCipher?.name),
cipherId: cipher?.id && String(cipher?.id),

View File

@@ -442,10 +442,10 @@ export class Fido2Component implements OnInit, OnDestroy {
);
this.buildCipher(name, username);
const cipher = await this.cipherService.encrypt(this.cipher, activeUserId);
const encrypted = await this.cipherService.encrypt(this.cipher, activeUserId);
try {
await this.cipherService.createWithServer(cipher);
this.cipher.id = cipher.id;
await this.cipherService.createWithServer(encrypted);
this.cipher.id = encrypted.cipher.id;
} catch (e) {
this.logService.error(e);
}

View File

@@ -353,7 +353,7 @@ describe("VaultPopupAutofillService", () => {
});
it("should add a URI to the cipher and save with the server", async () => {
const mockEncryptedCipher = {} as Cipher;
const mockEncryptedCipher = { cipher: {} as Cipher, encryptedFor: mockUserId };
mockCipherService.encrypt.mockResolvedValue(mockEncryptedCipher);
const result = await service.doAutofillAndSave(mockCipher);
expect(result).toBe(true);

View File

@@ -19,20 +19,29 @@ then
LGID=65534
fi
# Create user and group
if [ "$(id -u)" = "0" ]; then
# Create user and group
groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
mkhomedir_helper $USERNAME
groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
mkhomedir_helper $USERNAME
# The rest...
# The rest...
chown -R $USERNAME:$GROUPNAME /etc/bitwarden
cp /etc/bitwarden/web/app-id.json /app/app-id.json
chown -R $USERNAME:$GROUPNAME /app
chown -R $USERNAME:$GROUPNAME /bitwarden_server
chown -R $USERNAME:$GROUPNAME /etc/bitwarden
chown -R $USERNAME:$GROUPNAME /app
chown -R $USERNAME:$GROUPNAME /bitwarden_server
exec gosu $USERNAME:$GROUPNAME dotnet /bitwarden_server/Server.dll \
/contentRoot=/app /webRoot=. /serveUnknown=false /webVault=true
gosu_cmd="gosu $USERNAME:$GROUPNAME"
else
gosu_cmd=""
fi
exec $gosu_cmd /bitwarden_server/Server \
/contentRoot=/app \
/webRoot=. \
/serveUnknown=false \
/webVault=true \
/appIdLocation=/etc/bitwarden/web/app-id.json

View File

@@ -3,7 +3,7 @@
<small #actionSpinner [appApiAction]="actionPromise">
<ng-container *ngIf="$any(actionSpinner).loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
@@ -183,10 +183,10 @@
</tr>
</ng-template>
</bit-table>
<div class="no-items" *ngIf="filteredSends && !filteredSends.length">
<div *ngIf="filteredSends && !filteredSends.length">
<ng-container *ngIf="!loaded">
<i
class="bwi bwi-spinner bwi-spin text-muted"
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>

View File

@@ -97,6 +97,7 @@ export type ExposedPasswordDetail = {
* organization member to a cipher
*/
export type MemberDetailsFlat = {
userGuid: string;
userName: string;
email: string;
cipherId: string;

View File

@@ -1,6 +1,7 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class MemberCipherDetailsResponse extends BaseResponse {
userGuid: string;
userName: string;
email: string;
useKeyConnector: boolean;
@@ -8,6 +9,7 @@ export class MemberCipherDetailsResponse extends BaseResponse {
constructor(response: any) {
super(response);
this.userGuid = this.getResponseProperty("UserGuid");
this.userName = this.getResponseProperty("UserName");
this.email = this.getResponseProperty("Email");
this.useKeyConnector = this.getResponseProperty("UseKeyConnector");

View File

@@ -48,7 +48,9 @@ export class RiskInsightsReportService {
const results$ = zip(allCiphers$, memberCiphers$).pipe(
map(([allCiphers, memberCiphers]) => {
const details: MemberDetailsFlat[] = memberCiphers.flatMap((dtl) =>
dtl.cipherIds.map((c) => this.getMemberDetailsFlat(dtl.userName, dtl.email, c)),
dtl.cipherIds.map((c) =>
this.getMemberDetailsFlat(dtl.userGuid, dtl.userName, dtl.email, c),
),
);
return [allCiphers, details] as const;
}),
@@ -408,11 +410,13 @@ export class RiskInsightsReportService {
}
private getMemberDetailsFlat(
userGuid: string,
userName: string,
email: string,
cipherId: string,
): MemberDetailsFlat {
return {
userGuid: userGuid,
userName: userName,
email: email,
cipherId: cipherId,

View File

@@ -44,7 +44,7 @@ describe("lockGuard", () => {
const keyService: MockProxy<KeyService> = mock<KeyService>();
keyService.isLegacyUser.mockResolvedValue(setupParams.isLegacyUser);
keyService.everHadUserKey$ = of(setupParams.everHadUserKey);
keyService.everHadUserKey$.mockReturnValue(of(setupParams.everHadUserKey));
const platformUtilService: MockProxy<PlatformUtilsService> = mock<PlatformUtilsService>();
platformUtilService.getClientType.mockReturnValue(setupParams.clientType);

View File

@@ -84,7 +84,7 @@ export function lockGuard(): CanActivateFn {
}
// If authN user with TDE directly navigates to lock, reject that navigation
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$(activeUser.id));
if (tdeEnabled && !everHadUserKey) {
return false;
}

View File

@@ -2,8 +2,10 @@ import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { KeyService } from "@bitwarden/key-management";
@@ -33,6 +35,7 @@ export function redirectGuard(overrides: Partial<RedirectRoutes> = {}): CanActiv
const authService = inject(AuthService);
const keyService = inject(KeyService);
const deviceTrustService = inject(DeviceTrustServiceAbstraction);
const accountService = inject(AccountService);
const logService = inject(LogService);
const router = inject(Router);
@@ -49,7 +52,8 @@ export function redirectGuard(overrides: Partial<RedirectRoutes> = {}): CanActiv
// If locked, TDE is enabled, and the user hasn't decrypted yet, then redirect to the
// login decryption options component.
const tdeEnabled = await firstValueFrom(deviceTrustService.supportsDeviceTrust$);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$);
const userId = await firstValueFrom(accountService.activeAccount$.pipe(getUserId));
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$(userId));
if (authStatus === AuthenticationStatus.Locked && tdeEnabled && !everHadUserKey) {
logService.info(
"Sending user to TDE decryption options. AuthStatus is %s. TDE support is %s. Ever had user key is %s.",

View File

@@ -0,0 +1,107 @@
import { TestBed } from "@angular/core/testing";
import { Router, provideRouter } from "@angular/router";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
import { tdeDecryptionRequiredGuard } from "./tde-decryption-required.guard";
describe("tdeDecryptionRequiredGuard", () => {
const activeUser: Account = {
id: "fake_user_id" as UserId,
email: "test@email.com",
emailVerified: true,
name: "Test User",
};
const setup = (
activeUser: Account | null,
authStatus: AuthenticationStatus | null = null,
tdeEnabled: boolean = false,
everHadUserKey: boolean = false,
) => {
const accountService = mock<AccountService>();
const authService = mock<AuthService>();
const keyService = mock<KeyService>();
const deviceTrustService = mock<DeviceTrustServiceAbstraction>();
const logService = mock<LogService>();
accountService.activeAccount$ = new BehaviorSubject<Account | null>(activeUser);
if (authStatus !== null) {
authService.getAuthStatus.mockResolvedValue(authStatus);
}
keyService.everHadUserKey$.mockReturnValue(of(everHadUserKey));
deviceTrustService.supportsDeviceTrust$ = of(tdeEnabled);
const testBed = TestBed.configureTestingModule({
providers: [
{ provide: AccountService, useValue: accountService },
{ provide: AuthService, useValue: authService },
{ provide: KeyService, useValue: keyService },
{ provide: DeviceTrustServiceAbstraction, useValue: deviceTrustService },
{ provide: LogService, useValue: logService },
provideRouter([
{ path: "", component: EmptyComponent },
{
path: "protected-route",
component: EmptyComponent,
canActivate: [tdeDecryptionRequiredGuard()],
},
]),
],
});
return {
router: testBed.inject(Router),
};
};
it("redirects to root when the active account is null", async () => {
const { router } = setup(null, null);
await router.navigate(["protected-route"]);
expect(router.url).toBe("/");
});
test.each([AuthenticationStatus.Unlocked, AuthenticationStatus.LoggedOut])(
"redirects to root when the user isn't locked",
async (authStatus) => {
const { router } = setup(activeUser, authStatus);
await router.navigate(["protected-route"]);
expect(router.url).toBe("/");
},
);
it("redirects to root when TDE is not enabled", async () => {
const { router } = setup(activeUser, AuthenticationStatus.Locked, false, true);
await router.navigate(["protected-route"]);
expect(router.url).toBe("/");
});
it("redirects to root when user has had a user key", async () => {
const { router } = setup(activeUser, AuthenticationStatus.Locked, true, true);
await router.navigate(["protected-route"]);
expect(router.url).toBe("/");
});
it("allows access when user is locked, TDE is enabled, and user has never had a user key", async () => {
const { router } = setup(activeUser, AuthenticationStatus.Locked, true, false);
const result = await router.navigate(["protected-route"]);
expect(result).toBe(true);
expect(router.url).toBe("/protected-route");
});
});

View File

@@ -5,8 +5,9 @@ import {
RouterStateSnapshot,
CanActivateFn,
} from "@angular/router";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, map } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
@@ -24,12 +25,18 @@ export function tdeDecryptionRequiredGuard(): CanActivateFn {
const authService = inject(AuthService);
const keyService = inject(KeyService);
const deviceTrustService = inject(DeviceTrustServiceAbstraction);
const accountService = inject(AccountService);
const logService = inject(LogService);
const router = inject(Router);
const userId = await firstValueFrom(accountService.activeAccount$.pipe(map((a) => a?.id)));
if (userId == null) {
return router.createUrlTree(["/"]);
}
const authStatus = await authService.getAuthStatus();
const tdeEnabled = await firstValueFrom(deviceTrustService.supportsDeviceTrust$);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$(userId));
// We need to determine if we should bypass the decryption options and send the user to the vault.
// The ONLY time that we want to send a user to the decryption options is when:

View File

@@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -43,7 +43,7 @@ describe("UnauthGuard", () => {
authService.authStatusFor$.mockReturnValue(activeAccountStatusObservable);
}
keyService.everHadUserKey$ = new BehaviorSubject<boolean>(everHadUserKey);
keyService.everHadUserKey$.mockReturnValue(of(everHadUserKey));
deviceTrustService.supportsDeviceTrustByUserId$.mockReturnValue(
new BehaviorSubject<boolean>(tdeEnabled),
);

View File

@@ -50,7 +50,7 @@ async function unauthGuard(
const tdeEnabled = await firstValueFrom(
deviceTrustService.supportsDeviceTrustByUserId$(activeUser.id),
);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$);
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$(activeUser.id));
// If locked, TDE is enabled, and the user hasn't decrypted yet, then redirect to the
// login decryption options component.

View File

@@ -26,11 +26,13 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
CipherService,
EncryptionContext,
} from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
@@ -740,17 +742,17 @@ export class AddEditComponent implements OnInit, OnDestroy {
return this.cipherService.encrypt(this.cipher, userId);
}
protected saveCipher(cipher: Cipher) {
protected saveCipher(data: EncryptionContext) {
let orgAdmin = this.organization?.canEditAllCiphers;
// if a cipher is unassigned we want to check if they are an admin or have permission to edit any collection
if (!cipher.collectionIds) {
if (!data.cipher.collectionIds) {
orgAdmin = this.organization?.canEditUnassignedCiphers;
}
return this.cipher.id == null
? this.cipherService.createWithServer(cipher, orgAdmin)
: this.cipherService.updateWithServer(cipher, orgAdmin);
? this.cipherService.createWithServer(data, orgAdmin)
: this.cipherService.updateWithServer(data, orgAdmin);
}
protected deleteCipher(userId: UserId) {

View File

@@ -6,7 +6,7 @@ import { BehaviorSubject, of } from "rxjs";
import { mockAccountServiceWith } from "../../../../spec";
import { Account } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { CipherService, EncryptionContext } from "../../../vault/abstractions/cipher.service";
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
import { CipherRepromptType } from "../../../vault/enums/cipher-reprompt-type";
import { CipherType } from "../../../vault/enums/cipher-type";
@@ -36,8 +36,9 @@ type ParentWindowReference = string;
const RpId = "bitwarden.com";
describe("FidoAuthenticatorService", () => {
const userId = "testId" as UserId;
const activeAccountSubject = new BehaviorSubject<Account | null>({
id: "testId" as UserId,
id: userId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
@@ -254,7 +255,7 @@ describe("FidoAuthenticatorService", () => {
cipherId: existingCipher.id,
userVerified: false,
});
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher);
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as EncryptionContext);
await authenticator.makeCredential(params, windowReference);
@@ -325,7 +326,7 @@ describe("FidoAuthenticatorService", () => {
cipherId: existingCipher.id,
userVerified: false,
});
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher);
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as EncryptionContext);
cipherService.updateWithServer.mockRejectedValue(new Error("Internal error"));
const result = async () => await authenticator.makeCredential(params, windowReference);
@@ -357,13 +358,13 @@ describe("FidoAuthenticatorService", () => {
cipherService.decrypt.mockResolvedValue(cipher);
cipherService.encrypt.mockImplementation(async (cipher) => {
cipher.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability
return {} as any;
return { cipher: {} as any as Cipher, encryptedFor: userId };
});
cipherService.createWithServer.mockImplementation(async (cipher) => {
cipherService.createWithServer.mockImplementation(async ({ cipher }) => {
cipher.id = cipherId;
return cipher;
});
cipherService.updateWithServer.mockImplementation(async (cipher) => {
cipherService.updateWithServer.mockImplementation(async ({ cipher }) => {
cipher.id = cipherId;
return cipher;
});

View File

@@ -180,9 +180,7 @@ export class DefaultSdkService implements SdkService {
return () => client?.markForDisposal();
});
}),
tap({
finalize: () => this.sdkClientCache.delete(userId),
}),
tap({ finalize: () => this.sdkClientCache.delete(userId) }),
shareReplay({ refCount: true, bufferSize: 1 }),
);
@@ -205,9 +203,7 @@ export class DefaultSdkService implements SdkService {
method: { decryptedKey: { decrypted_user_key: userKey.keyB64 } },
kdfParams:
kdfParams.kdfType === KdfType.PBKDF2_SHA256
? {
pBKDF2: { iterations: kdfParams.iterations },
}
? { pBKDF2: { iterations: kdfParams.iterations } }
: {
argon2id: {
iterations: kdfParams.iterations,

View File

@@ -21,6 +21,12 @@ import { CipherView } from "../models/view/cipher.view";
import { FieldView } from "../models/view/field.view";
import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
export type EncryptionContext = {
cipher: Cipher;
/** The Id of the user that encrypted the cipher. It should always represent a UserId, even for Organization-owned ciphers */
encryptedFor: UserId;
};
export abstract class CipherService implements UserKeyRotationDataProvider<CipherWithIdRequest> {
abstract cipherViews$(userId: UserId): Observable<CipherView[]>;
abstract ciphers$(userId: UserId): Observable<Record<CipherId, CipherData>>;
@@ -42,7 +48,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
keyForEncryption?: SymmetricCryptoKey,
keyForCipherKeyDecryption?: SymmetricCryptoKey,
originalCipher?: Cipher,
): Promise<Cipher>;
): Promise<EncryptionContext>;
abstract encryptFields(fieldsModel: FieldView[], key: SymmetricCryptoKey): Promise<Field[]>;
abstract encryptField(fieldModel: FieldView, key: SymmetricCryptoKey): Promise<Field>;
abstract get(id: string, userId: UserId): Promise<Cipher>;
@@ -94,7 +100,10 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
*
* @returns A promise that resolves to the created cipher
*/
abstract createWithServer(cipher: Cipher, orgAdmin?: boolean): Promise<Cipher>;
abstract createWithServer(
{ cipher, encryptedFor }: EncryptionContext,
orgAdmin?: boolean,
): Promise<Cipher>;
/**
* Update a cipher with the server
* @param cipher The cipher to update
@@ -104,7 +113,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
* @returns A promise that resolves to the updated cipher
*/
abstract updateWithServer(
cipher: Cipher,
{ cipher, encryptedFor }: EncryptionContext,
orgAdmin?: boolean,
isNotClone?: boolean,
): Promise<Cipher>;

View File

@@ -7,7 +7,7 @@ export class FieldData {
type: FieldType;
name: string;
value: string;
linkedId: LinkedIdType;
linkedId: LinkedIdType | null;
constructor(response?: FieldApi) {
if (response == null) {

View File

@@ -1,5 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { UserId } from "../../../types/guid";
import { Cipher } from "../domain/cipher";
import { CipherWithIdRequest } from "./cipher-with-id.request";
@@ -8,11 +9,15 @@ export class CipherBulkShareRequest {
ciphers: CipherWithIdRequest[];
collectionIds: string[];
constructor(ciphers: Cipher[], collectionIds: string[]) {
constructor(
ciphers: Cipher[],
collectionIds: string[],
readonly encryptedFor: UserId,
) {
if (ciphers != null) {
this.ciphers = [];
ciphers.forEach((c) => {
this.ciphers.push(new CipherWithIdRequest(c));
this.ciphers.push(new CipherWithIdRequest({ cipher: c, encryptedFor }));
});
}
this.collectionIds = collectionIds;

View File

@@ -1,4 +1,4 @@
import { Cipher } from "../domain/cipher";
import { EncryptionContext } from "../../abstractions/cipher.service";
import { CipherRequest } from "./cipher.request";
@@ -6,8 +6,8 @@ export class CipherCreateRequest {
cipher: CipherRequest;
collectionIds: string[];
constructor(cipher: Cipher) {
this.cipher = new CipherRequest(cipher);
constructor({ cipher, encryptedFor }: EncryptionContext) {
this.cipher = new CipherRequest({ cipher, encryptedFor });
this.collectionIds = cipher.collectionIds;
}
}

View File

@@ -1,4 +1,4 @@
import { Cipher } from "../domain/cipher";
import { EncryptionContext } from "../../abstractions/cipher.service";
import { CipherRequest } from "./cipher.request";
@@ -6,8 +6,8 @@ export class CipherShareRequest {
cipher: CipherRequest;
collectionIds: string[];
constructor(cipher: Cipher) {
this.cipher = new CipherRequest(cipher);
constructor({ cipher, encryptedFor }: EncryptionContext) {
this.cipher = new CipherRequest({ cipher, encryptedFor });
this.collectionIds = cipher.collectionIds;
}
}

View File

@@ -1,12 +1,12 @@
import { Cipher } from "../domain/cipher";
import { EncryptionContext } from "../../abstractions/cipher.service";
import { CipherRequest } from "./cipher.request";
export class CipherWithIdRequest extends CipherRequest {
id: string;
constructor(cipher: Cipher) {
super(cipher);
constructor({ cipher, encryptedFor }: EncryptionContext) {
super({ cipher, encryptedFor });
this.id = cipher.id;
}
}

View File

@@ -1,5 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { UserId } from "../../../types/guid";
import { EncryptionContext } from "../../abstractions/cipher.service";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CipherType } from "../../enums/cipher-type";
import { CardApi } from "../api/card.api";
@@ -10,12 +12,12 @@ import { LoginUriApi } from "../api/login-uri.api";
import { LoginApi } from "../api/login.api";
import { SecureNoteApi } from "../api/secure-note.api";
import { SshKeyApi } from "../api/ssh-key.api";
import { Cipher } from "../domain/cipher";
import { AttachmentRequest } from "./attachment.request";
import { PasswordHistoryRequest } from "./password-history.request";
export class CipherRequest {
encryptedFor: UserId;
type: CipherType;
folderId: string;
organizationId: string;
@@ -36,8 +38,9 @@ export class CipherRequest {
reprompt: CipherRepromptType;
key: string;
constructor(cipher: Cipher) {
constructor({ cipher, encryptedFor }: EncryptionContext) {
this.type = cipher.type;
this.encryptedFor = encryptedFor;
this.folderId = cipher.folderId;
this.organizationId = cipher.organizationId;
this.name = cipher.name ? cipher.name.encryptedString : null;

View File

@@ -25,7 +25,7 @@ export class CipherView implements View, InitializerMetadata {
readonly initializerKey = InitializerKey.CipherView;
id: string = null;
organizationId: string = null;
organizationId: string | undefined = null;
folderId: string = null;
name: string = null;
notes: string = null;

View File

@@ -27,6 +27,7 @@ import { ContainerService } from "../../platform/services/container.service";
import { CipherId, UserId } from "../../types/guid";
import { CipherKey, OrgKey, UserKey } from "../../types/key";
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
import { EncryptionContext } from "../abstractions/cipher.service";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
import { FieldType } from "../enums";
import { CipherRepromptType } from "../enums/cipher-reprompt-type";
@@ -78,36 +79,12 @@ const cipherData: CipherData = {
},
passwordHistory: [{ password: "EncryptedString", lastUsedDate: "2022-01-31T12:00:00.000Z" }],
attachments: [
{
id: "a1",
url: "url",
size: "1100",
sizeName: "1.1 KB",
fileName: "file",
key: "EncKey",
},
{
id: "a2",
url: "url",
size: "1100",
sizeName: "1.1 KB",
fileName: "file",
key: "EncKey",
},
{ id: "a1", url: "url", size: "1100", sizeName: "1.1 KB", fileName: "file", key: "EncKey" },
{ id: "a2", url: "url", size: "1100", sizeName: "1.1 KB", fileName: "file", key: "EncKey" },
],
fields: [
{
name: "EncryptedString",
value: "EncryptedString",
type: FieldType.Text,
linkedId: null,
},
{
name: "EncryptedString",
value: "EncryptedString",
type: FieldType.Hidden,
linkedId: null,
},
{ name: "EncryptedString", value: "EncryptedString", type: FieldType.Text, linkedId: null },
{ name: "EncryptedString", value: "EncryptedString", type: FieldType.Hidden, linkedId: null },
],
};
const mockUserId = Utils.newGuid() as UserId;
@@ -133,7 +110,7 @@ describe("Cipher Service", () => {
const userId = "TestUserId" as UserId;
let cipherService: CipherService;
let cipherObj: Cipher;
let encryptionContext: EncryptionContext;
beforeEach(() => {
encryptService.encryptFileData.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES));
@@ -159,7 +136,7 @@ describe("Cipher Service", () => {
cipherEncryptionService,
);
cipherObj = new Cipher(cipherData);
encryptionContext = { cipher: new Cipher(cipherData), encryptedFor: userId };
});
afterEach(() => {
@@ -192,33 +169,33 @@ describe("Cipher Service", () => {
it("should call apiService.postCipherAdmin when orgAdmin param is true and the cipher orgId != null", async () => {
const spy = jest
.spyOn(apiService, "postCipherAdmin")
.mockImplementation(() => Promise.resolve<any>(cipherObj.toCipherData()));
await cipherService.createWithServer(cipherObj, true);
const expectedObj = new CipherCreateRequest(cipherObj);
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.createWithServer(encryptionContext, true);
const expectedObj = new CipherCreateRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(expectedObj);
});
it("should call apiService.postCipher when orgAdmin param is true and the cipher orgId is null", async () => {
cipherObj.organizationId = null;
encryptionContext.cipher.organizationId = null!;
const spy = jest
.spyOn(apiService, "postCipher")
.mockImplementation(() => Promise.resolve<any>(cipherObj.toCipherData()));
await cipherService.createWithServer(cipherObj, true);
const expectedObj = new CipherRequest(cipherObj);
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.createWithServer(encryptionContext, true);
const expectedObj = new CipherRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(expectedObj);
});
it("should call apiService.postCipherCreate if collectionsIds != null", async () => {
cipherObj.collectionIds = ["123"];
encryptionContext.cipher.collectionIds = ["123"];
const spy = jest
.spyOn(apiService, "postCipherCreate")
.mockImplementation(() => Promise.resolve<any>(cipherObj.toCipherData()));
await cipherService.createWithServer(cipherObj);
const expectedObj = new CipherCreateRequest(cipherObj);
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.createWithServer(encryptionContext);
const expectedObj = new CipherCreateRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(expectedObj);
@@ -227,9 +204,9 @@ describe("Cipher Service", () => {
it("should call apiService.postCipher when orgAdmin and collectionIds logic is false", async () => {
const spy = jest
.spyOn(apiService, "postCipher")
.mockImplementation(() => Promise.resolve<any>(cipherObj.toCipherData()));
await cipherService.createWithServer(cipherObj);
const expectedObj = new CipherRequest(cipherObj);
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.createWithServer(encryptionContext);
const expectedObj = new CipherRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(expectedObj);
@@ -240,36 +217,36 @@ describe("Cipher Service", () => {
it("should call apiService.putCipherAdmin when orgAdmin param is true", async () => {
const spy = jest
.spyOn(apiService, "putCipherAdmin")
.mockImplementation(() => Promise.resolve<any>(cipherObj.toCipherData()));
await cipherService.updateWithServer(cipherObj, true);
const expectedObj = new CipherRequest(cipherObj);
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.updateWithServer(encryptionContext, true);
const expectedObj = new CipherRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(cipherObj.id, expectedObj);
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
});
it("should call apiService.putCipher if cipher.edit is true", async () => {
cipherObj.edit = true;
encryptionContext.cipher.edit = true;
const spy = jest
.spyOn(apiService, "putCipher")
.mockImplementation(() => Promise.resolve<any>(cipherObj.toCipherData()));
await cipherService.updateWithServer(cipherObj);
const expectedObj = new CipherRequest(cipherObj);
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.updateWithServer(encryptionContext);
const expectedObj = new CipherRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(cipherObj.id, expectedObj);
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
});
it("should call apiService.putPartialCipher when orgAdmin, and edit are false", async () => {
cipherObj.edit = false;
encryptionContext.cipher.edit = false;
const spy = jest
.spyOn(apiService, "putPartialCipher")
.mockImplementation(() => Promise.resolve<any>(cipherObj.toCipherData()));
await cipherService.updateWithServer(cipherObj);
const expectedObj = new CipherPartialRequest(cipherObj);
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.updateWithServer(encryptionContext);
const expectedObj = new CipherPartialRequest(encryptionContext.cipher);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(cipherObj.id, expectedObj);
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
});
});
@@ -293,6 +270,15 @@ describe("Cipher Service", () => {
jest.spyOn(cipherService as any, "getAutofillOnPageLoadDefault").mockResolvedValue(true);
});
it("should return the encrypting user id", async () => {
keyService.getOrgKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
const { encryptedFor } = await cipherService.encrypt(cipherView, userId);
expect(encryptedFor).toEqual(userId);
});
describe("login encryption", () => {
it("should add a uri hash to login uris", async () => {
encryptService.hash.mockImplementation((value) => Promise.resolve(`${value} hash`));
@@ -304,9 +290,9 @@ describe("Cipher Service", () => {
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
const domain = await cipherService.encrypt(cipherView, userId);
const { cipher } = await cipherService.encrypt(cipherView, userId);
expect(domain.login.uris).toEqual([
expect(cipher.login.uris).toEqual([
{
uri: new EncString("uri has been encrypted"),
uriChecksum: new EncString("uri hash has been encrypted"),
@@ -325,7 +311,7 @@ describe("Cipher Service", () => {
it("is null when feature flag is false", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
const cipher = await cipherService.encrypt(cipherView, userId);
const { cipher } = await cipherService.encrypt(cipherView, userId);
expect(cipher.key).toBeNull();
});
@@ -338,7 +324,7 @@ describe("Cipher Service", () => {
it("is null when the cipher is not viewPassword", async () => {
cipherView.viewPassword = false;
const cipher = await cipherService.encrypt(cipherView, userId);
const { cipher } = await cipherService.encrypt(cipherView, userId);
expect(cipher.key).toBeNull();
});
@@ -346,7 +332,7 @@ describe("Cipher Service", () => {
it("is defined when the cipher is viewPassword", async () => {
cipherView.viewPassword = true;
const cipher = await cipherService.encrypt(cipherView, userId);
const { cipher } = await cipherService.encrypt(cipherView, userId);
expect(cipher.key).toBeDefined();
});
@@ -393,7 +379,13 @@ describe("Cipher Service", () => {
it("is called when cipher viewPassword is false and original cipher has a key", async () => {
cipherView.viewPassword = false;
await cipherService.encrypt(cipherView, userId, undefined, undefined, cipherObj);
await cipherService.encrypt(
cipherView,
userId,
undefined,
undefined,
encryptionContext.cipher,
);
expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled();
});
@@ -416,22 +408,17 @@ describe("Cipher Service", () => {
stateService.getUserId.mockResolvedValue(mockUserId);
const keys = {
userKey: originalUserKey,
} as CipherDecryptionKeys;
const keys = { userKey: originalUserKey } as CipherDecryptionKeys;
keyService.cipherDecryptionKeys$.mockReturnValue(of(keys));
const cipher1 = new CipherView(cipherObj);
cipher1.id = "Cipher 1";
const cipher1 = new CipherView(encryptionContext.cipher);
cipher1.id = "Cipher 1" as CipherId;
cipher1.organizationId = null;
const cipher2 = new CipherView(cipherObj);
cipher2.id = "Cipher 2";
const cipher2 = new CipherView(encryptionContext.cipher);
cipher2.id = "Cipher 2" as CipherId;
cipher2.organizationId = null;
decryptedCiphers = new BehaviorSubject({
Cipher1: cipher1,
Cipher2: cipher2,
});
decryptedCiphers = new BehaviorSubject({ [cipher1.id]: cipher1, [cipher2.id]: cipher2 });
jest
.spyOn(cipherService, "cipherViews$")
.mockImplementation((userId: UserId) =>
@@ -462,19 +449,19 @@ describe("Cipher Service", () => {
});
it("throws if the original user key is null", async () => {
await expect(cipherService.getRotatedData(null, newUserKey, mockUserId)).rejects.toThrow(
await expect(cipherService.getRotatedData(null!, newUserKey, mockUserId)).rejects.toThrow(
"Original user key is required to rotate ciphers",
);
});
it("throws if the new user key is null", async () => {
await expect(cipherService.getRotatedData(originalUserKey, null, mockUserId)).rejects.toThrow(
"New user key is required to rotate ciphers",
);
await expect(
cipherService.getRotatedData(originalUserKey, null!, mockUserId),
).rejects.toThrow("New user key is required to rotate ciphers");
});
it("throws if the user has any failed to decrypt ciphers", async () => {
const badCipher = new CipherView(cipherObj);
const badCipher = new CipherView(encryptionContext.cipher);
badCipher.id = "Cipher 3";
badCipher.organizationId = null;
badCipher.decryptionFailure = true;
@@ -488,12 +475,15 @@ describe("Cipher Service", () => {
describe("decrypt", () => {
it("should call decrypt method of CipherEncryptionService when feature flag is true", async () => {
configService.getFeatureFlag.mockResolvedValue(true);
cipherEncryptionService.decrypt.mockResolvedValue(new CipherView(cipherObj));
cipherEncryptionService.decrypt.mockResolvedValue(new CipherView(encryptionContext.cipher));
const result = await cipherService.decrypt(cipherObj, userId);
const result = await cipherService.decrypt(encryptionContext.cipher, userId);
expect(result).toEqual(new CipherView(cipherObj));
expect(cipherEncryptionService.decrypt).toHaveBeenCalledWith(cipherObj, userId);
expect(result).toEqual(new CipherView(encryptionContext.cipher));
expect(cipherEncryptionService.decrypt).toHaveBeenCalledWith(
encryptionContext.cipher,
userId,
);
});
it("should call legacy decrypt when feature flag is false", async () => {
@@ -501,12 +491,14 @@ describe("Cipher Service", () => {
configService.getFeatureFlag.mockResolvedValue(false);
cipherService.getKeyForCipherKeyDecryption = jest.fn().mockResolvedValue(mockUserKey);
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
jest.spyOn(cipherObj, "decrypt").mockResolvedValue(new CipherView(cipherObj));
jest
.spyOn(encryptionContext.cipher, "decrypt")
.mockResolvedValue(new CipherView(encryptionContext.cipher));
const result = await cipherService.decrypt(cipherObj, userId);
const result = await cipherService.decrypt(encryptionContext.cipher, userId);
expect(result).toEqual(new CipherView(cipherObj));
expect(cipherObj.decrypt).toHaveBeenCalledWith(mockUserKey);
expect(result).toEqual(new CipherView(encryptionContext.cipher));
expect(encryptionContext.cipher.decrypt).toHaveBeenCalledWith(mockUserKey);
});
});

View File

@@ -33,7 +33,10 @@ import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid
import { OrgKey, UserKey } from "../../types/key";
import { filterOutNullish, perUserCache$ } from "../../vault/utils/observable-utilities";
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service";
import {
CipherService as CipherServiceAbstraction,
EncryptionContext,
} from "../abstractions/cipher.service";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
import { FieldType } from "../enums";
import { CipherType } from "../enums/cipher-type";
@@ -196,7 +199,7 @@ export class CipherService implements CipherServiceAbstraction {
keyForCipherEncryption?: SymmetricCryptoKey,
keyForCipherKeyDecryption?: SymmetricCryptoKey,
originalCipher: Cipher = null,
): Promise<Cipher> {
): Promise<EncryptionContext> {
if (model.id != null) {
if (originalCipher == null) {
originalCipher = await this.get(model.id, userId);
@@ -230,18 +233,24 @@ export class CipherService implements CipherServiceAbstraction {
keyForCipherEncryption ||= userOrOrgKey;
// If the caller has provided a key for cipher key decryption, use it. Otherwise, use the user or org key.
keyForCipherKeyDecryption ||= userOrOrgKey;
return this.encryptCipherWithCipherKey(
model,
cipher,
keyForCipherEncryption,
keyForCipherKeyDecryption,
);
return {
cipher: await this.encryptCipherWithCipherKey(
model,
cipher,
keyForCipherEncryption,
keyForCipherKeyDecryption,
),
encryptedFor: userId,
};
} else {
keyForCipherEncryption ||= await this.getKeyForCipherKeyDecryption(cipher, userId);
// We want to ensure that the cipher key is null if cipher key encryption is disabled
// so that decryption uses the proper key.
cipher.key = null;
return this.encryptCipher(model, cipher, keyForCipherEncryption);
return {
cipher: await this.encryptCipher(model, cipher, keyForCipherEncryption),
encryptedFor: userId,
};
}
}
@@ -261,19 +270,14 @@ export class CipherService implements CipherServiceAbstraction {
attachment.size = model.size;
attachment.sizeName = model.sizeName;
attachment.url = model.url;
const promise = this.encryptObjProperty(
model,
attachment,
{
fileName: null,
const promise = this.encryptObjProperty(model, attachment, { fileName: null }, key).then(
async () => {
if (model.key != null) {
attachment.key = await this.encryptService.wrapSymmetricKey(model.key, key);
}
encAttachments.push(attachment);
},
key,
).then(async () => {
if (model.key != null) {
attachment.key = await this.encryptService.wrapSymmetricKey(model.key, key);
}
encAttachments.push(attachment);
});
);
promises.push(promise);
});
@@ -306,15 +310,7 @@ export class CipherService implements CipherServiceAbstraction {
fieldModel.value = "false";
}
await this.encryptObjProperty(
fieldModel,
field,
{
name: null,
value: null,
},
key,
);
await this.encryptObjProperty(fieldModel, field, { name: null, value: null }, key);
return field;
}
@@ -345,14 +341,7 @@ export class CipherService implements CipherServiceAbstraction {
const ph = new Password();
ph.lastUsedDate = phModel.lastUsedDate;
await this.encryptObjProperty(
phModel,
ph,
{
password: null,
},
key,
);
await this.encryptObjProperty(phModel, ph, { password: null }, key);
return ph;
}
@@ -705,9 +694,7 @@ export class CipherService implements CipherServiceAbstraction {
if (ciphersLocalData[cipherId]) {
ciphersLocalData[cipherId].lastUsedDate = new Date().getTime();
} else {
ciphersLocalData[cipherId] = {
lastUsedDate: new Date().getTime(),
};
ciphersLocalData[cipherId] = { lastUsedDate: new Date().getTime() };
}
await this.localDataState(userId).update(() => ciphersLocalData);
@@ -735,10 +722,7 @@ export class CipherService implements CipherServiceAbstraction {
}
const currentTime = new Date().getTime();
ciphersLocalData[id as CipherId] = {
lastLaunched: currentTime,
lastUsedDate: currentTime,
};
ciphersLocalData[id as CipherId] = { lastLaunched: currentTime, lastUsedDate: currentTime };
await this.localDataState(userId).update(() => ciphersLocalData);
@@ -770,18 +754,21 @@ export class CipherService implements CipherServiceAbstraction {
await this.domainSettingsService.setNeverDomains(domains);
}
async createWithServer(cipher: Cipher, orgAdmin?: boolean): Promise<Cipher> {
async createWithServer(
{ cipher, encryptedFor }: EncryptionContext,
orgAdmin?: boolean,
): Promise<Cipher> {
let response: CipherResponse;
if (orgAdmin && cipher.organizationId != null) {
const request = new CipherCreateRequest(cipher);
const request = new CipherCreateRequest({ cipher, encryptedFor });
response = await this.apiService.postCipherAdmin(request);
const data = new CipherData(response, cipher.collectionIds);
return new Cipher(data);
} else if (cipher.collectionIds != null) {
const request = new CipherCreateRequest(cipher);
const request = new CipherCreateRequest({ cipher, encryptedFor });
response = await this.apiService.postCipherCreate(request);
} else {
const request = new CipherRequest(cipher);
const request = new CipherRequest({ cipher, encryptedFor });
response = await this.apiService.postCipher(request);
}
cipher.id = response.id;
@@ -792,15 +779,18 @@ export class CipherService implements CipherServiceAbstraction {
return new Cipher(updated[cipher.id as CipherId]);
}
async updateWithServer(cipher: Cipher, orgAdmin?: boolean): Promise<Cipher> {
async updateWithServer(
{ cipher, encryptedFor }: EncryptionContext,
orgAdmin?: boolean,
): Promise<Cipher> {
let response: CipherResponse;
if (orgAdmin) {
const request = new CipherRequest(cipher);
const request = new CipherRequest({ cipher, encryptedFor });
response = await this.apiService.putCipherAdmin(cipher.id, request);
const data = new CipherData(response, cipher.collectionIds);
return new Cipher(data, cipher.localData);
} else if (cipher.edit) {
const request = new CipherRequest(cipher);
const request = new CipherRequest({ cipher, encryptedFor });
response = await this.apiService.putCipher(cipher.id, request);
} else {
const request = new CipherPartialRequest(cipher);
@@ -854,12 +844,12 @@ export class CipherService implements CipherServiceAbstraction {
cipher.collectionIds = collectionIds;
promises.push(
this.encryptSharedCipher(cipher, userId).then((c) => {
encCiphers.push(c);
encCiphers.push(c.cipher);
}),
);
}
await Promise.all(promises);
const request = new CipherBulkShareRequest(encCiphers, collectionIds);
const request = new CipherBulkShareRequest(encCiphers, collectionIds, userId);
try {
await this.apiService.putShareCiphers(request);
} catch (e) {
@@ -921,8 +911,8 @@ export class CipherService implements CipherServiceAbstraction {
//in order to keep item and it's attachments with the same encryption level
if (cipher.key != null && !cipherKeyEncryptionEnabled) {
const model = await this.decrypt(cipher, userId);
cipher = await this.encrypt(model, userId);
await this.updateWithServer(cipher);
const reEncrypted = await this.encrypt(model, userId);
await this.updateWithServer(reEncrypted);
}
const encFileName = await this.encryptService.encryptString(filename, cipherEncKey);
@@ -1482,7 +1472,7 @@ export class CipherService implements CipherServiceAbstraction {
// In the case of a cipher that is being shared with an organization, we want to decrypt the
// cipher key with the user's key and then re-encrypt it with the organization's key.
private async encryptSharedCipher(model: CipherView, userId: UserId): Promise<Cipher> {
private async encryptSharedCipher(model: CipherView, userId: UserId): Promise<EncryptionContext> {
const keyForCipherKeyDecryption = await this.keyService.getUserKeyWithLegacySupport(userId);
return await this.encrypt(model, userId, null, keyForCipherKeyDecryption);
}
@@ -1584,10 +1574,7 @@ export class CipherService implements CipherServiceAbstraction {
fd.append(
"data",
Buffer.from(encData.buffer) as any,
{
filepath: encFileName.encryptedString,
contentType: "application/octet-stream",
} as any,
{ filepath: encFileName.encryptedString, contentType: "application/octet-stream" } as any,
);
} else {
throw e;
@@ -1649,11 +1636,7 @@ export class CipherService implements CipherServiceAbstraction {
await this.encryptObjProperty(
model.login,
cipher.login,
{
username: null,
password: null,
totp: null,
},
{ username: null, password: null, totp: null },
key,
);
@@ -1663,14 +1646,7 @@ export class CipherService implements CipherServiceAbstraction {
for (let i = 0; i < model.login.uris.length; i++) {
const loginUri = new LoginUri();
loginUri.match = model.login.uris[i].match;
await this.encryptObjProperty(
model.login.uris[i],
loginUri,
{
uri: null,
},
key,
);
await this.encryptObjProperty(model.login.uris[i], loginUri, { uri: null }, key);
const uriHash = await this.encryptService.hash(model.login.uris[i].uri, "sha256");
loginUri.uriChecksum = await this.encryptService.encryptString(uriHash, key);
cipher.login.uris.push(loginUri);
@@ -1766,11 +1742,7 @@ export class CipherService implements CipherServiceAbstraction {
await this.encryptObjProperty(
model.sshKey,
cipher.sshKey,
{
privateKey: null,
publicKey: null,
keyFingerprint: null,
},
{ privateKey: null, publicKey: null, keyFingerprint: null },
key,
);
return;
@@ -1855,15 +1827,7 @@ export class CipherService implements CipherServiceAbstraction {
}
await Promise.all([
this.encryptObjProperty(
model,
cipher,
{
name: null,
notes: null,
},
key,
),
this.encryptObjProperty(model, cipher, { name: null, notes: null }, key),
this.encryptCipherData(cipher, model, key),
this.encryptFields(model.fields, key).then((fields) => {
cipher.fields = fields;

View File

@@ -16,6 +16,12 @@ const SizeClasses: Record<SizeTypes, string[]> = {
xsmall: ["tw-h-6", "tw-w-6"],
};
/**
* Avatars display a unique color that helps a user visually recognize their logged in account.
* A variance in color across the avatar component is important as it is used in Account Switching as a
* visual indicator to recognize which of a personal or work account a user is logged into.
*/
@Component({
selector: "bit-avatar",
template: `@if (src) {

View File

@@ -1,15 +1,15 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Description, Meta, Canvas, Primary, Controls, Title } from "@storybook/addon-docs";
import * as stories from "./avatar.stories";
<Meta of={stories} />
# Avatar
```ts
import { AvatarModule } from "@bitwarden/components";
```
Avatars display a unique color that helps a user visually recognize their logged in account.
A variance in color across the avatar component is important as it is used in Account Switching as a
visual indicator to recognize which of a personal or work account a user is logged into.
<Title />
<Description />
<Primary />
<Controls />

View File

@@ -1,5 +1,7 @@
import { Meta, StoryObj } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { AvatarComponent } from "./avatar.component";
export default {
@@ -21,42 +23,56 @@ export default {
type Story = StoryObj<AvatarComponent>;
export const Default: Story = {
render: (args) => {
return {
props: args,
template: `
<bit-avatar ${formatArgsForCodeSnippet<AvatarComponent>(args)}></bit-avatar>
`,
};
},
args: {
color: "#175ddc",
},
};
export const Large: Story = {
...Default,
args: {
size: "large",
},
};
export const Small: Story = {
...Default,
args: {
size: "small",
},
};
export const LightBackground: Story = {
...Default,
args: {
color: "#d2ffcf",
},
};
export const Border: Story = {
...Default,
args: {
border: true,
},
};
export const ColorByID: Story = {
...Default,
args: {
id: "236478",
},
};
export const ColorByText: Story = {
...Default,
args: {
text: "Jason Doe",
},

View File

@@ -2,6 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { BadgeModule } from "../badge";
import { SharedModule } from "../shared";
import { I18nMockService } from "../utils/i18n-mock.service";
@@ -44,7 +45,7 @@ export const Default: Story = {
render: (args) => ({
props: args,
template: `
<bit-badge-list [variant]="variant" [maxItems]="maxItems" [items]="items" [truncate]="truncate"></bit-badge-list>
<bit-badge-list ${formatArgsForCodeSnippet<BadgeListComponent>(args)}></bit-badge-list>
`,
}),

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, ElementRef, HostBinding, Input } from "@angular/core";
@@ -45,7 +43,18 @@ const hoverStyles: Record<BadgeVariant, string[]> = {
"hover:!tw-text-contrast",
],
};
/**
* Badges are primarily used as labels, counters, and small buttons.
* Typically Badges are only used with text set to `text-xs`. If additional sizes are needed, the component configurations may be reviewed and adjusted.
* The Badge directive can be used on a `<span>` (non clickable events), or an `<a>` or `<button>` tag
* > `NOTE:` The Focus and Hover states only apply to badges used for interactive events.
*
* > `NOTE:` The `disabled` state only applies to buttons.
*
*/
@Component({
selector: "span[bitBadge], a[bitBadge], button[bitBadge]",
providers: [{ provide: FocusableElement, useExisting: BadgeComponent }],
@@ -89,7 +98,7 @@ export class BadgeComponent implements FocusableElement {
if (this.title !== undefined) {
return this.title;
}
return this.truncate ? this.el.nativeElement.textContent.trim() : null;
return this.truncate ? this?.el?.nativeElement?.textContent?.trim() : null;
}
/**

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./badge.stories";
@@ -8,25 +8,15 @@ import * as stories from "./badge.stories";
import { BadgeModule } from "@bitwarden/components";
```
# Badge
Badges are primarily used as labels, counters, and small buttons.
Typically Badges are only used with text set to `text-xs`. If additional sizes are needed, the
component configurations may be reviewed and adjusted.
The Badge directive can be used on a `<span>` (non clickable events), or an `<a>` or `<button>` tag
for interactive events. The Focus and Hover states only apply to badges used for interactive events.
The `disabled` state only applies to buttons.
The story below uses the `<button>` element to demonstrate all the possible states.
<Title />
<Description />
<Primary />
<Controls />
## Styles
### Primary
### Default / Primary
The primary badge is used to indicate an active status (example: device management page) or provide
additional information (example: type of emergency access granted).

View File

@@ -1,6 +1,8 @@
import { CommonModule } from "@angular/common";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { BadgeComponent } from "./badge.component";
export default {
@@ -12,7 +14,6 @@ export default {
}),
],
args: {
variant: "primary",
truncate: false,
},
parameters: {
@@ -25,45 +26,11 @@ export default {
type Story = StoryObj<BadgeComponent>;
export const Variants: Story = {
export const Default: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<span class="tw-text-main tw-mx-1">Default</span>
<button class="tw-mx-1" bitBadge variant="primary" [truncate]="truncate">Primary</button>
<button class="tw-mx-1" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
<button class="tw-mx-1" bitBadge variant="success" [truncate]="truncate">Success</button>
<button class="tw-mx-1" bitBadge variant="danger" [truncate]="truncate">Danger</button>
<button class="tw-mx-1" bitBadge variant="warning" [truncate]="truncate">Warning</button>
<button class="tw-mx-1" bitBadge variant="info" [truncate]="truncate">Info</button>
<button class="tw-mx-1" bitBadge variant="notification" [truncate]="truncate">Notification</button>
<br/><br/>
<span class="tw-text-main tw-mx-1">Hover</span>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="primary" [truncate]="truncate">Primary</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="success" [truncate]="truncate">Success</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="danger" [truncate]="truncate">Danger</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="warning" [truncate]="truncate">Warning</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="info" [truncate]="truncate">Info</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="notification" [truncate]="truncate">Notification</button>
<br/><br/>
<span class="tw-text-main tw-mx-1">Focus Visible</span>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="primary" [truncate]="truncate">Primary</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="success" [truncate]="truncate">Success</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="danger" [truncate]="truncate">Danger</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="warning" [truncate]="truncate">Warning</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="info" [truncate]="truncate">Info</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="notification" [truncate]="truncate">Notification</button>
<br/><br/>
<span class="tw-text-main tw-mx-1">Disabled</span>
<button disabled class="tw-mx-1" bitBadge variant="primary" [truncate]="truncate">Primary</button>
<button disabled class="tw-mx-1" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
<button disabled class="tw-mx-1" bitBadge variant="success" [truncate]="truncate">Success</button>
<button disabled class="tw-mx-1" bitBadge variant="danger" [truncate]="truncate">Danger</button>
<button disabled class="tw-mx-1" bitBadge variant="warning" [truncate]="truncate">Warning</button>
<button disabled class="tw-mx-1" bitBadge variant="info" [truncate]="truncate">Info</button>
<button disabled class="tw-mx-1" bitBadge variant="notification" [truncate]="truncate">Notification</button>
<span bitBadge ${formatArgsForCodeSnippet<BadgeComponent>(args)}>Badge containing lengthy text</span>
`,
}),
};
@@ -72,11 +39,17 @@ export const Primary: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<span class="tw-text-main">Span </span><span bitBadge [variant]="variant" [truncate]="truncate">Badge containing lengthy text</span>
<br /><br />
<span class="tw-text-main">Link </span><a href="#" bitBadge [variant]="variant" [truncate]="truncate">Badge</a>
<br /><br />
<span class="tw-text-main">Button </span><button bitBadge [variant]="variant" [truncate]="truncate">Badge</button>
<div class="tw-flex tw-flex-col tw-gap-4">
<div class="tw-flex tw-items-center tw-gap-2">
<span class="tw-text-main">span</span><span bitBadge ${formatArgsForCodeSnippet<BadgeComponent>(args)}>Badge containing lengthy text</span>
</div>
<div class="tw-flex tw-items-center tw-gap-2">
<span class="tw-text-main">link </span><a href="#" bitBadge ${formatArgsForCodeSnippet<BadgeComponent>(args)}>Badge</a>
</div>
<div class="tw-flex tw-items-center tw-gap-2">
<span class="tw-text-main">button </span><button bitBadge ${formatArgsForCodeSnippet<BadgeComponent>(args)}>Badge</button>
</div>
</div>
`,
}),
};
@@ -129,3 +102,46 @@ export const Truncated: Story = {
truncate: true,
},
};
export const VariantsAndInteractionStates: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<span class="tw-text-main tw-mx-1">Default</span>
<button class="tw-mx-1" bitBadge variant="primary" [truncate]="truncate">Primary</button>
<button class="tw-mx-1" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
<button class="tw-mx-1" bitBadge variant="success" [truncate]="truncate">Success</button>
<button class="tw-mx-1" bitBadge variant="danger" [truncate]="truncate">Danger</button>
<button class="tw-mx-1" bitBadge variant="warning" [truncate]="truncate">Warning</button>
<button class="tw-mx-1" bitBadge variant="info" [truncate]="truncate">Info</button>
<button class="tw-mx-1" bitBadge variant="notification" [truncate]="truncate">Notification</button>
<br/><br/>
<span class="tw-text-main tw-mx-1">Hover</span>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="primary" [truncate]="truncate">Primary</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="success" [truncate]="truncate">Success</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="danger" [truncate]="truncate">Danger</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="warning" [truncate]="truncate">Warning</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="info" [truncate]="truncate">Info</button>
<button class="tw-mx-1 tw-test-hover" bitBadge variant="notification" [truncate]="truncate">Notification</button>
<br/><br/>
<span class="tw-text-main tw-mx-1">Focus Visible</span>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="primary" [truncate]="truncate">Primary</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="success" [truncate]="truncate">Success</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="danger" [truncate]="truncate">Danger</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="warning" [truncate]="truncate">Warning</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="info" [truncate]="truncate">Info</button>
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="notification" [truncate]="truncate">Notification</button>
<br/><br/>
<span class="tw-text-main tw-mx-1">Disabled</span>
<button disabled class="tw-mx-1" bitBadge variant="primary" [truncate]="truncate">Primary</button>
<button disabled class="tw-mx-1" bitBadge variant="secondary" [truncate]="truncate">Secondary</button>
<button disabled class="tw-mx-1" bitBadge variant="success" [truncate]="truncate">Success</button>
<button disabled class="tw-mx-1" bitBadge variant="danger" [truncate]="truncate">Danger</button>
<button disabled class="tw-mx-1" bitBadge variant="warning" [truncate]="truncate">Warning</button>
<button disabled class="tw-mx-1" bitBadge variant="info" [truncate]="truncate">Info</button>
<button disabled class="tw-mx-1" bitBadge variant="notification" [truncate]="truncate">Notification</button>
`,
}),
};

View File

@@ -7,15 +7,24 @@ import { I18nPipe } from "@bitwarden/ui-common";
import { IconButtonModule } from "../icon-button";
type BannerTypes = "premium" | "info" | "warning" | "danger";
type BannerType = "premium" | "info" | "warning" | "danger";
const defaultIcon: Record<BannerTypes, string> = {
const defaultIcon: Record<BannerType, string> = {
premium: "bwi-star",
info: "bwi-info-circle",
warning: "bwi-exclamation-triangle",
danger: "bwi-error",
};
/**
* Banners are used for important communication with the user that needs to be seen right away, but has
* little effect on the experience. Banners appear at the top of the user's screen on page load and
* persist across all pages a user navigates to.
* - They should always be dismissible and never use a timeout. If a user dismisses a banner, it should not reappear during that same active session.
* - Use banners sparingly, as they can feel intrusive to the user if they appear unexpectedly. Their effectiveness may decrease if too many are used.
* - Avoid stacking multiple banners.
* - Banners can contain a button or anchor that uses the `bitLink` directive with `linkType="secondary"`.
*/
@Component({
selector: "bit-banner",
templateUrl: "./banner.component.html",
@@ -23,7 +32,7 @@ const defaultIcon: Record<BannerTypes, string> = {
imports: [CommonModule, IconButtonModule, I18nPipe],
})
export class BannerComponent implements OnInit {
@Input("bannerType") bannerType: BannerTypes = "info";
@Input("bannerType") bannerType: BannerType = "info";
@Input() icon: string;
@Input() useAlertRole = true;
@Input() showClose = true;

View File

@@ -1,25 +1,16 @@
import { Meta, Controls, Canvas, Primary } from "@storybook/addon-docs";
import { Canvas, Controls, Description, Meta, Primary, Title } from "@storybook/addon-docs";
import * as stories from "./banner.stories";
<Meta of={stories} />
# Banner
Banners are used for important communication with the user that needs to be seen right away, but has
little effect on the experience. Banners appear at the top of the user's screen on page load and
persist across all pages a user navigates to.
- They should always be dismissible and never use a timeout. If a user dismisses a banner, it should
not reappear during that same active session.
- Use banners sparingly, as they can feel intrusive to the user if they appear unexpectedly. Their
effectiveness may decrease if too many are used.
- Avoid stacking multiple banners.
- Banners can contain a button or anchor that uses the `bitLink` directive with
`linkType="secondary"`.
```ts
import { BannerModule } from "@bitwarden/components";
```
<Title />
<Description />
<Primary />
<Controls />
## Types
@@ -56,5 +47,5 @@ Rarely used, but may be used to alert users over critical messages or very outda
## Accessibility
Banners sets the `role="status"` and `aria-live="polite"` attributes to ensure screen readers
announce the content prior to the test of the page. This behaviour can be disabled by setting
announce the content prior to the test of the page. This behavior can be disabled by setting
`[useAlertRole]="false"`.

View File

@@ -2,6 +2,7 @@ import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { IconButtonModule } from "../icon-button";
import { LinkModule } from "../link";
import { SharedModule } from "../shared/shared.module";
@@ -44,48 +45,50 @@ export default {
type Story = StoryObj<BannerComponent>;
export const Base: Story = {
render: (args) => {
return {
props: args,
template: `
<bit-banner ${formatArgsForCodeSnippet<BannerComponent>(args)}>
Content Really Long Text Lorem Ipsum Ipsum Ipsum
<button bitLink linkType="secondary">Button</button>
</bit-banner>
`,
};
},
};
export const Premium: Story = {
...Base,
args: {
bannerType: "premium",
},
render: (args) => ({
props: args,
template: `
<bit-banner [bannerType]="bannerType" (onClose)="onClose($event)" [showClose]=showClose>
Content Really Long Text Lorem Ipsum Ipsum Ipsum
<button bitLink linkType="secondary">Button</button>
</bit-banner>
`,
}),
};
Premium.args = {
bannerType: "premium",
};
export const Info: Story = {
...Premium,
...Base,
args: {
bannerType: "info",
},
};
export const Warning: Story = {
...Premium,
...Base,
args: {
bannerType: "warning",
},
};
export const Danger: Story = {
...Premium,
...Base,
args: {
bannerType: "danger",
},
};
export const HideClose: Story = {
...Premium,
...Base,
args: {
showClose: false,
},

View File

@@ -8,6 +8,11 @@ import { MenuModule } from "../menu";
import { BreadcrumbComponent } from "./breadcrumb.component";
/**
* Breadcrumbs are used to help users understand where they are in a products navigation. Typically
* Bitwarden uses this component to indicate the user's current location in a set of data organized in
* containers (Collections, Folders, or Projects).
*/
@Component({
selector: "bit-breadcrumbs",
templateUrl: "./breadcrumbs.component.html",

View File

@@ -1,14 +1,15 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./breadcrumbs.stories";
<Meta of={stories} />
# Breadcrumbs
```ts
import { BreadcrumbsModule } from "@bitwarden/components";
```
Breadcrumbs are used to help users understand where they are in a products navigation. Typically
Bitwarden uses this component to indicate the user's current location in a set of data organized in
containers (Collections, Folders, or Projects).
<Title />
<Description />
<Primary />
<Controls />

View File

@@ -1,4 +1,12 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import {
Markdown,
Meta,
Canvas,
Primary,
Controls,
Title,
Description,
} from "@storybook/addon-docs";
import * as stories from "./button.stories";
@@ -8,10 +16,9 @@ import * as stories from "./button.stories";
import { ButtonModule } from "@bitwarden/components";
```
# Button
<Title />
Buttons are interactive elements that can be triggered using a mouse, keyboard, or touch. They are
used to indicate actions that can be performed by a user such as submitting a form.
### Default / Secondary
<Primary />
@@ -30,7 +37,7 @@ takes:
### Groups
Groups of buttons should be seperated by a `0.5` rem gap. Usually acomplished by using the
Groups of buttons should be separated by a `0.5` rem gap. Usually accomplished by using the
`tw-gap-2` class in the button group container.
Groups within page content, dialog footers or forms should have the `primary` call to action placed
@@ -41,26 +48,24 @@ right.
There are 3 main styles for the button: Primary, Secondary, and Danger.
### Primary
### Default / Secondary
<Canvas of={stories.Primary} />
The secondary styling(shown above) should be used for secondary calls to action. An action is
"secondary" if it relates indirectly to the purpose of a page. There may be multiple secondary
buttons next to each other; however, generally there should only be 1 or 2 calls to action per page.
### Primary
Use the primary button styling for all Primary call to actions. An action is "primary" if it relates
to the main purpose of a page. There should never be 2 primary styled buttons next to each other.
### Secondary
<Canvas of={stories.Secondary} />
The secondary styling should be used for secondary calls to action. An action is "secondary" if it
relates indirectly to the purpose of a page. There may be multiple secondary buttons next to each
other; however, generally there should only be 1 or 2 calls to action per page.
<Canvas of={stories.Primary} />
### Danger
<Canvas of={stories.Danger} />
Use the danger styling only in settings when the user may perform a permanent destructive action.
Use the danger styling only in settings when the user may preform a permanent action.
<Canvas of={stories.Danger} />
## Disabled UI
@@ -114,7 +119,7 @@ success toast).
### Submit and async actions
Both submit and async action buttons use a loading button state while an action is taken. If your
button is preforming a long running task in the background like a server API call, be sure to review
button is performing a long running task in the background like a server API call, be sure to review
the [Async Actions Directive](?path=/story/component-library-async-actions-overview--page).
<Canvas of={stories.Loading} />

View File

@@ -1,15 +1,15 @@
import { Meta, StoryObj } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { ButtonComponent } from "./button.component";
export default {
title: "Component Library/Button",
component: ButtonComponent,
args: {
buttonType: "primary",
disabled: false,
loading: false,
size: "default",
},
argTypes: {
size: {
@@ -27,40 +27,27 @@ export default {
type Story = StoryObj<ButtonComponent>;
export const Primary: Story = {
export const Default: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<div class="tw-flex tw-gap-4 tw-mb-6 tw-items-center">
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block">Button</button>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover">Button:hover</button>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-focus-visible">Button:focus-visible</button>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover tw-test-focus-visible">Button:hover:focus-visible</button>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-active">Button:active</button>
</div>
<div class="tw-flex tw-gap-4 tw-items-center">
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block">Anchor</a>
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover">Anchor:hover</a>
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-focus-visible">Anchor:focus-visible</a>
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover tw-test-focus-visible">Anchor:hover:focus-visible</a>
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-active">Anchor:active</a>
</div>
<button bitButton ${formatArgsForCodeSnippet<ButtonComponent>(args)}>Button</button>
`,
}),
args: {
buttonType: "primary",
},
};
export const Secondary: Story = {
...Primary,
args: {
buttonType: "secondary",
},
};
export const Primary: Story = {
...Default,
args: {
buttonType: "primary",
},
};
export const Danger: Story = {
...Primary,
...Default,
args: {
buttonType: "danger",
},
@@ -83,16 +70,8 @@ export const Small: Story = {
};
export const Loading: Story = {
render: (args) => ({
props: args,
template: `
<button bitButton [disabled]="disabled" [loading]="loading" [block]="block" buttonType="primary" class="tw-mr-2">Primary</button>
<button bitButton [disabled]="disabled" [loading]="loading" [block]="block" buttonType="secondary" class="tw-mr-2">Secondary</button>
<button bitButton [disabled]="disabled" [loading]="loading" [block]="block" buttonType="danger" class="tw-mr-2">Danger</button>
`,
}),
...Default,
args: {
disabled: false,
loading: true,
},
};
@@ -101,7 +80,6 @@ export const Disabled: Story = {
...Loading,
args: {
disabled: true,
loading: false,
},
};
@@ -165,3 +143,28 @@ export const WithIcon: Story = {
`,
}),
};
export const InteractionStates: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<div class="tw-flex tw-gap-4 tw-mb-6 tw-items-center">
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block">Button</button>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover">Button:hover</button>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-focus-visible">Button:focus-visible</button>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover tw-test-focus-visible">Button:hover:focus-visible</button>
<button bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-active">Button:active</button>
</div>
<div class="tw-flex tw-gap-4 tw-items-center">
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block">Anchor</a>
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover">Anchor:hover</a>
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-focus-visible">Anchor:focus-visible</a>
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-hover tw-test-focus-visible">Anchor:hover:focus-visible</a>
<a href="#" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size" [block]="block" class="tw-test-active">Anchor:active</a>
</div>
`,
}),
args: {
buttonType: "primary",
},
};

View File

@@ -4,7 +4,10 @@
[attr.aria-labelledby]="titleId"
>
@if (title) {
<header id="{{ titleId }}" class="tw-mb-1 tw-mt-0 tw-text-base tw-font-semibold">
<header
id="{{ titleId }}"
class="tw-mb-1 tw-mt-0 tw-text-base tw-font-semibold tw-flex tw-gap-2 tw-items-center"
>
@if (icon) {
<i class="bwi" [ngClass]="[icon, headerClass]" aria-hidden="true"></i>
}

View File

@@ -24,6 +24,11 @@ const defaultI18n: Partial<Record<CalloutTypes, string>> = {
// Increments for each instance of this component
let nextId = 0;
/**
* Callouts are used to communicate important information to the user. Callouts should be used
* sparingly, as they command a large amount of visual attention. Avoid using more than 1 callout in
* the same location.
*/
@Component({
selector: "bit-callout",
templateUrl: "callout.component.html",

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./callout.stories";
@@ -8,11 +8,11 @@ import { CalloutModule } from "@bitwarden/components";
<Meta of={stories} />
# Callouts
<Title />
<Description />
Callouts are used to communicate important information to the user. Callouts should be used
sparingly, as they command a large amount of visual attention. Avoid using more than 1 callout in
the same location.
<Primary />
<Controls />
## Styles

View File

@@ -2,6 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { I18nMockService } from "../utils/i18n-mock.service";
import { CalloutComponent } from "./callout.component";
@@ -24,9 +25,6 @@ export default {
],
}),
],
args: {
type: "warning",
},
parameters: {
design: {
type: "figma",
@@ -37,36 +35,35 @@ export default {
type Story = StoryObj<CalloutComponent>;
export const Success: Story = {
export const Info: Story = {
render: (args) => ({
props: args,
template: `
<bit-callout [type]="type" [title]="title">Content</bit-callout>
<bit-callout ${formatArgsForCodeSnippet<CalloutComponent>(args)}>Content</bit-callout>
`,
}),
args: {
type: "success",
title: "Success",
title: "Title",
},
};
export const Info: Story = {
...Success,
export const Success: Story = {
...Info,
args: {
type: "info",
title: "Info",
...Info.args,
type: "success",
},
};
export const Warning: Story = {
...Success,
...Info,
args: {
type: "warning",
},
};
export const Danger: Story = {
...Success,
...Info,
args: {
type: "danger",
},

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Source, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./checkbox.stories";
@@ -8,7 +8,8 @@ import * as stories from "./checkbox.stories";
import { CheckboxModule } from "@bitwarden/components";
```
# Checkbox
<Title />
<Description />
<Primary />
<Controls />

View File

@@ -33,6 +33,9 @@ export type ChipSelectOption<T> = Option<T> & {
children?: ChipSelectOption<T>[];
};
/**
* `<bit-chip-select>` is a select element that is commonly used to filter items in lists or tables.
*/
@Component({
selector: "bit-chip-select",
templateUrl: "chip-select.component.html",

View File

@@ -1,4 +1,4 @@
import { Meta, Primary, Controls, Canvas } from "@storybook/addon-docs";
import { Meta, Primary, Controls, Canvas, Title, Description } from "@storybook/addon-docs";
import * as stories from "./chip-select.stories";
@@ -8,9 +8,8 @@ import * as stories from "./chip-select.stories";
import { ChipSelectComponent } from "@bitwarden/components";
```
# Chip Select
`<bit-chip-select>` is a select element that is commonly used to filter items in lists or tables.
<Title />
<Description />
<Canvas of={stories.Default} />

View File

@@ -10,7 +10,11 @@ enum CharacterType {
Special,
Number,
}
/**
* The color password is used primarily in the Generator pages and in the Login type form. It includes
* the logic for displaying letters as `text-main`, numbers as `primary`, and special symbols as
* `danger`.
*/
@Component({
selector: "bit-color-password",
template: `@for (character of passwordCharArray(); track $index; let i = $index) {

View File

@@ -1,14 +1,15 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./color-password.stories";
<Meta of={stories} />
# Color password
```ts
import { ColorPasswordModule } from "@bitwarden/components";
```
The color password is used primarily in the Generator pages and in the Login type form. It includes
the logic for displaying letters as `text-main`, numbers as `primary`, and special symbols as
`danger`.
<Title />
<Description />
<Primary />
<Controls />

View File

@@ -1,5 +1,7 @@
import { Meta, StoryObj } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { ColorPasswordComponent } from "./color-password.component";
const examplePassword = "Wq$Jk😀7j DX#rS5Sdi!z ";
@@ -25,7 +27,7 @@ export const ColorPassword: Story = {
render: (args) => ({
props: args,
template: `
<bit-color-password class="tw-text-base" [password]="password" [showCount]="showCount"></bit-color-password>
<bit-color-password ${formatArgsForCodeSnippet<ColorPasswordComponent>(args)}></bit-color-password>
`,
}),
};
@@ -35,7 +37,7 @@ export const WrappedColorPassword: Story = {
props: args,
template: `
<div class="tw-max-w-32">
<bit-color-password class="tw-text-base" [password]="password" [showCount]="showCount"></bit-color-password>
<bit-color-password ${formatArgsForCodeSnippet<ColorPasswordComponent>(args)}></bit-color-password>
</div>
`,
}),

View File

@@ -11,6 +11,30 @@ import {
let nextId = 0;
/**
* The `bit-disclosure` component is used in tandem with the `bitDisclosureTriggerFor` directive to create an accessible content area whose visibility is controlled by a trigger button.
* To compose a disclosure and trigger:
* 1. Create a trigger component (see "Supported Trigger Components" section below)
* 2. Create a `bit-disclosure`
* 3. Set a template reference on the `bit-disclosure`
* 4. Use the `bitDisclosureTriggerFor` directive on the trigger component, and pass it the `bit-disclosure` template reference
* 5. Set the `open` property on the `bit-disclosure` to init the disclosure as either currently expanded or currently collapsed. The disclosure will default to `false`, meaning it defaults to being hidden.
*
* @example
*
* ```html
* <button
* type="button"
* bitIconButton="bwi-sliders"
* [buttonType]="'muted'"
* [bitDisclosureTriggerFor]="disclosureRef"
* ></button>
* <bit-disclosure #disclosureRef open>click button to hide this content</bit-disclosure>
* ```
*
*/
@Component({
selector: "bit-disclosure",
standalone: true,

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./disclosure.stories";
@@ -8,37 +8,11 @@ import * as stories from "./disclosure.stories";
import { DisclosureComponent, DisclosureTriggerForDirective } from "@bitwarden/components";
```
# Disclosure
The `bit-disclosure` component is used in tandem with the `bitDisclosureTriggerFor` directive to
create an accessible content area whose visibility is controlled by a trigger button.
To compose a disclosure and trigger:
1. Create a trigger component (see "Supported Trigger Components" section below)
2. Create a `bit-disclosure`
3. Set a template reference on the `bit-disclosure`
4. Use the `bitDisclosureTriggerFor` directive on the trigger component, and pass it the
`bit-disclosure` template reference
5. Set the `open` property on the `bit-disclosure` to init the disclosure as either currently
expanded or currently collapsed. The disclosure will default to `false`, meaning it defaults to
being hidden.
```
<button
type="button"
bitIconButton="bwi-sliders"
[buttonType]="'muted'"
[bitDisclosureTriggerFor]="disclosureRef"
></button>
<bit-disclosure #disclosureRef open>click button to hide this content</bit-disclosure>
```
<Title />
<Description />
<Canvas of={stories.DisclosureWithIconButton} />
<br />
<br />
## Supported Trigger Components
This is the list of currently supported trigger components:

View File

@@ -147,7 +147,13 @@ const sizes: Record<IconButtonSize, string[]> = {
default: ["tw-px-2.5", "tw-py-1.5"],
small: ["tw-leading-none", "tw-text-base", "tw-p-1"],
};
/**
* Icon buttons are used when no text accompanies the button. It consists of an icon that may be updated to any icon in the `bwi-font`, a `title` attribute, and an `aria-label`.
* The most common use of the icon button is in the banner, toast, and modal components as a close button. It can also be found in tables as the 3 dot option menu, or on navigation list items when there are options that need to be collapsed into a menu.
* Similar to the main button components, spacing between multiple icon buttons should be .5rem.
*/
@Component({
selector: "button[bitIconButton]:not(button[bitButton])",
templateUrl: "icon-button.component.html",

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./icon-button.stories";
@@ -8,16 +8,8 @@ import * as stories from "./icon-button.stories";
import { IconButtonModule } from "@bitwarden/components";
```
# Icon Button
Icon buttons are used when no text accompanies the button. It consists of an icon that may be
updated to any icon in the `bwi-font`, a `title` attribute, and an `aria-label`.
The most common use of the icon button is in the banner, toast, and modal components as a close
button. It can also be found in tables as the 3 dot option menu, or on navigation list items when
there are options that need to be collapsed into a menu.
Similar to the main button components, spacing between multiple icon buttons should be .5rem.
<Title />
<Description />
<Primary />
<Controls />

View File

@@ -1,5 +1,7 @@
import { Meta, StoryObj } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { BitIconButtonComponent } from "./icon-button.component";
export default {
@@ -7,8 +9,11 @@ export default {
component: BitIconButtonComponent,
args: {
bitIconButton: "bwi-plus",
size: "default",
disabled: false,
},
argTypes: {
buttonType: {
options: ["primary", "secondary", "danger", "unstyled", "contrast", "main", "muted", "light"],
},
},
parameters: {
design: {
@@ -24,25 +29,9 @@ export const Default: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<div class="tw-space-x-4">
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="main" [size]="size">Button</button>
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="muted" [size]="size">Button</button>
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="primary" [size]="size">Button</button>
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="secondary"[size]="size">Button</button>
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="danger" [size]="size">Button</button>
<div class="tw-bg-primary-600 tw-p-2 tw-inline-block">
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="contrast" [size]="size">Button</button>
</div>
<div class="tw-bg-background-alt2 tw-p-2 tw-inline-block">
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" buttonType="light" [size]="size">Button</button>
</div>
</div>
<button ${formatArgsForCodeSnippet<BitIconButtonComponent>(args)}>Button</button>
`,
}),
args: {
size: "default",
buttonType: "primary",
},
};
export const Small: Story = {
@@ -54,40 +43,35 @@ export const Small: Story = {
};
export const Primary: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size">Button</button>
`,
}),
...Default,
args: {
buttonType: "primary",
},
};
export const Secondary: Story = {
...Primary,
...Default,
args: {
buttonType: "secondary",
},
};
export const Danger: Story = {
...Primary,
...Default,
args: {
buttonType: "danger",
},
};
export const Main: Story = {
...Primary,
...Default,
args: {
buttonType: "main",
},
};
export const Muted: Story = {
...Primary,
...Default,
args: {
buttonType: "muted",
},
@@ -98,7 +82,8 @@ export const Light: Story = {
props: args,
template: /*html*/ `
<div class="tw-bg-background-alt2 tw-p-6 tw-w-full tw-inline-block">
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size">Button</button>
<!-- <div> used only to provide dark background color -->
<button ${formatArgsForCodeSnippet<BitIconButtonComponent>(args)}>Button</button>
</div>
`,
}),
@@ -112,7 +97,8 @@ export const Contrast: Story = {
props: args,
template: /*html*/ `
<div class="tw-bg-primary-600 tw-p-6 tw-w-full tw-inline-block">
<button bitIconButton="bwi-plus" [disabled]="disabled" [loading]="loading" [buttonType]="buttonType" [size]="size">Button</button>
<!-- <div> used only to provide dark background color -->
<button ${formatArgsForCodeSnippet<BitIconButtonComponent>(args)}>Button</button>
</div>
`,
}),

View File

@@ -4,6 +4,10 @@ import * as stories from "./icon.stories";
<Meta of={stories} />
```ts
import { IconModule } from "@bitwarden/components";
```
# Icon Use Instructions
- Icons will generally be attached to the associated Jira task.

View File

@@ -66,6 +66,14 @@ abstract class LinkDirective {
linkType: LinkType = "primary";
}
/**
* Text Links and Buttons can use either the `<a>` or `<button>` tags. Choose which based on the action the button takes:
* - if navigating to a new page, use a `<a>`
* - if taking an action on the current page, use a `<button>`
* Text buttons or links are most commonly used in paragraphs of text or in forms to customize actions or show/hide additional form options.
*/
@Directive({
selector: "a[bitLink]",
standalone: true,

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Story, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./link.stories";
@@ -8,18 +8,11 @@ import * as stories from "./link.stories";
import { LinkModule } from "@bitwarden/components";
```
# Link / Text button
Text Links and Buttons can use either the `<a>` or `<button>` tags. Choose which based on the action
the button takes:
- if navigating to a new page, use a `<a>`
- if taking an action on the current page, use a `<button>`
Text buttons or links are most commonly used in paragraphs of text or in forms to customize actions
or show/hide additional form options.
<Title>Link / Text button</Title>
<Description />
<Primary />
<Controls />
## Variants

View File

@@ -1,5 +1,7 @@
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive";
import { LinkModule } from "./link.module";
@@ -27,6 +29,14 @@ export default {
type Story = StoryObj<ButtonLinkDirective>;
export const Default: Story = {
render: (args) => ({
template: /*html*/ `
<a bitLink ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
`,
}),
};
export const InteractionStates: Story = {
render: () => ({
template: /*html*/ `
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6">

View File

@@ -1,22 +1,25 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
type SizeTypes = "small" | "default" | "large";
type BackgroundTypes = "danger" | "primary" | "success" | "warning";
type ProgressSizeType = "small" | "default" | "large";
type BackgroundType = "danger" | "primary" | "success" | "warning";
const SizeClasses: Record<SizeTypes, string[]> = {
const SizeClasses: Record<ProgressSizeType, string[]> = {
small: ["tw-h-1"],
default: ["tw-h-4"],
large: ["tw-h-6"],
};
const BackgroundClasses: Record<BackgroundTypes, string[]> = {
const BackgroundClasses: Record<BackgroundType, string[]> = {
danger: ["tw-bg-danger-600"],
primary: ["tw-bg-primary-600"],
success: ["tw-bg-success-600"],
warning: ["tw-bg-warning-600"],
};
/**
* Progress indicators may be used to visually indicate progress or to visually measure some other value, such as a password strength indicator.
*/
@Component({
selector: "bit-progress",
templateUrl: "./progress.component.html",
@@ -25,9 +28,9 @@ const BackgroundClasses: Record<BackgroundTypes, string[]> = {
})
export class ProgressComponent {
@Input() barWidth = 0;
@Input() bgColor: BackgroundTypes = "primary";
@Input() bgColor: BackgroundType = "primary";
@Input() showText = true;
@Input() size: SizeTypes = "default";
@Input() size: ProgressSizeType = "default";
@Input() text?: string;
get displayText() {

View File

@@ -1,13 +1,15 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./progress.stories";
<Meta of={stories} />
# Progress
```ts
import { ProgressModule } from "@bitwarden/components";
```
Progress indicators may be used to visually indicate progress or to visually measure some other
value, such as a password strength indicator.
<Title />
<Description />
<Primary />
<Controls />

View File

@@ -1,5 +1,7 @@
import { Meta, StoryObj } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { ProgressComponent } from "./progress.component";
export default {
@@ -20,19 +22,34 @@ export default {
type Story = StoryObj<ProgressComponent>;
export const Base: Story = {
render: (args) => ({
props: args,
template: `
<bit-progress ${formatArgsForCodeSnippet<ProgressComponent>(args)}></bit-progress>
`,
}),
args: {
barWidth: 50,
},
};
export const Empty: Story = {
...Base,
args: {
barWidth: 0,
},
};
export const Full: Story = {
...Base,
args: {
barWidth: 100,
},
};
export const CustomText: Story = {
...Base,
args: {
barWidth: 25,
text: "Loading...",

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Source, Primary, Controls, Title } from "@storybook/addon-docs";
import * as stories from "./search.stories";
@@ -8,7 +8,7 @@ import * as stories from "./search.stories";
import { SearchModule } from "@bitwarden/components";
```
# Search
<Title>Search field</Title>
<Primary />
<Controls />

View File

@@ -3,6 +3,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { InputModule } from "../input/input.module";
import { SharedModule } from "../shared";
import { I18nMockService } from "../utils/i18n-mock.service";
@@ -27,6 +28,10 @@ export default {
],
}),
],
args: {
placeholder: "search",
disabled: false,
},
} as Meta;
type Story = StoryObj<SearchComponent>;
@@ -35,7 +40,7 @@ export const Default: Story = {
render: (args) => ({
props: args,
template: `
<bit-search [(ngModel)]="searchText" [placeholder]="placeholder" [disabled]="disabled"></bit-search>
<bit-search [(ngModel)]="searchText"${formatArgsForCodeSnippet<SearchComponent>(args)}></bit-search>
`,
}),
args: {},

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Source, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./toast.stories";
@@ -8,12 +8,16 @@ import * as stories from "./toast.stories";
import { ToastService } from "@bitwarden/components";
```
# Toast
<Title />
Toasts are ephemeral notifications. They most often communicate the result of a user action. Due to
their ephemeral nature, long messages and critical alerts should not utilize toasts.
<Primary />
<Controls />
<Canvas of={stories.Default} />
### Variants
<Canvas of={stories.Variants} />
### Long content
<Canvas of={stories.LongContent} />
@@ -38,7 +42,7 @@ The following options are accepted:
<Canvas of={stories.Service} />
## Toast container
### Toast container
`bit-toast-container` should be added to the app root of consuming clients to ensure toasts are
properly announced to screenreaders.
@@ -48,7 +52,7 @@ properly announced to screenreaders.
<bit-toast-container></bit-toast-container>
```
## Accessibility
### Accessibility
In addition to the accessibility provided by the `bit-toast-container` component, the toast itself
will apply `aria-alert="true"` if the toast is of type `error`.

View File

@@ -6,6 +6,7 @@ import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/an
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { ButtonModule } from "../button";
import { I18nMockService } from "../utils/i18n-mock.service";
@@ -75,11 +76,22 @@ export const Default: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-flex tw-flex-col tw-min-w tw-max-w-[--bit-toast-width]">
<bit-toast [title]="title" [message]="message" [progressWidth]="progressWidth" (onClose)="onClose()" variant="success"></bit-toast>
<bit-toast [title]="title" [message]="message" [progressWidth]="progressWidth" (onClose)="onClose()" variant="info"></bit-toast>
<bit-toast [title]="title" [message]="message" [progressWidth]="progressWidth" (onClose)="onClose()" variant="warning"></bit-toast>
<bit-toast [title]="title" [message]="message" [progressWidth]="progressWidth" (onClose)="onClose()" variant="error"></bit-toast>
<div class="tw-min-w tw-max-w-[--bit-toast-width]">
<bit-toast ${formatArgsForCodeSnippet<ToastComponent>(args)}></bit-toast>
</div>
`,
}),
};
export const Variants: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-flex tw-flex-col tw-min-w tw-max-w-[--bit-toast-width] tw-gap-2">
<bit-toast ${formatArgsForCodeSnippet<ToastComponent>(args)} variant="success"></bit-toast>
<bit-toast ${formatArgsForCodeSnippet<ToastComponent>(args)} variant="info"></bit-toast>
<bit-toast ${formatArgsForCodeSnippet<ToastComponent>(args)} variant="warning"></bit-toast>
<bit-toast ${formatArgsForCodeSnippet<ToastComponent>(args)} variant="error"></bit-toast>
</div>
`,
}),
@@ -93,8 +105,8 @@ export const LongContent: Story = {
args: {
title: "Foo",
message: [
"Lorem ipsum dolor sit amet, consectetur adipisci",
"Lorem ipsum dolor sit amet, consectetur adipisci",
"Maecenas commodo posuere quam, vel malesuada nulla accumsan ac.",
"Pellentesque interdum ligula ante, eget bibendum ante lacinia congue.",
],
},
};

View File

@@ -4,6 +4,9 @@ import { Toast as BaseToastrComponent, ToastPackage, ToastrService } from "ngx-t
import { ToastComponent } from "./toast.component";
/**
* Toasts are ephemeral notifications. They most often communicate the result of a user action. Due to their ephemeral nature, long messages and critical alerts should not utilize toasts.
*/
@Component({
template: `
<bit-toast

View File

@@ -85,11 +85,13 @@ export abstract class KeyService {
* (such as auto, biometrics, or pin)
*/
abstract refreshAdditionalKeys(): Promise<void>;
/**
* Observable value that returns whether or not the currently active user has ever had auser key,
* Observable value that returns whether or not the user has ever had a userKey,
* i.e. has ever been unlocked/decrypted. This is key for differentiating between TDE locked and standard locked states.
*/
abstract everHadUserKey$: Observable<boolean>;
abstract everHadUserKey$(userId: UserId): Observable<boolean>;
/**
* Retrieves the user key
* @param userId The desired user

View File

@@ -34,7 +34,6 @@ import {
FakeAccountService,
mockAccountServiceWith,
FakeStateProvider,
FakeActiveUserState,
FakeSingleUserState,
} from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
@@ -190,28 +189,28 @@ describe("keyService", () => {
});
describe("everHadUserKey$", () => {
let everHadUserKeyState: FakeActiveUserState<boolean>;
let everHadUserKeyState: FakeSingleUserState<boolean>;
beforeEach(() => {
everHadUserKeyState = stateProvider.activeUser.getFake(USER_EVER_HAD_USER_KEY);
everHadUserKeyState = stateProvider.singleUser.getFake(mockUserId, USER_EVER_HAD_USER_KEY);
});
it("should return true when stored value is true", async () => {
everHadUserKeyState.nextState(true);
expect(await firstValueFrom(keyService.everHadUserKey$)).toBe(true);
expect(await firstValueFrom(keyService.everHadUserKey$(mockUserId))).toBe(true);
});
it("should return false when stored value is false", async () => {
everHadUserKeyState.nextState(false);
expect(await firstValueFrom(keyService.everHadUserKey$)).toBe(false);
expect(await firstValueFrom(keyService.everHadUserKey$(mockUserId))).toBe(false);
});
it("should return false when stored value is null", async () => {
everHadUserKeyState.nextState(null);
expect(await firstValueFrom(keyService.everHadUserKey$)).toBe(false);
expect(await firstValueFrom(keyService.everHadUserKey$(mockUserId))).toBe(false);
});
});

View File

@@ -41,7 +41,7 @@ import {
USER_EVER_HAD_USER_KEY,
USER_KEY,
} from "@bitwarden/common/platform/services/key-state/user-key.state";
import { ActiveUserState, StateProvider } from "@bitwarden/common/platform/state";
import { StateProvider } from "@bitwarden/common/platform/state";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { OrganizationId, ProviderId, UserId } from "@bitwarden/common/types/guid";
import {
@@ -63,10 +63,6 @@ import {
import { KdfConfig } from "./models/kdf-config";
export class DefaultKeyService implements KeyServiceAbstraction {
private readonly activeUserEverHadUserKey: ActiveUserState<boolean>;
readonly everHadUserKey$: Observable<boolean>;
readonly activeUserOrgKeys$: Observable<Record<OrganizationId, OrgKey>>;
constructor(
@@ -82,10 +78,6 @@ export class DefaultKeyService implements KeyServiceAbstraction {
protected stateProvider: StateProvider,
protected kdfConfigService: KdfConfigService,
) {
// User Key
this.activeUserEverHadUserKey = stateProvider.getActive(USER_EVER_HAD_USER_KEY);
this.everHadUserKey$ = this.activeUserEverHadUserKey.state$.pipe(map((x) => x ?? false));
this.activeUserOrgKeys$ = this.stateProvider.activeUserId$.pipe(
switchMap((userId) => (userId != null ? this.orgKeys$(userId) : NEVER)),
) as Observable<Record<OrganizationId, OrgKey>>;
@@ -141,6 +133,12 @@ export class DefaultKeyService implements KeyServiceAbstraction {
await this.setUserKey(key, activeUserId);
}
everHadUserKey$(userId: UserId): Observable<boolean> {
return this.stateProvider
.getUser(userId, USER_EVER_HAD_USER_KEY)
.state$.pipe(map((x) => x ?? false));
}
getInMemoryUserKeyFor$(userId: UserId): Observable<UserKey> {
return this.stateProvider.getUserState$(USER_KEY, userId);
}

View File

@@ -29,19 +29,20 @@ export class DefaultCipherFormService implements CipherFormService {
async saveCipher(cipher: CipherView, config: CipherFormConfig): Promise<CipherView> {
// Passing the original cipher is important here as it is responsible for appending to password history
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const encryptedCipher = await this.cipherService.encrypt(
const encrypted = await this.cipherService.encrypt(
cipher,
activeUserId,
null,
null,
config.originalCipher ?? null,
);
const encryptedCipher = encrypted.cipher;
let savedCipher: Cipher;
// Creating a new cipher
if (cipher.id == null) {
savedCipher = await this.cipherService.createWithServer(encryptedCipher, config.admin);
savedCipher = await this.cipherService.createWithServer(encrypted, config.admin);
return await this.cipherService.decrypt(savedCipher, activeUserId);
}
@@ -64,13 +65,13 @@ export class DefaultCipherFormService implements CipherFormService {
);
// If the collectionIds are the same, update the cipher normally
} else if (isSetEqual(originalCollectionIds, newCollectionIds)) {
savedCipher = await this.cipherService.updateWithServer(encryptedCipher, config.admin);
savedCipher = await this.cipherService.updateWithServer(encrypted, config.admin);
} else {
// Updating a cipher with collection changes is not supported with a single request currently
// First update the cipher with the original collectionIds
encryptedCipher.collectionIds = config.originalCipher.collectionIds;
await this.cipherService.updateWithServer(
encryptedCipher,
encrypted,
config.admin || originalCollectionIds.size === 0,
);

View File

@@ -506,7 +506,7 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI
private async updateAssignedCollections(cipherView: CipherView, userId: UserId) {
const { collections } = this.formGroup.getRawValue();
cipherView.collectionIds = collections.map((i) => i.id as CollectionId);
const cipher = await this.cipherService.encrypt(cipherView, userId);
const { cipher } = await this.cipherService.encrypt(cipherView, userId);
if (this.params.isSingleCipherAdmin) {
await this.cipherService.saveCollectionsWithServerAdmin(cipher);
} else {

View File

@@ -46,6 +46,7 @@
".storybook/main.ts",
".storybook/manager.js",
".storybook/test-runner.ts",
".storybook/format-args-for-code-snippet.ts",
"apps/browser/src/autofill/content/components/.lit-storybook/main.ts"
],
"include": ["apps/**/*", "libs/**/*", "bitwarden_license/**/*", "scripts/**/*"],