diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 275b867390e..019647f594a 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -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 diff --git a/.storybook/format-args-for-code-snippet.ts b/.storybook/format-args-for-code-snippet.ts new file mode 100644 index 00000000000..bf36c153c0a --- /dev/null +++ b/.storybook/format-args-for-code-snippet.ts @@ -0,0 +1,33 @@ +import { argsToTemplate, StoryObj } from "@storybook/angular"; + +type RenderArgType = StoryObj["args"]; + +export const formatArgsForCodeSnippet = >( + args: RenderArgType, +) => { + 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, + ); + + 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 })}`; +}; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index a948fce0428..59b5287f3a3 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -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, }, diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index 009efd7ff36..b161200215a 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -69,8 +69,9 @@ describe("NotificationBackground", () => { const accountService = mock(); const organizationService = mock(); + 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); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index a73141b7e4d..cb6a67c8137 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -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), diff --git a/apps/browser/src/autofill/popup/fido2/fido2.component.ts b/apps/browser/src/autofill/popup/fido2/fido2.component.ts index 6b7d9120195..996d1bb6176 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2.component.ts @@ -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); } diff --git a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts index 415aeb31081..73c3fed3276 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts @@ -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); diff --git a/apps/web/entrypoint.sh b/apps/web/entrypoint.sh index 16d1c78fb77..53e8af235fb 100644 --- a/apps/web/entrypoint.sh +++ b/apps/web/entrypoint.sh @@ -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 diff --git a/apps/web/src/app/tools/send/send.component.html b/apps/web/src/app/tools/send/send.component.html index e55d5e56f78..1f220f4551e 100644 --- a/apps/web/src/app/tools/send/send.component.html +++ b/apps/web/src/app/tools/send/send.component.html @@ -3,7 +3,7 @@ @@ -183,10 +183,10 @@ -
+
diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts index d24d8386ecd..acb4a116b8f 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts @@ -97,6 +97,7 @@ export type ExposedPasswordDetail = { * organization member to a cipher */ export type MemberDetailsFlat = { + userGuid: string; userName: string; email: string; cipherId: string; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/response/member-cipher-details.response.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/response/member-cipher-details.response.ts index fcf5ada4b2c..7aa52330663 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/response/member-cipher-details.response.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/response/member-cipher-details.response.ts @@ -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"); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts index 6fdab58115d..afd246e1836 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts @@ -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, diff --git a/libs/angular/src/auth/guards/lock.guard.spec.ts b/libs/angular/src/auth/guards/lock.guard.spec.ts index 32b8ecbb9dd..ed77f9bdebf 100644 --- a/libs/angular/src/auth/guards/lock.guard.spec.ts +++ b/libs/angular/src/auth/guards/lock.guard.spec.ts @@ -44,7 +44,7 @@ describe("lockGuard", () => { const keyService: MockProxy = mock(); keyService.isLegacyUser.mockResolvedValue(setupParams.isLegacyUser); - keyService.everHadUserKey$ = of(setupParams.everHadUserKey); + keyService.everHadUserKey$.mockReturnValue(of(setupParams.everHadUserKey)); const platformUtilService: MockProxy = mock(); platformUtilService.getClientType.mockReturnValue(setupParams.clientType); diff --git a/libs/angular/src/auth/guards/lock.guard.ts b/libs/angular/src/auth/guards/lock.guard.ts index 10ad4917f32..01d03dc718d 100644 --- a/libs/angular/src/auth/guards/lock.guard.ts +++ b/libs/angular/src/auth/guards/lock.guard.ts @@ -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; } diff --git a/libs/angular/src/auth/guards/redirect.guard.ts b/libs/angular/src/auth/guards/redirect.guard.ts index 00dd20c9909..b893614b405 100644 --- a/libs/angular/src/auth/guards/redirect.guard.ts +++ b/libs/angular/src/auth/guards/redirect.guard.ts @@ -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 = {}): 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 = {}): 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.", diff --git a/libs/angular/src/auth/guards/tde-decryption-required.guard.spec.ts b/libs/angular/src/auth/guards/tde-decryption-required.guard.spec.ts new file mode 100644 index 00000000000..4408452a2a2 --- /dev/null +++ b/libs/angular/src/auth/guards/tde-decryption-required.guard.spec.ts @@ -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(); + const authService = mock(); + const keyService = mock(); + const deviceTrustService = mock(); + const logService = mock(); + + accountService.activeAccount$ = new BehaviorSubject(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"); + }); +}); diff --git a/libs/angular/src/auth/guards/tde-decryption-required.guard.ts b/libs/angular/src/auth/guards/tde-decryption-required.guard.ts index 1d98b1fa740..13e7c6d04e1 100644 --- a/libs/angular/src/auth/guards/tde-decryption-required.guard.ts +++ b/libs/angular/src/auth/guards/tde-decryption-required.guard.ts @@ -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: diff --git a/libs/angular/src/auth/guards/unauth.guard.spec.ts b/libs/angular/src/auth/guards/unauth.guard.spec.ts index ad0ce680a1f..c696b849558 100644 --- a/libs/angular/src/auth/guards/unauth.guard.spec.ts +++ b/libs/angular/src/auth/guards/unauth.guard.spec.ts @@ -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(everHadUserKey); + keyService.everHadUserKey$.mockReturnValue(of(everHadUserKey)); deviceTrustService.supportsDeviceTrustByUserId$.mockReturnValue( new BehaviorSubject(tdeEnabled), ); diff --git a/libs/angular/src/auth/guards/unauth.guard.ts b/libs/angular/src/auth/guards/unauth.guard.ts index 6764b46843e..3fcfd38349b 100644 --- a/libs/angular/src/auth/guards/unauth.guard.ts +++ b/libs/angular/src/auth/guards/unauth.guard.ts @@ -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. diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 8175372cae5..8cc79a22dfd 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -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) { diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts index 5c377e1a980..78ae8253ee2 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts @@ -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({ - 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; }); diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts index 6be89a4b376..d9f7ba19a6f 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -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, diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index fc809058161..91f8006d15e 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -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 { abstract cipherViews$(userId: UserId): Observable; abstract ciphers$(userId: UserId): Observable>; @@ -42,7 +48,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider; + ): Promise; abstract encryptFields(fieldsModel: FieldView[], key: SymmetricCryptoKey): Promise; abstract encryptField(fieldModel: FieldView, key: SymmetricCryptoKey): Promise; abstract get(id: string, userId: UserId): Promise; @@ -94,7 +100,10 @@ export abstract class CipherService implements UserKeyRotationDataProvider; + abstract createWithServer( + { cipher, encryptedFor }: EncryptionContext, + orgAdmin?: boolean, + ): Promise; /** * Update a cipher with the server * @param cipher The cipher to update @@ -104,7 +113,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider; diff --git a/libs/common/src/vault/models/data/field.data.ts b/libs/common/src/vault/models/data/field.data.ts index b9daf7fa423..cf9df69a6b0 100644 --- a/libs/common/src/vault/models/data/field.data.ts +++ b/libs/common/src/vault/models/data/field.data.ts @@ -7,7 +7,7 @@ export class FieldData { type: FieldType; name: string; value: string; - linkedId: LinkedIdType; + linkedId: LinkedIdType | null; constructor(response?: FieldApi) { if (response == null) { diff --git a/libs/common/src/vault/models/request/cipher-bulk-share.request.ts b/libs/common/src/vault/models/request/cipher-bulk-share.request.ts index 4f56297d0a5..d0c394bea00 100644 --- a/libs/common/src/vault/models/request/cipher-bulk-share.request.ts +++ b/libs/common/src/vault/models/request/cipher-bulk-share.request.ts @@ -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; diff --git a/libs/common/src/vault/models/request/cipher-create.request.ts b/libs/common/src/vault/models/request/cipher-create.request.ts index 9c3be5544b9..e992ebed9b2 100644 --- a/libs/common/src/vault/models/request/cipher-create.request.ts +++ b/libs/common/src/vault/models/request/cipher-create.request.ts @@ -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; } } diff --git a/libs/common/src/vault/models/request/cipher-share.request.ts b/libs/common/src/vault/models/request/cipher-share.request.ts index 4043599ce05..17c46168efe 100644 --- a/libs/common/src/vault/models/request/cipher-share.request.ts +++ b/libs/common/src/vault/models/request/cipher-share.request.ts @@ -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; } } diff --git a/libs/common/src/vault/models/request/cipher-with-id.request.ts b/libs/common/src/vault/models/request/cipher-with-id.request.ts index f291e342640..0b04f50fb1e 100644 --- a/libs/common/src/vault/models/request/cipher-with-id.request.ts +++ b/libs/common/src/vault/models/request/cipher-with-id.request.ts @@ -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; } } diff --git a/libs/common/src/vault/models/request/cipher.request.ts b/libs/common/src/vault/models/request/cipher.request.ts index 5b77ee7508e..2e3b2efbedc 100644 --- a/libs/common/src/vault/models/request/cipher.request.ts +++ b/libs/common/src/vault/models/request/cipher.request.ts @@ -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; diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 1f73903a5bc..e182025a332 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -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; diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 9e56bac2ca0..1a0b1568775 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -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(cipherObj.toCipherData())); - await cipherService.createWithServer(cipherObj, true); - const expectedObj = new CipherCreateRequest(cipherObj); + .mockImplementation(() => Promise.resolve(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(cipherObj.toCipherData())); - await cipherService.createWithServer(cipherObj, true); - const expectedObj = new CipherRequest(cipherObj); + .mockImplementation(() => Promise.resolve(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(cipherObj.toCipherData())); - await cipherService.createWithServer(cipherObj); - const expectedObj = new CipherCreateRequest(cipherObj); + .mockImplementation(() => Promise.resolve(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(cipherObj.toCipherData())); - await cipherService.createWithServer(cipherObj); - const expectedObj = new CipherRequest(cipherObj); + .mockImplementation(() => Promise.resolve(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(cipherObj.toCipherData())); - await cipherService.updateWithServer(cipherObj, true); - const expectedObj = new CipherRequest(cipherObj); + .mockImplementation(() => Promise.resolve(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(cipherObj.toCipherData())); - await cipherService.updateWithServer(cipherObj); - const expectedObj = new CipherRequest(cipherObj); + .mockImplementation(() => Promise.resolve(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(cipherObj.toCipherData())); - await cipherService.updateWithServer(cipherObj); - const expectedObj = new CipherPartialRequest(cipherObj); + .mockImplementation(() => Promise.resolve(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(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(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); }); }); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 2693d9d4644..0c948fe0c6b 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -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 { + ): Promise { 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 { + async createWithServer( + { cipher, encryptedFor }: EncryptionContext, + orgAdmin?: boolean, + ): Promise { 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 { + async updateWithServer( + { cipher, encryptedFor }: EncryptionContext, + orgAdmin?: boolean, + ): Promise { 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 { + private async encryptSharedCipher(model: CipherView, userId: UserId): Promise { 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; diff --git a/libs/components/src/avatar/avatar.component.ts b/libs/components/src/avatar/avatar.component.ts index 554f55636fc..c66bba1c462 100644 --- a/libs/components/src/avatar/avatar.component.ts +++ b/libs/components/src/avatar/avatar.component.ts @@ -16,6 +16,12 @@ const SizeClasses: Record = { 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) { diff --git a/libs/components/src/avatar/avatar.mdx b/libs/components/src/avatar/avatar.mdx index 627ba526ed9..bbf356f96fa 100644 --- a/libs/components/src/avatar/avatar.mdx +++ b/libs/components/src/avatar/avatar.mdx @@ -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"; -# 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. + +<Description /> <Primary /> <Controls /> diff --git a/libs/components/src/avatar/avatar.stories.ts b/libs/components/src/avatar/avatar.stories.ts index 19a6f86d89c..9b0d4e4aa8c 100644 --- a/libs/components/src/avatar/avatar.stories.ts +++ b/libs/components/src/avatar/avatar.stories.ts @@ -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", }, diff --git a/libs/components/src/badge-list/badge-list.stories.ts b/libs/components/src/badge-list/badge-list.stories.ts index f69ecde8377..504871f9509 100644 --- a/libs/components/src/badge-list/badge-list.stories.ts +++ b/libs/components/src/badge-list/badge-list.stories.ts @@ -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> `, }), diff --git a/libs/components/src/badge/badge.component.ts b/libs/components/src/badge/badge.component.ts index 893257ff225..3612827eff2 100644 --- a/libs/components/src/badge/badge.component.ts +++ b/libs/components/src/badge/badge.component.ts @@ -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; } /** diff --git a/libs/components/src/badge/badge.mdx b/libs/components/src/badge/badge.mdx index 55f32183899..957a3256cbb 100644 --- a/libs/components/src/badge/badge.mdx +++ b/libs/components/src/badge/badge.mdx @@ -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). diff --git a/libs/components/src/badge/badge.stories.ts b/libs/components/src/badge/badge.stories.ts index 6473ba8c867..a151547ef6a 100644 --- a/libs/components/src/badge/badge.stories.ts +++ b/libs/components/src/badge/badge.stories.ts @@ -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> + `, + }), +}; diff --git a/libs/components/src/banner/banner.component.ts b/libs/components/src/banner/banner.component.ts index a7b710d6a74..a6719f25989 100644 --- a/libs/components/src/banner/banner.component.ts +++ b/libs/components/src/banner/banner.component.ts @@ -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; diff --git a/libs/components/src/banner/banner.mdx b/libs/components/src/banner/banner.mdx index 67fb796a548..f37fe90e117 100644 --- a/libs/components/src/banner/banner.mdx +++ b/libs/components/src/banner/banner.mdx @@ -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"`. diff --git a/libs/components/src/banner/banner.stories.ts b/libs/components/src/banner/banner.stories.ts index 105d30bc04a..8338c9240b9 100644 --- a/libs/components/src/banner/banner.stories.ts +++ b/libs/components/src/banner/banner.stories.ts @@ -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, }, diff --git a/libs/components/src/breadcrumbs/breadcrumbs.component.ts b/libs/components/src/breadcrumbs/breadcrumbs.component.ts index 6e8fbf5c25a..24265212969 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.component.ts +++ b/libs/components/src/breadcrumbs/breadcrumbs.component.ts @@ -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", diff --git a/libs/components/src/breadcrumbs/breadcrumbs.mdx b/libs/components/src/breadcrumbs/breadcrumbs.mdx index 1ea0aff8c36..cd1d0226387 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.mdx +++ b/libs/components/src/breadcrumbs/breadcrumbs.mdx @@ -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 /> diff --git a/libs/components/src/button/button.mdx b/libs/components/src/button/button.mdx index 61874922fc7..b0f347ba337 100644 --- a/libs/components/src/button/button.mdx +++ b/libs/components/src/button/button.mdx @@ -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} /> diff --git a/libs/components/src/button/button.stories.ts b/libs/components/src/button/button.stories.ts index 759bd1a352c..d0a4354f374 100644 --- a/libs/components/src/button/button.stories.ts +++ b/libs/components/src/button/button.stories.ts @@ -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", + }, +}; diff --git a/libs/components/src/callout/callout.component.html b/libs/components/src/callout/callout.component.html index bb7f918df32..4e7b5f2a0cc 100644 --- a/libs/components/src/callout/callout.component.html +++ b/libs/components/src/callout/callout.component.html @@ -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> } diff --git a/libs/components/src/callout/callout.component.ts b/libs/components/src/callout/callout.component.ts index 6ffd8d2d0ec..e1bd7f1a596 100644 --- a/libs/components/src/callout/callout.component.ts +++ b/libs/components/src/callout/callout.component.ts @@ -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", diff --git a/libs/components/src/callout/callout.mdx b/libs/components/src/callout/callout.mdx index 160b1e1cc33..a1254b3f691 100644 --- a/libs/components/src/callout/callout.mdx +++ b/libs/components/src/callout/callout.mdx @@ -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 diff --git a/libs/components/src/callout/callout.stories.ts b/libs/components/src/callout/callout.stories.ts index 3101d4316f1..5f22bf9570a 100644 --- a/libs/components/src/callout/callout.stories.ts +++ b/libs/components/src/callout/callout.stories.ts @@ -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", }, diff --git a/libs/components/src/checkbox/checkbox.mdx b/libs/components/src/checkbox/checkbox.mdx index f3ce0d8fd07..ba5de4d234a 100644 --- a/libs/components/src/checkbox/checkbox.mdx +++ b/libs/components/src/checkbox/checkbox.mdx @@ -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 /> diff --git a/libs/components/src/chip-select/chip-select.component.ts b/libs/components/src/chip-select/chip-select.component.ts index d1f3bba2624..270249ade0c 100644 --- a/libs/components/src/chip-select/chip-select.component.ts +++ b/libs/components/src/chip-select/chip-select.component.ts @@ -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", diff --git a/libs/components/src/chip-select/chip-select.mdx b/libs/components/src/chip-select/chip-select.mdx index d569158b75a..b09b9664f8e 100644 --- a/libs/components/src/chip-select/chip-select.mdx +++ b/libs/components/src/chip-select/chip-select.mdx @@ -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} /> diff --git a/libs/components/src/color-password/color-password.component.ts b/libs/components/src/color-password/color-password.component.ts index 2dd78e8525d..a6cd58044a3 100644 --- a/libs/components/src/color-password/color-password.component.ts +++ b/libs/components/src/color-password/color-password.component.ts @@ -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) { diff --git a/libs/components/src/color-password/color-password.mdx b/libs/components/src/color-password/color-password.mdx index 8f3746715e1..4deeace9b9e 100644 --- a/libs/components/src/color-password/color-password.mdx +++ b/libs/components/src/color-password/color-password.mdx @@ -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 /> diff --git a/libs/components/src/color-password/color-password.stories.ts b/libs/components/src/color-password/color-password.stories.ts index bb835d97d4a..5a544dcb22e 100644 --- a/libs/components/src/color-password/color-password.stories.ts +++ b/libs/components/src/color-password/color-password.stories.ts @@ -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> `, }), diff --git a/libs/components/src/disclosure/disclosure.component.ts b/libs/components/src/disclosure/disclosure.component.ts index 6de06b48b3f..c18a2e31ea6 100644 --- a/libs/components/src/disclosure/disclosure.component.ts +++ b/libs/components/src/disclosure/disclosure.component.ts @@ -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, diff --git a/libs/components/src/disclosure/disclosure.mdx b/libs/components/src/disclosure/disclosure.mdx index 2fcff6f5982..50ccf936acc 100644 --- a/libs/components/src/disclosure/disclosure.mdx +++ b/libs/components/src/disclosure/disclosure.mdx @@ -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: diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index 60877070e2b..573708b1e40 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -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", diff --git a/libs/components/src/icon-button/icon-button.mdx b/libs/components/src/icon-button/icon-button.mdx index 85164717de7..637a9d7daa0 100644 --- a/libs/components/src/icon-button/icon-button.mdx +++ b/libs/components/src/icon-button/icon-button.mdx @@ -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 /> diff --git a/libs/components/src/icon-button/icon-button.stories.ts b/libs/components/src/icon-button/icon-button.stories.ts index 08c95c5d641..f63c494f7db 100644 --- a/libs/components/src/icon-button/icon-button.stories.ts +++ b/libs/components/src/icon-button/icon-button.stories.ts @@ -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> `, }), diff --git a/libs/components/src/icon/icon.mdx b/libs/components/src/icon/icon.mdx index 6435fc24948..d1809c81cd2 100644 --- a/libs/components/src/icon/icon.mdx +++ b/libs/components/src/icon/icon.mdx @@ -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. diff --git a/libs/components/src/link/link.directive.ts b/libs/components/src/link/link.directive.ts index 52aba557661..ca25e5fef56 100644 --- a/libs/components/src/link/link.directive.ts +++ b/libs/components/src/link/link.directive.ts @@ -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, diff --git a/libs/components/src/link/link.mdx b/libs/components/src/link/link.mdx index e509ddb9911..8fb5f693f10 100644 --- a/libs/components/src/link/link.mdx +++ b/libs/components/src/link/link.mdx @@ -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 + + ## Variants diff --git a/libs/components/src/link/link.stories.ts b/libs/components/src/link/link.stories.ts index d07d33ae589..edf2cb14cd6 100644 --- a/libs/components/src/link/link.stories.ts +++ b/libs/components/src/link/link.stories.ts @@ -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; export const Default: Story = { + render: (args) => ({ + template: /*html*/ ` + (args)}>Your text here + `, + }), +}; + +export const InteractionStates: Story = { render: () => ({ template: /*html*/ `
diff --git a/libs/components/src/progress/progress.component.ts b/libs/components/src/progress/progress.component.ts index 04e535158b1..cc2a6df7340 100644 --- a/libs/components/src/progress/progress.component.ts +++ b/libs/components/src/progress/progress.component.ts @@ -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 = { +const SizeClasses: Record = { small: ["tw-h-1"], default: ["tw-h-4"], large: ["tw-h-6"], }; -const BackgroundClasses: Record = { +const BackgroundClasses: Record = { 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 = { }) 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() { diff --git a/libs/components/src/progress/progress.mdx b/libs/components/src/progress/progress.mdx index 9a75f8ae1fa..def2f239129 100644 --- a/libs/components/src/progress/progress.mdx +++ b/libs/components/src/progress/progress.mdx @@ -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"; -# 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. + +<Description /> <Primary /> <Controls /> diff --git a/libs/components/src/progress/progress.stories.ts b/libs/components/src/progress/progress.stories.ts index 1484dab0a21..5c7eb066cd3 100644 --- a/libs/components/src/progress/progress.stories.ts +++ b/libs/components/src/progress/progress.stories.ts @@ -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...", diff --git a/libs/components/src/search/search.mdx b/libs/components/src/search/search.mdx index 492fd0dda2d..7775225b8c2 100644 --- a/libs/components/src/search/search.mdx +++ b/libs/components/src/search/search.mdx @@ -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 diff --git a/libs/components/src/search/search.stories.ts b/libs/components/src/search/search.stories.ts index a6cd714d43a..526e1381d70 100644 --- a/libs/components/src/search/search.stories.ts +++ b/libs/components/src/search/search.stories.ts @@ -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; @@ -35,7 +40,7 @@ export const Default: Story = { render: (args) => ({ props: args, template: ` - + (args)}> `, }), args: {}, diff --git a/libs/components/src/toast/toast.mdx b/libs/components/src/toast/toast.mdx index d27109b4772..6d9d80c6ae5 100644 --- a/libs/components/src/toast/toast.mdx +++ b/libs/components/src/toast/toast.mdx @@ -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 + -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`. diff --git a/libs/components/src/toast/toast.stories.ts b/libs/components/src/toast/toast.stories.ts index 0af4974eead..b4a80cd3276 100644 --- a/libs/components/src/toast/toast.stories.ts +++ b/libs/components/src/toast/toast.stories.ts @@ -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.", ], }, }; diff --git a/libs/components/src/toast/toastr.component.ts b/libs/components/src/toast/toastr.component.ts index 75124ceb4b3..06182f094aa 100644 --- a/libs/components/src/toast/toastr.component.ts +++ b/libs/components/src/toast/toastr.component.ts @@ -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 diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index 95b79890c6a..51a99421967 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -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 diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 6d2e8fd20ec..400d7279a30 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -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); }); }); diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index 1d4fcc86a0c..fe288adeb88 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -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); } diff --git a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts index 68eac4f0da2..99f853d4c86 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts @@ -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, ); diff --git a/libs/vault/src/components/assign-collections.component.ts b/libs/vault/src/components/assign-collections.component.ts index faa2dae072a..4a0bd1fc670 100644 --- a/libs/vault/src/components/assign-collections.component.ts +++ b/libs/vault/src/components/assign-collections.component.ts @@ -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 { diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 90b95ff54bf..a60a7053182 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -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/**/*"],