mirror of
https://github.com/bitwarden/browser
synced 2026-02-07 12:13:45 +00:00
Merge branch 'main' into autofill/pm-21845
This commit is contained in:
2
.github/workflows/build-web.yml
vendored
2
.github/workflows/build-web.yml
vendored
@@ -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
|
||||
|
||||
33
.storybook/format-args-for-code-snippet.ts
Normal file
33
.storybook/format-args-for-code-snippet.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { argsToTemplate, StoryObj } from "@storybook/angular";
|
||||
|
||||
type RenderArgType<T> = StoryObj<T>["args"];
|
||||
|
||||
export const formatArgsForCodeSnippet = <ComponentType extends Record<string, any>>(
|
||||
args: RenderArgType<ComponentType>,
|
||||
) => {
|
||||
const nonNullArgs = Object.entries(args as ComponentType).filter(
|
||||
([_, value]) => value !== null && value !== undefined,
|
||||
);
|
||||
const functionArgs = nonNullArgs.filter(([_, value]) => typeof value === "function");
|
||||
const argsToFormat = nonNullArgs.filter(([_, value]) => typeof value !== "function");
|
||||
|
||||
const argsToTemplateIncludeKeys = [...functionArgs].map(
|
||||
([key, _]) => key as keyof RenderArgType<ComponentType>,
|
||||
);
|
||||
|
||||
const formattedNonFunctionArgs = argsToFormat
|
||||
.map(([key, value]) => {
|
||||
if (typeof value === "boolean") {
|
||||
return `[${key}]="${value}"`;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const formattedArray = value.map((v) => `'${v}'`).join(", ");
|
||||
return `[${key}]="[${formattedArray}]"`;
|
||||
}
|
||||
return `${key}="${value}"`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
return `${formattedNonFunctionArgs} ${argsToTemplate(args as ComponentType, { include: argsToTemplateIncludeKeys })}`;
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -69,8 +69,9 @@ describe("NotificationBackground", () => {
|
||||
const accountService = mock<AccountService>();
|
||||
const organizationService = mock<OrganizationService>();
|
||||
|
||||
const userId = "testId" as UserId;
|
||||
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({
|
||||
id: "testId" as UserId,
|
||||
id: userId,
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
@@ -1141,8 +1142,11 @@ describe("NotificationBackground", () => {
|
||||
convertAddLoginQueueMessageToCipherViewSpy.mockReturnValueOnce(cipherView);
|
||||
editItemSpy.mockResolvedValueOnce(undefined);
|
||||
cipherEncryptSpy.mockResolvedValueOnce({
|
||||
...cipherView,
|
||||
id: "testId",
|
||||
cipher: {
|
||||
...cipherView,
|
||||
id: "testId",
|
||||
},
|
||||
encryptedFor: userId,
|
||||
});
|
||||
|
||||
sendMockExtensionMessage(message, sender);
|
||||
@@ -1188,6 +1192,13 @@ describe("NotificationBackground", () => {
|
||||
folderExistsSpy.mockResolvedValueOnce(true);
|
||||
convertAddLoginQueueMessageToCipherViewSpy.mockReturnValueOnce(cipherView);
|
||||
editItemSpy.mockResolvedValueOnce(undefined);
|
||||
cipherEncryptSpy.mockResolvedValueOnce({
|
||||
cipher: {
|
||||
...cipherView,
|
||||
id: "testId",
|
||||
},
|
||||
encryptedFor: userId,
|
||||
});
|
||||
const errorMessage = "fetch error";
|
||||
createWithServerSpy.mockImplementation(() => {
|
||||
throw new Error(errorMessage);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<small #actionSpinner [appApiAction]="actionPromise">
|
||||
<ng-container *ngIf="$any(actionSpinner).loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin text-muted"
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
@@ -183,10 +183,10 @@
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
<div class="no-items" *ngIf="filteredSends && !filteredSends.length">
|
||||
<div *ngIf="filteredSends && !filteredSends.length">
|
||||
<ng-container *ngIf="!loaded">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin text-muted"
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
|
||||
@@ -97,6 +97,7 @@ export type ExposedPasswordDetail = {
|
||||
* organization member to a cipher
|
||||
*/
|
||||
export type MemberDetailsFlat = {
|
||||
userGuid: string;
|
||||
userName: string;
|
||||
email: string;
|
||||
cipherId: string;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("lockGuard", () => {
|
||||
|
||||
const keyService: MockProxy<KeyService> = mock<KeyService>();
|
||||
keyService.isLegacyUser.mockResolvedValue(setupParams.isLegacyUser);
|
||||
keyService.everHadUserKey$ = of(setupParams.everHadUserKey);
|
||||
keyService.everHadUserKey$.mockReturnValue(of(setupParams.everHadUserKey));
|
||||
|
||||
const platformUtilService: MockProxy<PlatformUtilsService> = mock<PlatformUtilsService>();
|
||||
platformUtilService.getClientType.mockReturnValue(setupParams.clientType);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { inject } from "@angular/core";
|
||||
import { CanActivateFn, Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
@@ -33,6 +35,7 @@ export function redirectGuard(overrides: Partial<RedirectRoutes> = {}): CanActiv
|
||||
const authService = inject(AuthService);
|
||||
const keyService = inject(KeyService);
|
||||
const deviceTrustService = inject(DeviceTrustServiceAbstraction);
|
||||
const accountService = inject(AccountService);
|
||||
const logService = inject(LogService);
|
||||
const router = inject(Router);
|
||||
|
||||
@@ -49,7 +52,8 @@ export function redirectGuard(overrides: Partial<RedirectRoutes> = {}): CanActiv
|
||||
// If locked, TDE is enabled, and the user hasn't decrypted yet, then redirect to the
|
||||
// login decryption options component.
|
||||
const tdeEnabled = await firstValueFrom(deviceTrustService.supportsDeviceTrust$);
|
||||
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$);
|
||||
const userId = await firstValueFrom(accountService.activeAccount$.pipe(getUserId));
|
||||
const everHadUserKey = await firstValueFrom(keyService.everHadUserKey$(userId));
|
||||
if (authStatus === AuthenticationStatus.Locked && tdeEnabled && !everHadUserKey) {
|
||||
logService.info(
|
||||
"Sending user to TDE decryption options. AuthStatus is %s. TDE support is %s. Ever had user key is %s.",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { Router, provideRouter } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { tdeDecryptionRequiredGuard } from "./tde-decryption-required.guard";
|
||||
|
||||
describe("tdeDecryptionRequiredGuard", () => {
|
||||
const activeUser: Account = {
|
||||
id: "fake_user_id" as UserId,
|
||||
email: "test@email.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
const setup = (
|
||||
activeUser: Account | null,
|
||||
authStatus: AuthenticationStatus | null = null,
|
||||
tdeEnabled: boolean = false,
|
||||
everHadUserKey: boolean = false,
|
||||
) => {
|
||||
const accountService = mock<AccountService>();
|
||||
const authService = mock<AuthService>();
|
||||
const keyService = mock<KeyService>();
|
||||
const deviceTrustService = mock<DeviceTrustServiceAbstraction>();
|
||||
const logService = mock<LogService>();
|
||||
|
||||
accountService.activeAccount$ = new BehaviorSubject<Account | null>(activeUser);
|
||||
if (authStatus !== null) {
|
||||
authService.getAuthStatus.mockResolvedValue(authStatus);
|
||||
}
|
||||
keyService.everHadUserKey$.mockReturnValue(of(everHadUserKey));
|
||||
deviceTrustService.supportsDeviceTrust$ = of(tdeEnabled);
|
||||
|
||||
const testBed = TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: KeyService, useValue: keyService },
|
||||
{ provide: DeviceTrustServiceAbstraction, useValue: deviceTrustService },
|
||||
{ provide: LogService, useValue: logService },
|
||||
provideRouter([
|
||||
{ path: "", component: EmptyComponent },
|
||||
{
|
||||
path: "protected-route",
|
||||
component: EmptyComponent,
|
||||
canActivate: [tdeDecryptionRequiredGuard()],
|
||||
},
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
router: testBed.inject(Router),
|
||||
};
|
||||
};
|
||||
|
||||
it("redirects to root when the active account is null", async () => {
|
||||
const { router } = setup(null, null);
|
||||
await router.navigate(["protected-route"]);
|
||||
expect(router.url).toBe("/");
|
||||
});
|
||||
|
||||
test.each([AuthenticationStatus.Unlocked, AuthenticationStatus.LoggedOut])(
|
||||
"redirects to root when the user isn't locked",
|
||||
async (authStatus) => {
|
||||
const { router } = setup(activeUser, authStatus);
|
||||
|
||||
await router.navigate(["protected-route"]);
|
||||
|
||||
expect(router.url).toBe("/");
|
||||
},
|
||||
);
|
||||
|
||||
it("redirects to root when TDE is not enabled", async () => {
|
||||
const { router } = setup(activeUser, AuthenticationStatus.Locked, false, true);
|
||||
|
||||
await router.navigate(["protected-route"]);
|
||||
|
||||
expect(router.url).toBe("/");
|
||||
});
|
||||
|
||||
it("redirects to root when user has had a user key", async () => {
|
||||
const { router } = setup(activeUser, AuthenticationStatus.Locked, true, true);
|
||||
|
||||
await router.navigate(["protected-route"]);
|
||||
|
||||
expect(router.url).toBe("/");
|
||||
});
|
||||
|
||||
it("allows access when user is locked, TDE is enabled, and user has never had a user key", async () => {
|
||||
const { router } = setup(activeUser, AuthenticationStatus.Locked, true, false);
|
||||
|
||||
const result = await router.navigate(["protected-route"]);
|
||||
expect(result).toBe(true);
|
||||
expect(router.url).toBe("/protected-route");
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing";
|
||||
import { Router } from "@angular/router";
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -43,7 +43,7 @@ describe("UnauthGuard", () => {
|
||||
authService.authStatusFor$.mockReturnValue(activeAccountStatusObservable);
|
||||
}
|
||||
|
||||
keyService.everHadUserKey$ = new BehaviorSubject<boolean>(everHadUserKey);
|
||||
keyService.everHadUserKey$.mockReturnValue(of(everHadUserKey));
|
||||
deviceTrustService.supportsDeviceTrustByUserId$.mockReturnValue(
|
||||
new BehaviorSubject<boolean>(tdeEnabled),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BehaviorSubject, of } from "rxjs";
|
||||
import { mockAccountServiceWith } from "../../../../spec";
|
||||
import { Account } from "../../../auth/abstractions/account.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { CipherService } from "../../../vault/abstractions/cipher.service";
|
||||
import { CipherService, EncryptionContext } from "../../../vault/abstractions/cipher.service";
|
||||
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherRepromptType } from "../../../vault/enums/cipher-reprompt-type";
|
||||
import { CipherType } from "../../../vault/enums/cipher-type";
|
||||
@@ -36,8 +36,9 @@ type ParentWindowReference = string;
|
||||
const RpId = "bitwarden.com";
|
||||
|
||||
describe("FidoAuthenticatorService", () => {
|
||||
const userId = "testId" as UserId;
|
||||
const activeAccountSubject = new BehaviorSubject<Account | null>({
|
||||
id: "testId" as UserId,
|
||||
id: userId,
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
@@ -254,7 +255,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
cipherId: existingCipher.id,
|
||||
userVerified: false,
|
||||
});
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher);
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as EncryptionContext);
|
||||
|
||||
await authenticator.makeCredential(params, windowReference);
|
||||
|
||||
@@ -325,7 +326,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
cipherId: existingCipher.id,
|
||||
userVerified: false,
|
||||
});
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher);
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as EncryptionContext);
|
||||
cipherService.updateWithServer.mockRejectedValue(new Error("Internal error"));
|
||||
|
||||
const result = async () => await authenticator.makeCredential(params, windowReference);
|
||||
@@ -357,13 +358,13 @@ describe("FidoAuthenticatorService", () => {
|
||||
cipherService.decrypt.mockResolvedValue(cipher);
|
||||
cipherService.encrypt.mockImplementation(async (cipher) => {
|
||||
cipher.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability
|
||||
return {} as any;
|
||||
return { cipher: {} as any as Cipher, encryptedFor: userId };
|
||||
});
|
||||
cipherService.createWithServer.mockImplementation(async (cipher) => {
|
||||
cipherService.createWithServer.mockImplementation(async ({ cipher }) => {
|
||||
cipher.id = cipherId;
|
||||
return cipher;
|
||||
});
|
||||
cipherService.updateWithServer.mockImplementation(async (cipher) => {
|
||||
cipherService.updateWithServer.mockImplementation(async ({ cipher }) => {
|
||||
cipher.id = cipherId;
|
||||
return cipher;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -21,6 +21,12 @@ import { CipherView } from "../models/view/cipher.view";
|
||||
import { FieldView } from "../models/view/field.view";
|
||||
import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
|
||||
|
||||
export type EncryptionContext = {
|
||||
cipher: Cipher;
|
||||
/** The Id of the user that encrypted the cipher. It should always represent a UserId, even for Organization-owned ciphers */
|
||||
encryptedFor: UserId;
|
||||
};
|
||||
|
||||
export abstract class CipherService implements UserKeyRotationDataProvider<CipherWithIdRequest> {
|
||||
abstract cipherViews$(userId: UserId): Observable<CipherView[]>;
|
||||
abstract ciphers$(userId: UserId): Observable<Record<CipherId, CipherData>>;
|
||||
@@ -42,7 +48,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
keyForEncryption?: SymmetricCryptoKey,
|
||||
keyForCipherKeyDecryption?: SymmetricCryptoKey,
|
||||
originalCipher?: Cipher,
|
||||
): Promise<Cipher>;
|
||||
): Promise<EncryptionContext>;
|
||||
abstract encryptFields(fieldsModel: FieldView[], key: SymmetricCryptoKey): Promise<Field[]>;
|
||||
abstract encryptField(fieldModel: FieldView, key: SymmetricCryptoKey): Promise<Field>;
|
||||
abstract get(id: string, userId: UserId): Promise<Cipher>;
|
||||
@@ -94,7 +100,10 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
*
|
||||
* @returns A promise that resolves to the created cipher
|
||||
*/
|
||||
abstract createWithServer(cipher: Cipher, orgAdmin?: boolean): Promise<Cipher>;
|
||||
abstract createWithServer(
|
||||
{ cipher, encryptedFor }: EncryptionContext,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<Cipher>;
|
||||
/**
|
||||
* Update a cipher with the server
|
||||
* @param cipher The cipher to update
|
||||
@@ -104,7 +113,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
* @returns A promise that resolves to the updated cipher
|
||||
*/
|
||||
abstract updateWithServer(
|
||||
cipher: Cipher,
|
||||
{ cipher, encryptedFor }: EncryptionContext,
|
||||
orgAdmin?: boolean,
|
||||
isNotClone?: boolean,
|
||||
): Promise<Cipher>;
|
||||
|
||||
@@ -7,7 +7,7 @@ export class FieldData {
|
||||
type: FieldType;
|
||||
name: string;
|
||||
value: string;
|
||||
linkedId: LinkedIdType;
|
||||
linkedId: LinkedIdType | null;
|
||||
|
||||
constructor(response?: FieldApi) {
|
||||
if (response == null) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -27,6 +27,7 @@ import { ContainerService } from "../../platform/services/container.service";
|
||||
import { CipherId, UserId } from "../../types/guid";
|
||||
import { CipherKey, OrgKey, UserKey } from "../../types/key";
|
||||
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
|
||||
import { EncryptionContext } from "../abstractions/cipher.service";
|
||||
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
|
||||
import { FieldType } from "../enums";
|
||||
import { CipherRepromptType } from "../enums/cipher-reprompt-type";
|
||||
@@ -78,36 +79,12 @@ const cipherData: CipherData = {
|
||||
},
|
||||
passwordHistory: [{ password: "EncryptedString", lastUsedDate: "2022-01-31T12:00:00.000Z" }],
|
||||
attachments: [
|
||||
{
|
||||
id: "a1",
|
||||
url: "url",
|
||||
size: "1100",
|
||||
sizeName: "1.1 KB",
|
||||
fileName: "file",
|
||||
key: "EncKey",
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
url: "url",
|
||||
size: "1100",
|
||||
sizeName: "1.1 KB",
|
||||
fileName: "file",
|
||||
key: "EncKey",
|
||||
},
|
||||
{ id: "a1", url: "url", size: "1100", sizeName: "1.1 KB", fileName: "file", key: "EncKey" },
|
||||
{ id: "a2", url: "url", size: "1100", sizeName: "1.1 KB", fileName: "file", key: "EncKey" },
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
name: "EncryptedString",
|
||||
value: "EncryptedString",
|
||||
type: FieldType.Text,
|
||||
linkedId: null,
|
||||
},
|
||||
{
|
||||
name: "EncryptedString",
|
||||
value: "EncryptedString",
|
||||
type: FieldType.Hidden,
|
||||
linkedId: null,
|
||||
},
|
||||
{ name: "EncryptedString", value: "EncryptedString", type: FieldType.Text, linkedId: null },
|
||||
{ name: "EncryptedString", value: "EncryptedString", type: FieldType.Hidden, linkedId: null },
|
||||
],
|
||||
};
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
@@ -133,7 +110,7 @@ describe("Cipher Service", () => {
|
||||
const userId = "TestUserId" as UserId;
|
||||
|
||||
let cipherService: CipherService;
|
||||
let cipherObj: Cipher;
|
||||
let encryptionContext: EncryptionContext;
|
||||
|
||||
beforeEach(() => {
|
||||
encryptService.encryptFileData.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES));
|
||||
@@ -159,7 +136,7 @@ describe("Cipher Service", () => {
|
||||
cipherEncryptionService,
|
||||
);
|
||||
|
||||
cipherObj = new Cipher(cipherData);
|
||||
encryptionContext = { cipher: new Cipher(cipherData), encryptedFor: userId };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -192,33 +169,33 @@ describe("Cipher Service", () => {
|
||||
it("should call apiService.postCipherAdmin when orgAdmin param is true and the cipher orgId != null", async () => {
|
||||
const spy = jest
|
||||
.spyOn(apiService, "postCipherAdmin")
|
||||
.mockImplementation(() => Promise.resolve<any>(cipherObj.toCipherData()));
|
||||
await cipherService.createWithServer(cipherObj, true);
|
||||
const expectedObj = new CipherCreateRequest(cipherObj);
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.createWithServer(encryptionContext, true);
|
||||
const expectedObj = new CipherCreateRequest(encryptionContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith(expectedObj);
|
||||
});
|
||||
|
||||
it("should call apiService.postCipher when orgAdmin param is true and the cipher orgId is null", async () => {
|
||||
cipherObj.organizationId = null;
|
||||
encryptionContext.cipher.organizationId = null!;
|
||||
const spy = jest
|
||||
.spyOn(apiService, "postCipher")
|
||||
.mockImplementation(() => Promise.resolve<any>(cipherObj.toCipherData()));
|
||||
await cipherService.createWithServer(cipherObj, true);
|
||||
const expectedObj = new CipherRequest(cipherObj);
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.createWithServer(encryptionContext, true);
|
||||
const expectedObj = new CipherRequest(encryptionContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith(expectedObj);
|
||||
});
|
||||
|
||||
it("should call apiService.postCipherCreate if collectionsIds != null", async () => {
|
||||
cipherObj.collectionIds = ["123"];
|
||||
encryptionContext.cipher.collectionIds = ["123"];
|
||||
const spy = jest
|
||||
.spyOn(apiService, "postCipherCreate")
|
||||
.mockImplementation(() => Promise.resolve<any>(cipherObj.toCipherData()));
|
||||
await cipherService.createWithServer(cipherObj);
|
||||
const expectedObj = new CipherCreateRequest(cipherObj);
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.createWithServer(encryptionContext);
|
||||
const expectedObj = new CipherCreateRequest(encryptionContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith(expectedObj);
|
||||
@@ -227,9 +204,9 @@ describe("Cipher Service", () => {
|
||||
it("should call apiService.postCipher when orgAdmin and collectionIds logic is false", async () => {
|
||||
const spy = jest
|
||||
.spyOn(apiService, "postCipher")
|
||||
.mockImplementation(() => Promise.resolve<any>(cipherObj.toCipherData()));
|
||||
await cipherService.createWithServer(cipherObj);
|
||||
const expectedObj = new CipherRequest(cipherObj);
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.createWithServer(encryptionContext);
|
||||
const expectedObj = new CipherRequest(encryptionContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith(expectedObj);
|
||||
@@ -240,36 +217,36 @@ describe("Cipher Service", () => {
|
||||
it("should call apiService.putCipherAdmin when orgAdmin param is true", async () => {
|
||||
const spy = jest
|
||||
.spyOn(apiService, "putCipherAdmin")
|
||||
.mockImplementation(() => Promise.resolve<any>(cipherObj.toCipherData()));
|
||||
await cipherService.updateWithServer(cipherObj, true);
|
||||
const expectedObj = new CipherRequest(cipherObj);
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.updateWithServer(encryptionContext, true);
|
||||
const expectedObj = new CipherRequest(encryptionContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith(cipherObj.id, expectedObj);
|
||||
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
|
||||
});
|
||||
|
||||
it("should call apiService.putCipher if cipher.edit is true", async () => {
|
||||
cipherObj.edit = true;
|
||||
encryptionContext.cipher.edit = true;
|
||||
const spy = jest
|
||||
.spyOn(apiService, "putCipher")
|
||||
.mockImplementation(() => Promise.resolve<any>(cipherObj.toCipherData()));
|
||||
await cipherService.updateWithServer(cipherObj);
|
||||
const expectedObj = new CipherRequest(cipherObj);
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.updateWithServer(encryptionContext);
|
||||
const expectedObj = new CipherRequest(encryptionContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith(cipherObj.id, expectedObj);
|
||||
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
|
||||
});
|
||||
|
||||
it("should call apiService.putPartialCipher when orgAdmin, and edit are false", async () => {
|
||||
cipherObj.edit = false;
|
||||
encryptionContext.cipher.edit = false;
|
||||
const spy = jest
|
||||
.spyOn(apiService, "putPartialCipher")
|
||||
.mockImplementation(() => Promise.resolve<any>(cipherObj.toCipherData()));
|
||||
await cipherService.updateWithServer(cipherObj);
|
||||
const expectedObj = new CipherPartialRequest(cipherObj);
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.updateWithServer(encryptionContext);
|
||||
const expectedObj = new CipherPartialRequest(encryptionContext.cipher);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith(cipherObj.id, expectedObj);
|
||||
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,6 +270,15 @@ describe("Cipher Service", () => {
|
||||
jest.spyOn(cipherService as any, "getAutofillOnPageLoadDefault").mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should return the encrypting user id", async () => {
|
||||
keyService.getOrgKey.mockReturnValue(
|
||||
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
|
||||
);
|
||||
|
||||
const { encryptedFor } = await cipherService.encrypt(cipherView, userId);
|
||||
expect(encryptedFor).toEqual(userId);
|
||||
});
|
||||
|
||||
describe("login encryption", () => {
|
||||
it("should add a uri hash to login uris", async () => {
|
||||
encryptService.hash.mockImplementation((value) => Promise.resolve(`${value} hash`));
|
||||
@@ -304,9 +290,9 @@ describe("Cipher Service", () => {
|
||||
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
|
||||
);
|
||||
|
||||
const domain = await cipherService.encrypt(cipherView, userId);
|
||||
const { cipher } = await cipherService.encrypt(cipherView, userId);
|
||||
|
||||
expect(domain.login.uris).toEqual([
|
||||
expect(cipher.login.uris).toEqual([
|
||||
{
|
||||
uri: new EncString("uri has been encrypted"),
|
||||
uriChecksum: new EncString("uri hash has been encrypted"),
|
||||
@@ -325,7 +311,7 @@ describe("Cipher Service", () => {
|
||||
|
||||
it("is null when feature flag is false", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
const cipher = await cipherService.encrypt(cipherView, userId);
|
||||
const { cipher } = await cipherService.encrypt(cipherView, userId);
|
||||
|
||||
expect(cipher.key).toBeNull();
|
||||
});
|
||||
@@ -338,7 +324,7 @@ describe("Cipher Service", () => {
|
||||
it("is null when the cipher is not viewPassword", async () => {
|
||||
cipherView.viewPassword = false;
|
||||
|
||||
const cipher = await cipherService.encrypt(cipherView, userId);
|
||||
const { cipher } = await cipherService.encrypt(cipherView, userId);
|
||||
|
||||
expect(cipher.key).toBeNull();
|
||||
});
|
||||
@@ -346,7 +332,7 @@ describe("Cipher Service", () => {
|
||||
it("is defined when the cipher is viewPassword", async () => {
|
||||
cipherView.viewPassword = true;
|
||||
|
||||
const cipher = await cipherService.encrypt(cipherView, userId);
|
||||
const { cipher } = await cipherService.encrypt(cipherView, userId);
|
||||
|
||||
expect(cipher.key).toBeDefined();
|
||||
});
|
||||
@@ -393,7 +379,13 @@ describe("Cipher Service", () => {
|
||||
it("is called when cipher viewPassword is false and original cipher has a key", async () => {
|
||||
cipherView.viewPassword = false;
|
||||
|
||||
await cipherService.encrypt(cipherView, userId, undefined, undefined, cipherObj);
|
||||
await cipherService.encrypt(
|
||||
cipherView,
|
||||
userId,
|
||||
undefined,
|
||||
undefined,
|
||||
encryptionContext.cipher,
|
||||
);
|
||||
|
||||
expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled();
|
||||
});
|
||||
@@ -416,22 +408,17 @@ describe("Cipher Service", () => {
|
||||
|
||||
stateService.getUserId.mockResolvedValue(mockUserId);
|
||||
|
||||
const keys = {
|
||||
userKey: originalUserKey,
|
||||
} as CipherDecryptionKeys;
|
||||
const keys = { userKey: originalUserKey } as CipherDecryptionKeys;
|
||||
keyService.cipherDecryptionKeys$.mockReturnValue(of(keys));
|
||||
|
||||
const cipher1 = new CipherView(cipherObj);
|
||||
cipher1.id = "Cipher 1";
|
||||
const cipher1 = new CipherView(encryptionContext.cipher);
|
||||
cipher1.id = "Cipher 1" as CipherId;
|
||||
cipher1.organizationId = null;
|
||||
const cipher2 = new CipherView(cipherObj);
|
||||
cipher2.id = "Cipher 2";
|
||||
const cipher2 = new CipherView(encryptionContext.cipher);
|
||||
cipher2.id = "Cipher 2" as CipherId;
|
||||
cipher2.organizationId = null;
|
||||
|
||||
decryptedCiphers = new BehaviorSubject({
|
||||
Cipher1: cipher1,
|
||||
Cipher2: cipher2,
|
||||
});
|
||||
decryptedCiphers = new BehaviorSubject({ [cipher1.id]: cipher1, [cipher2.id]: cipher2 });
|
||||
jest
|
||||
.spyOn(cipherService, "cipherViews$")
|
||||
.mockImplementation((userId: UserId) =>
|
||||
@@ -462,19 +449,19 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("throws if the original user key is null", async () => {
|
||||
await expect(cipherService.getRotatedData(null, newUserKey, mockUserId)).rejects.toThrow(
|
||||
await expect(cipherService.getRotatedData(null!, newUserKey, mockUserId)).rejects.toThrow(
|
||||
"Original user key is required to rotate ciphers",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if the new user key is null", async () => {
|
||||
await expect(cipherService.getRotatedData(originalUserKey, null, mockUserId)).rejects.toThrow(
|
||||
"New user key is required to rotate ciphers",
|
||||
);
|
||||
await expect(
|
||||
cipherService.getRotatedData(originalUserKey, null!, mockUserId),
|
||||
).rejects.toThrow("New user key is required to rotate ciphers");
|
||||
});
|
||||
|
||||
it("throws if the user has any failed to decrypt ciphers", async () => {
|
||||
const badCipher = new CipherView(cipherObj);
|
||||
const badCipher = new CipherView(encryptionContext.cipher);
|
||||
badCipher.id = "Cipher 3";
|
||||
badCipher.organizationId = null;
|
||||
badCipher.decryptionFailure = true;
|
||||
@@ -488,12 +475,15 @@ describe("Cipher Service", () => {
|
||||
describe("decrypt", () => {
|
||||
it("should call decrypt method of CipherEncryptionService when feature flag is true", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
cipherEncryptionService.decrypt.mockResolvedValue(new CipherView(cipherObj));
|
||||
cipherEncryptionService.decrypt.mockResolvedValue(new CipherView(encryptionContext.cipher));
|
||||
|
||||
const result = await cipherService.decrypt(cipherObj, userId);
|
||||
const result = await cipherService.decrypt(encryptionContext.cipher, userId);
|
||||
|
||||
expect(result).toEqual(new CipherView(cipherObj));
|
||||
expect(cipherEncryptionService.decrypt).toHaveBeenCalledWith(cipherObj, userId);
|
||||
expect(result).toEqual(new CipherView(encryptionContext.cipher));
|
||||
expect(cipherEncryptionService.decrypt).toHaveBeenCalledWith(
|
||||
encryptionContext.cipher,
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
it("should call legacy decrypt when feature flag is false", async () => {
|
||||
@@ -501,12 +491,14 @@ describe("Cipher Service", () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
cipherService.getKeyForCipherKeyDecryption = jest.fn().mockResolvedValue(mockUserKey);
|
||||
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
|
||||
jest.spyOn(cipherObj, "decrypt").mockResolvedValue(new CipherView(cipherObj));
|
||||
jest
|
||||
.spyOn(encryptionContext.cipher, "decrypt")
|
||||
.mockResolvedValue(new CipherView(encryptionContext.cipher));
|
||||
|
||||
const result = await cipherService.decrypt(cipherObj, userId);
|
||||
const result = await cipherService.decrypt(encryptionContext.cipher, userId);
|
||||
|
||||
expect(result).toEqual(new CipherView(cipherObj));
|
||||
expect(cipherObj.decrypt).toHaveBeenCalledWith(mockUserKey);
|
||||
expect(result).toEqual(new CipherView(encryptionContext.cipher));
|
||||
expect(encryptionContext.cipher.decrypt).toHaveBeenCalledWith(mockUserKey);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@ import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid
|
||||
import { OrgKey, UserKey } from "../../types/key";
|
||||
import { filterOutNullish, perUserCache$ } from "../../vault/utils/observable-utilities";
|
||||
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
|
||||
import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service";
|
||||
import {
|
||||
CipherService as CipherServiceAbstraction,
|
||||
EncryptionContext,
|
||||
} from "../abstractions/cipher.service";
|
||||
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
|
||||
import { FieldType } from "../enums";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
@@ -196,7 +199,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
keyForCipherEncryption?: SymmetricCryptoKey,
|
||||
keyForCipherKeyDecryption?: SymmetricCryptoKey,
|
||||
originalCipher: Cipher = null,
|
||||
): Promise<Cipher> {
|
||||
): Promise<EncryptionContext> {
|
||||
if (model.id != null) {
|
||||
if (originalCipher == null) {
|
||||
originalCipher = await this.get(model.id, userId);
|
||||
@@ -230,18 +233,24 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
keyForCipherEncryption ||= userOrOrgKey;
|
||||
// If the caller has provided a key for cipher key decryption, use it. Otherwise, use the user or org key.
|
||||
keyForCipherKeyDecryption ||= userOrOrgKey;
|
||||
return this.encryptCipherWithCipherKey(
|
||||
model,
|
||||
cipher,
|
||||
keyForCipherEncryption,
|
||||
keyForCipherKeyDecryption,
|
||||
);
|
||||
return {
|
||||
cipher: await this.encryptCipherWithCipherKey(
|
||||
model,
|
||||
cipher,
|
||||
keyForCipherEncryption,
|
||||
keyForCipherKeyDecryption,
|
||||
),
|
||||
encryptedFor: userId,
|
||||
};
|
||||
} else {
|
||||
keyForCipherEncryption ||= await this.getKeyForCipherKeyDecryption(cipher, userId);
|
||||
// We want to ensure that the cipher key is null if cipher key encryption is disabled
|
||||
// so that decryption uses the proper key.
|
||||
cipher.key = null;
|
||||
return this.encryptCipher(model, cipher, keyForCipherEncryption);
|
||||
return {
|
||||
cipher: await this.encryptCipher(model, cipher, keyForCipherEncryption),
|
||||
encryptedFor: userId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,19 +270,14 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
attachment.size = model.size;
|
||||
attachment.sizeName = model.sizeName;
|
||||
attachment.url = model.url;
|
||||
const promise = this.encryptObjProperty(
|
||||
model,
|
||||
attachment,
|
||||
{
|
||||
fileName: null,
|
||||
const promise = this.encryptObjProperty(model, attachment, { fileName: null }, key).then(
|
||||
async () => {
|
||||
if (model.key != null) {
|
||||
attachment.key = await this.encryptService.wrapSymmetricKey(model.key, key);
|
||||
}
|
||||
encAttachments.push(attachment);
|
||||
},
|
||||
key,
|
||||
).then(async () => {
|
||||
if (model.key != null) {
|
||||
attachment.key = await this.encryptService.wrapSymmetricKey(model.key, key);
|
||||
}
|
||||
encAttachments.push(attachment);
|
||||
});
|
||||
);
|
||||
promises.push(promise);
|
||||
});
|
||||
|
||||
@@ -306,15 +310,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
fieldModel.value = "false";
|
||||
}
|
||||
|
||||
await this.encryptObjProperty(
|
||||
fieldModel,
|
||||
field,
|
||||
{
|
||||
name: null,
|
||||
value: null,
|
||||
},
|
||||
key,
|
||||
);
|
||||
await this.encryptObjProperty(fieldModel, field, { name: null, value: null }, key);
|
||||
|
||||
return field;
|
||||
}
|
||||
@@ -345,14 +341,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
const ph = new Password();
|
||||
ph.lastUsedDate = phModel.lastUsedDate;
|
||||
|
||||
await this.encryptObjProperty(
|
||||
phModel,
|
||||
ph,
|
||||
{
|
||||
password: null,
|
||||
},
|
||||
key,
|
||||
);
|
||||
await this.encryptObjProperty(phModel, ph, { password: null }, key);
|
||||
|
||||
return ph;
|
||||
}
|
||||
@@ -705,9 +694,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
if (ciphersLocalData[cipherId]) {
|
||||
ciphersLocalData[cipherId].lastUsedDate = new Date().getTime();
|
||||
} else {
|
||||
ciphersLocalData[cipherId] = {
|
||||
lastUsedDate: new Date().getTime(),
|
||||
};
|
||||
ciphersLocalData[cipherId] = { lastUsedDate: new Date().getTime() };
|
||||
}
|
||||
|
||||
await this.localDataState(userId).update(() => ciphersLocalData);
|
||||
@@ -735,10 +722,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
|
||||
const currentTime = new Date().getTime();
|
||||
ciphersLocalData[id as CipherId] = {
|
||||
lastLaunched: currentTime,
|
||||
lastUsedDate: currentTime,
|
||||
};
|
||||
ciphersLocalData[id as CipherId] = { lastLaunched: currentTime, lastUsedDate: currentTime };
|
||||
|
||||
await this.localDataState(userId).update(() => ciphersLocalData);
|
||||
|
||||
@@ -770,18 +754,21 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.domainSettingsService.setNeverDomains(domains);
|
||||
}
|
||||
|
||||
async createWithServer(cipher: Cipher, orgAdmin?: boolean): Promise<Cipher> {
|
||||
async createWithServer(
|
||||
{ cipher, encryptedFor }: EncryptionContext,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<Cipher> {
|
||||
let response: CipherResponse;
|
||||
if (orgAdmin && cipher.organizationId != null) {
|
||||
const request = new CipherCreateRequest(cipher);
|
||||
const request = new CipherCreateRequest({ cipher, encryptedFor });
|
||||
response = await this.apiService.postCipherAdmin(request);
|
||||
const data = new CipherData(response, cipher.collectionIds);
|
||||
return new Cipher(data);
|
||||
} else if (cipher.collectionIds != null) {
|
||||
const request = new CipherCreateRequest(cipher);
|
||||
const request = new CipherCreateRequest({ cipher, encryptedFor });
|
||||
response = await this.apiService.postCipherCreate(request);
|
||||
} else {
|
||||
const request = new CipherRequest(cipher);
|
||||
const request = new CipherRequest({ cipher, encryptedFor });
|
||||
response = await this.apiService.postCipher(request);
|
||||
}
|
||||
cipher.id = response.id;
|
||||
@@ -792,15 +779,18 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
return new Cipher(updated[cipher.id as CipherId]);
|
||||
}
|
||||
|
||||
async updateWithServer(cipher: Cipher, orgAdmin?: boolean): Promise<Cipher> {
|
||||
async updateWithServer(
|
||||
{ cipher, encryptedFor }: EncryptionContext,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<Cipher> {
|
||||
let response: CipherResponse;
|
||||
if (orgAdmin) {
|
||||
const request = new CipherRequest(cipher);
|
||||
const request = new CipherRequest({ cipher, encryptedFor });
|
||||
response = await this.apiService.putCipherAdmin(cipher.id, request);
|
||||
const data = new CipherData(response, cipher.collectionIds);
|
||||
return new Cipher(data, cipher.localData);
|
||||
} else if (cipher.edit) {
|
||||
const request = new CipherRequest(cipher);
|
||||
const request = new CipherRequest({ cipher, encryptedFor });
|
||||
response = await this.apiService.putCipher(cipher.id, request);
|
||||
} else {
|
||||
const request = new CipherPartialRequest(cipher);
|
||||
@@ -854,12 +844,12 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
cipher.collectionIds = collectionIds;
|
||||
promises.push(
|
||||
this.encryptSharedCipher(cipher, userId).then((c) => {
|
||||
encCiphers.push(c);
|
||||
encCiphers.push(c.cipher);
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(promises);
|
||||
const request = new CipherBulkShareRequest(encCiphers, collectionIds);
|
||||
const request = new CipherBulkShareRequest(encCiphers, collectionIds, userId);
|
||||
try {
|
||||
await this.apiService.putShareCiphers(request);
|
||||
} catch (e) {
|
||||
@@ -921,8 +911,8 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
//in order to keep item and it's attachments with the same encryption level
|
||||
if (cipher.key != null && !cipherKeyEncryptionEnabled) {
|
||||
const model = await this.decrypt(cipher, userId);
|
||||
cipher = await this.encrypt(model, userId);
|
||||
await this.updateWithServer(cipher);
|
||||
const reEncrypted = await this.encrypt(model, userId);
|
||||
await this.updateWithServer(reEncrypted);
|
||||
}
|
||||
|
||||
const encFileName = await this.encryptService.encryptString(filename, cipherEncKey);
|
||||
@@ -1482,7 +1472,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
|
||||
// In the case of a cipher that is being shared with an organization, we want to decrypt the
|
||||
// cipher key with the user's key and then re-encrypt it with the organization's key.
|
||||
private async encryptSharedCipher(model: CipherView, userId: UserId): Promise<Cipher> {
|
||||
private async encryptSharedCipher(model: CipherView, userId: UserId): Promise<EncryptionContext> {
|
||||
const keyForCipherKeyDecryption = await this.keyService.getUserKeyWithLegacySupport(userId);
|
||||
return await this.encrypt(model, userId, null, keyForCipherKeyDecryption);
|
||||
}
|
||||
@@ -1584,10 +1574,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
fd.append(
|
||||
"data",
|
||||
Buffer.from(encData.buffer) as any,
|
||||
{
|
||||
filepath: encFileName.encryptedString,
|
||||
contentType: "application/octet-stream",
|
||||
} as any,
|
||||
{ filepath: encFileName.encryptedString, contentType: "application/octet-stream" } as any,
|
||||
);
|
||||
} else {
|
||||
throw e;
|
||||
@@ -1649,11 +1636,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.encryptObjProperty(
|
||||
model.login,
|
||||
cipher.login,
|
||||
{
|
||||
username: null,
|
||||
password: null,
|
||||
totp: null,
|
||||
},
|
||||
{ username: null, password: null, totp: null },
|
||||
key,
|
||||
);
|
||||
|
||||
@@ -1663,14 +1646,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
for (let i = 0; i < model.login.uris.length; i++) {
|
||||
const loginUri = new LoginUri();
|
||||
loginUri.match = model.login.uris[i].match;
|
||||
await this.encryptObjProperty(
|
||||
model.login.uris[i],
|
||||
loginUri,
|
||||
{
|
||||
uri: null,
|
||||
},
|
||||
key,
|
||||
);
|
||||
await this.encryptObjProperty(model.login.uris[i], loginUri, { uri: null }, key);
|
||||
const uriHash = await this.encryptService.hash(model.login.uris[i].uri, "sha256");
|
||||
loginUri.uriChecksum = await this.encryptService.encryptString(uriHash, key);
|
||||
cipher.login.uris.push(loginUri);
|
||||
@@ -1766,11 +1742,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.encryptObjProperty(
|
||||
model.sshKey,
|
||||
cipher.sshKey,
|
||||
{
|
||||
privateKey: null,
|
||||
publicKey: null,
|
||||
keyFingerprint: null,
|
||||
},
|
||||
{ privateKey: null, publicKey: null, keyFingerprint: null },
|
||||
key,
|
||||
);
|
||||
return;
|
||||
@@ -1855,15 +1827,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.encryptObjProperty(
|
||||
model,
|
||||
cipher,
|
||||
{
|
||||
name: null,
|
||||
notes: null,
|
||||
},
|
||||
key,
|
||||
),
|
||||
this.encryptObjProperty(model, cipher, { name: null, notes: null }, key),
|
||||
this.encryptCipherData(cipher, model, key),
|
||||
this.encryptFields(model.fields, key).then((fields) => {
|
||||
cipher.fields = fields;
|
||||
|
||||
@@ -16,6 +16,12 @@ const SizeClasses: Record<SizeTypes, string[]> = {
|
||||
xsmall: ["tw-h-6", "tw-w-6"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Avatars display a unique color that helps a user visually recognize their logged in account.
|
||||
|
||||
* A variance in color across the avatar component is important as it is used in Account Switching as a
|
||||
* visual indicator to recognize which of a personal or work account a user is logged into.
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-avatar",
|
||||
template: `@if (src) {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
|
||||
import { Description, Meta, Canvas, Primary, Controls, Title } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./avatar.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
# Avatar
|
||||
```ts
|
||||
import { AvatarModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
Avatars display a unique color that helps a user visually recognize their logged in account.
|
||||
|
||||
A variance in color across the avatar component is important as it is used in Account Switching as a
|
||||
visual indicator to recognize which of a personal or work account a user is logged into.
|
||||
<Title />
|
||||
<Description />
|
||||
|
||||
<Primary />
|
||||
<Controls />
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"`.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
|
||||
import { Meta, Story, Primary, Controls, Title, Description } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./link.stories";
|
||||
|
||||
@@ -8,18 +8,11 @@ import * as stories from "./link.stories";
|
||||
import { LinkModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Link / Text button
|
||||
|
||||
Text Links and Buttons can use either the `<a>` or `<button>` tags. Choose which based on the action
|
||||
the button takes:
|
||||
|
||||
- if navigating to a new page, use a `<a>`
|
||||
- if taking an action on the current page, use a `<button>`
|
||||
|
||||
Text buttons or links are most commonly used in paragraphs of text or in forms to customize actions
|
||||
or show/hide additional form options.
|
||||
<Title>Link / Text button</Title>
|
||||
<Description />
|
||||
|
||||
<Primary />
|
||||
<Controls />
|
||||
|
||||
## Variants
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
|
||||
|
||||
import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive";
|
||||
import { LinkModule } from "./link.module";
|
||||
|
||||
@@ -27,6 +29,14 @@ export default {
|
||||
type Story = StoryObj<ButtonLinkDirective>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
template: /*html*/ `
|
||||
<a bitLink ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const InteractionStates: Story = {
|
||||
render: () => ({
|
||||
template: /*html*/ `
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6">
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
|
||||
type SizeTypes = "small" | "default" | "large";
|
||||
type BackgroundTypes = "danger" | "primary" | "success" | "warning";
|
||||
type ProgressSizeType = "small" | "default" | "large";
|
||||
type BackgroundType = "danger" | "primary" | "success" | "warning";
|
||||
|
||||
const SizeClasses: Record<SizeTypes, string[]> = {
|
||||
const SizeClasses: Record<ProgressSizeType, string[]> = {
|
||||
small: ["tw-h-1"],
|
||||
default: ["tw-h-4"],
|
||||
large: ["tw-h-6"],
|
||||
};
|
||||
|
||||
const BackgroundClasses: Record<BackgroundTypes, string[]> = {
|
||||
const BackgroundClasses: Record<BackgroundType, string[]> = {
|
||||
danger: ["tw-bg-danger-600"],
|
||||
primary: ["tw-bg-primary-600"],
|
||||
success: ["tw-bg-success-600"],
|
||||
warning: ["tw-bg-warning-600"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Progress indicators may be used to visually indicate progress or to visually measure some other value, such as a password strength indicator.
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-progress",
|
||||
templateUrl: "./progress.component.html",
|
||||
@@ -25,9 +28,9 @@ const BackgroundClasses: Record<BackgroundTypes, string[]> = {
|
||||
})
|
||||
export class ProgressComponent {
|
||||
@Input() barWidth = 0;
|
||||
@Input() bgColor: BackgroundTypes = "primary";
|
||||
@Input() bgColor: BackgroundType = "primary";
|
||||
@Input() showText = true;
|
||||
@Input() size: SizeTypes = "default";
|
||||
@Input() size: ProgressSizeType = "default";
|
||||
@Input() text?: string;
|
||||
|
||||
get displayText() {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
|
||||
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./progress.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
# Progress
|
||||
```ts
|
||||
import { ProgressModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
Progress indicators may be used to visually indicate progress or to visually measure some other
|
||||
value, such as a password strength indicator.
|
||||
<Title />
|
||||
<Description />
|
||||
|
||||
<Primary />
|
||||
<Controls />
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
|
||||
import { Meta, Canvas, Source, Primary, Controls, Title } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./search.stories";
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as stories from "./search.stories";
|
||||
import { SearchModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Search
|
||||
<Title>Search field</Title>
|
||||
|
||||
<Primary />
|
||||
<Controls />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
|
||||
import { InputModule } from "../input/input.module";
|
||||
import { SharedModule } from "../shared";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
@@ -27,6 +28,10 @@ export default {
|
||||
],
|
||||
}),
|
||||
],
|
||||
args: {
|
||||
placeholder: "search",
|
||||
disabled: false,
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
type Story = StoryObj<SearchComponent>;
|
||||
@@ -35,7 +40,7 @@ export const Default: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-search [(ngModel)]="searchText" [placeholder]="placeholder" [disabled]="disabled"></bit-search>
|
||||
<bit-search [(ngModel)]="searchText"${formatArgsForCodeSnippet<SearchComponent>(args)}></bit-search>
|
||||
`,
|
||||
}),
|
||||
args: {},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
|
||||
import { Meta, Canvas, Source, Primary, Controls, Title, Description } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./toast.stories";
|
||||
|
||||
@@ -8,12 +8,16 @@ import * as stories from "./toast.stories";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Toast
|
||||
<Title />
|
||||
|
||||
Toasts are ephemeral notifications. They most often communicate the result of a user action. Due to
|
||||
their ephemeral nature, long messages and critical alerts should not utilize toasts.
|
||||
<Primary />
|
||||
<Controls />
|
||||
|
||||
<Canvas of={stories.Default} />
|
||||
### Variants
|
||||
|
||||
<Canvas of={stories.Variants} />
|
||||
|
||||
### Long content
|
||||
|
||||
<Canvas of={stories.LongContent} />
|
||||
|
||||
@@ -38,7 +42,7 @@ The following options are accepted:
|
||||
|
||||
<Canvas of={stories.Service} />
|
||||
|
||||
## Toast container
|
||||
### Toast container
|
||||
|
||||
`bit-toast-container` should be added to the app root of consuming clients to ensure toasts are
|
||||
properly announced to screenreaders.
|
||||
@@ -48,7 +52,7 @@ properly announced to screenreaders.
|
||||
<bit-toast-container></bit-toast-container>
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
### Accessibility
|
||||
|
||||
In addition to the accessibility provided by the `bit-toast-container` component, the toast itself
|
||||
will apply `aria-alert="true"` if the toast is of type `error`.
|
||||
|
||||
@@ -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.",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/**/*"],
|
||||
|
||||
Reference in New Issue
Block a user