mirror of
https://github.com/bitwarden/browser
synced 2026-02-26 17:43:22 +00:00
Merge branch 'km/refactor-symmetric-keys-2' into km/refactor-symmetric-keys-3
This commit is contained in:
@@ -629,12 +629,7 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
* Handle the SSO button click.
|
||||
*/
|
||||
async handleSsoClick() {
|
||||
// Make sure the email is not empty, for type safety
|
||||
const email = this.formGroup.value.email;
|
||||
if (!email) {
|
||||
this.logService.error("Email is required for SSO");
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure the email is valid
|
||||
const isEmailValid = await this.validateEmail();
|
||||
@@ -642,6 +637,12 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure the email is not empty, for type safety
|
||||
if (!email) {
|
||||
this.logService.error("Email is required for SSO");
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the email configuration for the login component
|
||||
await this.saveEmailSettings();
|
||||
|
||||
|
||||
@@ -155,13 +155,6 @@ export class SsoComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect if we are on the first portion of the SSO flow
|
||||
// and have been sent here from another client with the info in query params
|
||||
if (this.hasParametersFromOtherClientRedirect(qParams)) {
|
||||
this.initializeFromRedirectFromOtherClient(qParams);
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect if we have landed here but only have an SSO identifier in the URL.
|
||||
// This is used by integrations that want to "short-circuit" the login to send users
|
||||
// directly to their IdP to simulate IdP-initiated SSO, so we submit automatically.
|
||||
@@ -172,8 +165,15 @@ export class SsoComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're routed here with no additional parameters, we'll try to determine the
|
||||
// identifier using claimed domain or local state saved from their last attempt.
|
||||
// Detect if we are on the first portion of the SSO flow
|
||||
// and have been sent here from another client with the info in query params.
|
||||
// If so, we want to initialize the SSO flow with those values.
|
||||
if (this.hasParametersFromOtherClientRedirect(qParams)) {
|
||||
this.initializeFromRedirectFromOtherClient(qParams);
|
||||
}
|
||||
|
||||
// Try to determine the identifier using claimed domain or local state
|
||||
// persisted from the user's last login attempt.
|
||||
await this.initializeIdentifierFromEmailOrStorage();
|
||||
}
|
||||
|
||||
@@ -445,13 +445,6 @@ export class SsoComponent implements OnInit {
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(orgSsoIdentifier, userId);
|
||||
|
||||
// Users enrolled in admin acct recovery can be forced to set a new password after
|
||||
// having the admin set a temp password for them (affects TDE & standard users)
|
||||
if (authResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) {
|
||||
// Weak password is not a valid scenario here b/c we cannot have evaluated a MP yet
|
||||
return await this.handleForcePasswordReset(orgSsoIdentifier);
|
||||
}
|
||||
|
||||
// must come after 2fa check since user decryption options aren't available if 2fa is required
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
|
||||
@@ -16,7 +16,7 @@ export class DefaultAuthRequestApiService implements AuthRequestApiService {
|
||||
const path = `/auth-requests/${requestId}`;
|
||||
const response = await this.apiService.send("GET", path, null, true, true);
|
||||
|
||||
return response;
|
||||
return new AuthRequestResponse(response);
|
||||
} catch (e: unknown) {
|
||||
this.logService.error(e);
|
||||
throw e;
|
||||
@@ -28,7 +28,7 @@ export class DefaultAuthRequestApiService implements AuthRequestApiService {
|
||||
const path = `/auth-requests/${requestId}/response?code=${accessCode}`;
|
||||
const response = await this.apiService.send("GET", path, null, false, true);
|
||||
|
||||
return response;
|
||||
return new AuthRequestResponse(response);
|
||||
} catch (e: unknown) {
|
||||
this.logService.error(e);
|
||||
throw e;
|
||||
@@ -45,7 +45,7 @@ export class DefaultAuthRequestApiService implements AuthRequestApiService {
|
||||
true,
|
||||
);
|
||||
|
||||
return response;
|
||||
return new AuthRequestResponse(response);
|
||||
} catch (e: unknown) {
|
||||
this.logService.error(e);
|
||||
throw e;
|
||||
@@ -54,9 +54,22 @@ export class DefaultAuthRequestApiService implements AuthRequestApiService {
|
||||
|
||||
async postAuthRequest(request: AuthRequest): Promise<AuthRequestResponse> {
|
||||
try {
|
||||
const response = await this.apiService.send("POST", "/auth-requests/", request, false, true);
|
||||
// Submit the current device identifier in the header as well as in the POST body.
|
||||
// The value in the header will be used to build the request context and ensure that the resulting
|
||||
// notifications have the current device as a source.
|
||||
const response = await this.apiService.send(
|
||||
"POST",
|
||||
"/auth-requests/",
|
||||
request,
|
||||
false,
|
||||
true,
|
||||
null,
|
||||
(headers) => {
|
||||
headers.set("Device-Identifier", request.deviceIdentifier);
|
||||
},
|
||||
);
|
||||
|
||||
return response;
|
||||
return new AuthRequestResponse(response);
|
||||
} catch (e: unknown) {
|
||||
this.logService.error(e);
|
||||
throw e;
|
||||
|
||||
@@ -142,7 +142,7 @@ export abstract class ApiService {
|
||||
body: any,
|
||||
authed: boolean,
|
||||
hasResponse: boolean,
|
||||
apiUrl?: string,
|
||||
apiUrl?: string | null,
|
||||
alterHeaders?: (headers: Headers) => void,
|
||||
) => Promise<any>;
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ export enum FeatureFlag {
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs",
|
||||
AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner",
|
||||
NewDeviceVerification = "new-device-verification",
|
||||
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
|
||||
RecoveryCodeLogin = "pm-17128-recovery-code-login",
|
||||
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
|
||||
@@ -104,7 +103,6 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
[FeatureFlag.ResellerManagedOrgAlert]: FALSE,
|
||||
[FeatureFlag.AccountDeprovisioningBanner]: FALSE,
|
||||
[FeatureFlag.NewDeviceVerification]: FALSE,
|
||||
[FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE,
|
||||
[FeatureFlag.RecoveryCodeLogin]: FALSE,
|
||||
[FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { EncryptedObject } from "@bitwarden/common/platform/models/domain/encrypted-object";
|
||||
import {
|
||||
Aes256CbcHmacKey,
|
||||
Aes256CbcKey,
|
||||
SymmetricCryptoKey,
|
||||
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
@@ -72,8 +73,13 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
encBytes.set(new Uint8Array(encValue.mac), 1 + encValue.iv.byteLength);
|
||||
encBytes.set(new Uint8Array(encValue.data), 1 + encValue.iv.byteLength + macLen);
|
||||
return new EncArrayBuffer(encBytes);
|
||||
} else {
|
||||
throw new Error(`Encrypt is not supported for keys of type ${innerKey.type}`);
|
||||
} else if (innerKey.type === EncryptionType.AesCbc256_B64) {
|
||||
const encValue = await this.aesEncryptLegacy(plainValue, innerKey);
|
||||
const encBytes = new Uint8Array(1 + encValue.iv.byteLength + encValue.data.byteLength);
|
||||
encBytes.set([innerKey.type]);
|
||||
encBytes.set(new Uint8Array(encValue.iv), 1);
|
||||
encBytes.set(new Uint8Array(encValue.data), 1 + encValue.iv.byteLength);
|
||||
return new EncArrayBuffer(encBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +304,16 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Removed once AesCbc256_B64 support is removed
|
||||
*/
|
||||
private async aesEncryptLegacy(data: Uint8Array, key: Aes256CbcKey): Promise<EncryptedObject> {
|
||||
const obj = new EncryptedObject();
|
||||
obj.iv = await this.cryptoFunctionService.randomBytes(16);
|
||||
obj.data = await this.cryptoFunctionService.aesEncrypt(data, obj.iv, key.encryptionKey);
|
||||
return obj;
|
||||
}
|
||||
|
||||
private logDecryptError(
|
||||
msg: string,
|
||||
keyEncType: EncryptionType,
|
||||
|
||||
@@ -40,13 +40,7 @@ describe("EncryptService", () => {
|
||||
const actual = await encryptService.encrypt(null, key);
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
it("throws for AesCbc256_B64", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
|
||||
await expect(encryptService.encrypt("data", key)).rejects.toThrow(
|
||||
"Encrypt is not supported for keys of type 0",
|
||||
);
|
||||
});
|
||||
it("creates an EncString for AesCbc256_HmacSha256_B64", async () => {
|
||||
it("creates an EncString for Aes256Cbc_HmacSha256_B64", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const plainValue = "data";
|
||||
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32));
|
||||
@@ -70,15 +64,23 @@ describe("EncryptService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for an AesCbc256_B64 key", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32, 100));
|
||||
it("encrypts data with provided Aes256Cbc key and returns correct encbuffer", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0));
|
||||
const iv = makeStaticByteArray(16, 80);
|
||||
const cipherText = makeStaticByteArray(20, 150);
|
||||
cryptoFunctionService.randomBytes.mockResolvedValue(iv as CsprngArray);
|
||||
cryptoFunctionService.aesEncrypt.mockResolvedValue(cipherText);
|
||||
|
||||
await expect(encryptService.encryptToBytes(plainValue, key)).rejects.toThrow(
|
||||
"Encrypt is not supported for keys of type 0",
|
||||
);
|
||||
const actual = await encryptService.encryptToBytes(plainValue, key);
|
||||
const expectedBytes = new Uint8Array(1 + iv.byteLength + cipherText.byteLength);
|
||||
expectedBytes.set([EncryptionType.AesCbc256_B64]);
|
||||
expectedBytes.set(iv, 1);
|
||||
expectedBytes.set(cipherText, 1 + iv.byteLength);
|
||||
|
||||
expect(actual.buffer).toEqualBuffer(expectedBytes);
|
||||
});
|
||||
|
||||
it("encrypts data with provided key and returns correct encbuffer", async () => {
|
||||
it("encrypts data with provided Aes256Cbc_HmacSha256 key and returns correct encbuffer", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0));
|
||||
const iv = makeStaticByteArray(16, 80);
|
||||
const mac = makeStaticByteArray(32, 100);
|
||||
@@ -122,7 +124,7 @@ describe("EncryptService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("decrypts data with provided key for AesCbc256Hmac", async () => {
|
||||
it("decrypts data with provided key for Aes256CbcHmac", async () => {
|
||||
const decryptedBytes = makeStaticByteArray(10, 200);
|
||||
|
||||
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(1));
|
||||
@@ -141,7 +143,7 @@ describe("EncryptService", () => {
|
||||
expect(actual).toEqualBuffer(decryptedBytes);
|
||||
});
|
||||
|
||||
it("decrypts data with provided key for AesCbc256", async () => {
|
||||
it("decrypts data with provided key for Aes256Cbc", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0));
|
||||
const encBuffer = new EncArrayBuffer(makeStaticByteArray(60, EncryptionType.AesCbc256_B64));
|
||||
const decryptedBytes = makeStaticByteArray(10, 200);
|
||||
@@ -192,7 +194,16 @@ describe("EncryptService", () => {
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null if key is AesCbc256 but encbuffer is AesCbc256_HMAC", async () => {
|
||||
it("returns null if mac could not be calculated", async () => {
|
||||
cryptoFunctionService.hmac.mockResolvedValue(null);
|
||||
|
||||
const actual = await encryptService.decryptToBytes(encBuffer, key);
|
||||
expect(cryptoFunctionService.hmac).toHaveBeenCalled();
|
||||
expect(cryptoFunctionService.aesDecrypt).not.toHaveBeenCalled();
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null if key is Aes256Cbc but encbuffer is Aes256Cbc_HmacSha256", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0));
|
||||
cryptoFunctionService.compare.mockResolvedValue(true);
|
||||
|
||||
@@ -202,7 +213,7 @@ describe("EncryptService", () => {
|
||||
expect(cryptoFunctionService.aesDecrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null if key is AesCbc256_HMAC but encbuffer is AesCbc256", async () => {
|
||||
it("returns null if key is Aes256Cbc_HmacSha256 but encbuffer is Aes256Cbc", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0));
|
||||
cryptoFunctionService.compare.mockResolvedValue(true);
|
||||
const buffer = new EncArrayBuffer(makeStaticByteArray(200, EncryptionType.AesCbc256_B64));
|
||||
|
||||
@@ -102,4 +102,21 @@ describe("SymmetricCryptoKey", () => {
|
||||
|
||||
expect(actual).toEqual(keyB64);
|
||||
});
|
||||
|
||||
describe("fromString", () => {
|
||||
it("null string returns null", () => {
|
||||
const actual = SymmetricCryptoKey.fromString(null);
|
||||
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
|
||||
it("base64 string creates object", () => {
|
||||
const key = makeStaticByteArray(64);
|
||||
const expected = new SymmetricCryptoKey(key);
|
||||
const actual = SymmetricCryptoKey.fromString(expected.keyB64);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
expect(actual).toBeInstanceOf(SymmetricCryptoKey);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,3 +199,4 @@ export const NEW_DEVICE_VERIFICATION_NOTICE = new StateDefinition(
|
||||
);
|
||||
export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk");
|
||||
export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
|
||||
export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");
|
||||
|
||||
@@ -1863,7 +1863,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
body: any,
|
||||
authed: boolean,
|
||||
hasResponse: boolean,
|
||||
apiUrl?: string,
|
||||
apiUrl?: string | null,
|
||||
alterHeaders?: (headers: Headers) => void,
|
||||
): Promise<any> {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
@fadeIn
|
||||
>
|
||||
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2 tw-px-4 tw-pt-4 tw-text-center">
|
||||
@if (hasIcon) {
|
||||
<ng-content select="[bitDialogIcon]"></ng-content>
|
||||
} @else {
|
||||
<i class="bwi bwi-exclamation-triangle tw-text-3xl tw-text-warning" aria-hidden="true"></i>
|
||||
@if (!hideIcon()) {
|
||||
@if (hasIcon) {
|
||||
<ng-content select="[bitDialogIcon]"></ng-content>
|
||||
} @else {
|
||||
<i class="bwi bwi-exclamation-triangle tw-text-3xl tw-text-warning" aria-hidden="true"></i>
|
||||
}
|
||||
}
|
||||
<h1
|
||||
bitDialogTitleContainer
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, ContentChild, Directive } from "@angular/core";
|
||||
import { booleanAttribute, Component, ContentChild, Directive, input } from "@angular/core";
|
||||
|
||||
import { TypographyDirective } from "../../typography/typography.directive";
|
||||
import { fadeIn } from "../animations";
|
||||
@@ -20,6 +20,11 @@ export class IconDirective {}
|
||||
export class SimpleDialogComponent {
|
||||
@ContentChild(IconDirective) icon!: IconDirective;
|
||||
|
||||
/**
|
||||
* Optional flag to hide the dialog's center icon. Defaults to false.
|
||||
*/
|
||||
hideIcon = input(false, { transform: booleanAttribute });
|
||||
|
||||
get hasIcon() {
|
||||
return this.icon != null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
|
||||
import { ButtonModule } from "../../button";
|
||||
import { DialogModule } from "../dialog.module";
|
||||
@@ -57,8 +57,24 @@ export const CustomIcon: Story = {
|
||||
}),
|
||||
};
|
||||
|
||||
export const HideIcon: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-simple-dialog hideIcon>
|
||||
<span bitDialogTitle>Premium Subscription Available</span>
|
||||
<span bitDialogContent> Message Content</span>
|
||||
<ng-container bitDialogFooter>
|
||||
<button bitButton buttonType="primary">Yes</button>
|
||||
<button bitButton buttonType="secondary">No</button>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const ScrollingContent: Story = {
|
||||
render: (args: SimpleDialogComponent) => ({
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-simple-dialog>
|
||||
|
||||
@@ -20,6 +20,6 @@
|
||||
></vault-carousel-button>
|
||||
</div>
|
||||
<div class="tw-absolute tw-invisible" #tempSlideContainer *ngIf="minHeight === null">
|
||||
<ng-template [cdkPortalOutlet] #tempSlideOutlet></ng-template>
|
||||
<ng-template cdkPortalOutlet></ng-template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
ContentChildren,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
Output,
|
||||
QueryList,
|
||||
ViewChild,
|
||||
ViewChildren,
|
||||
inject,
|
||||
} from "@angular/core";
|
||||
|
||||
import { ButtonModule } from "@bitwarden/components";
|
||||
@@ -89,7 +89,7 @@ export class VaultCarouselComponent implements AfterViewInit {
|
||||
this.slideChange.emit(index);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
async ngAfterViewInit() {
|
||||
this.keyManager = new FocusKeyManager(this.carouselButtons)
|
||||
.withHorizontalOrientation("ltr")
|
||||
.withWrap()
|
||||
@@ -98,7 +98,7 @@ export class VaultCarouselComponent implements AfterViewInit {
|
||||
// Set the first carousel button as active, this avoids having to double tab the arrow keys on initial focus.
|
||||
this.keyManager.setFirstItemActive();
|
||||
|
||||
this.setMinHeightOfCarousel();
|
||||
await this.setMinHeightOfCarousel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,7 +106,7 @@ export class VaultCarouselComponent implements AfterViewInit {
|
||||
* Render each slide in a temporary portal outlet to get the height of each slide
|
||||
* and store the tallest slide height.
|
||||
*/
|
||||
private setMinHeightOfCarousel() {
|
||||
private async setMinHeightOfCarousel() {
|
||||
// Store the height of the carousel button element.
|
||||
const heightOfButtonsPx = this.carouselButtonWrapper.nativeElement.offsetHeight;
|
||||
|
||||
@@ -121,13 +121,14 @@ export class VaultCarouselComponent implements AfterViewInit {
|
||||
// to determine the height of the first slide.
|
||||
let tallestSlideHeightPx = containerHeight - heightOfButtonsPx;
|
||||
|
||||
this.slides.forEach((slide, index) => {
|
||||
// Skip the first slide, the height is accounted for above.
|
||||
if (index === this.selectedIndex) {
|
||||
return;
|
||||
for (let i = 0; i < this.slides.length; i++) {
|
||||
if (i === this.selectedIndex) {
|
||||
continue;
|
||||
}
|
||||
this.tempSlideOutlet.attach(this.slides.get(i)!.content);
|
||||
|
||||
this.tempSlideOutlet.attach(slide.content);
|
||||
// Wait for the slide to render. Otherwise, the previous slide may not have been removed from the DOM yet.
|
||||
await new Promise(requestAnimationFrame);
|
||||
|
||||
// Store the height of the current slide if is larger than the current stored height;
|
||||
if (this.tempSlideContainer.nativeElement.offsetHeight > tallestSlideHeightPx) {
|
||||
@@ -136,8 +137,7 @@ export class VaultCarouselComponent implements AfterViewInit {
|
||||
|
||||
// cleanup the outlet
|
||||
this.tempSlideOutlet.detach();
|
||||
});
|
||||
|
||||
}
|
||||
// Set the min height of the entire carousel based on the largest slide.
|
||||
this.minHeight = `${tallestSlideHeightPx + heightOfButtonsPx}px`;
|
||||
this.changeDetectorRef.detectChanges();
|
||||
|
||||
10
libs/vault/src/components/carousel/carousel.module.ts
Normal file
10
libs/vault/src/components/carousel/carousel.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component";
|
||||
import { VaultCarouselComponent } from "./carousel.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [VaultCarouselComponent, VaultCarouselSlideComponent],
|
||||
exports: [VaultCarouselComponent, VaultCarouselSlideComponent],
|
||||
})
|
||||
export class VaultCarouselModule {}
|
||||
@@ -1 +1 @@
|
||||
export { VaultCarouselComponent } from "./carousel.component";
|
||||
export { VaultCarouselModule } from "./carousel.module";
|
||||
|
||||
62
libs/vault/src/components/dark-image-source.directive.ts
Normal file
62
libs/vault/src/components/dark-image-source.directive.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
DestroyRef,
|
||||
Directive,
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
inject,
|
||||
input,
|
||||
OnInit,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { combineLatest, Observable } from "rxjs";
|
||||
|
||||
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
|
||||
import { Theme } from "@bitwarden/common/platform/enums";
|
||||
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
|
||||
|
||||
/**
|
||||
* Directive that will switch the image source based on the currently applied theme.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <img src="light-image.png" appDarkImgSrc="dark-image.png" />
|
||||
* ```
|
||||
*/
|
||||
@Directive({
|
||||
selector: "[appDarkImgSrc]",
|
||||
standalone: true,
|
||||
})
|
||||
export class DarkImageSourceDirective implements OnInit {
|
||||
private themeService = inject(ThemeStateService);
|
||||
private systemTheme$: Observable<Theme> = inject(SYSTEM_THEME_OBSERVABLE);
|
||||
private el = inject(ElementRef<HTMLElement>);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
/**
|
||||
* The image source to use when the light theme is applied. Automatically assigned the value
|
||||
* of the `<img>` src attribute.
|
||||
*/
|
||||
protected lightImgSrc: string | undefined;
|
||||
|
||||
/**
|
||||
* The image source to use when the dark theme is applied.
|
||||
*/
|
||||
darkImgSrc = input.required<string>({ alias: "appDarkImgSrc" });
|
||||
|
||||
@HostBinding("attr.src") src: string | undefined;
|
||||
|
||||
ngOnInit() {
|
||||
// Set the light image source from the element's current src attribute
|
||||
this.lightImgSrc = this.el.nativeElement.getAttribute("src");
|
||||
|
||||
// Update the image source based on the active theme
|
||||
combineLatest([this.themeService.selectedTheme$, this.systemTheme$])
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(([theme, systemTheme]) => {
|
||||
const appliedTheme = theme === "system" ? systemTheme : theme;
|
||||
const isDark =
|
||||
appliedTheme === "dark" || appliedTheme === "nord" || appliedTheme === "solarizedDark";
|
||||
this.src = isDark ? this.darkImgSrc() : this.lightImgSrc;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field
|
||||
export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive";
|
||||
export { OrgIconDirective } from "./components/org-icon.directive";
|
||||
export { CanDeleteCipherDirective } from "./components/can-delete-cipher.directive";
|
||||
export { DarkImageSourceDirective } from "./components/dark-image-source.directive";
|
||||
|
||||
export * from "./utils/observable-utilities";
|
||||
|
||||
@@ -21,6 +22,7 @@ export { NewDeviceVerificationNoticePageOneComponent } from "./components/new-de
|
||||
export { NewDeviceVerificationNoticePageTwoComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-two.component";
|
||||
export { DecryptionFailureDialogComponent } from "./components/decryption-failure-dialog/decryption-failure-dialog.component";
|
||||
export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.component";
|
||||
export * from "./components/carousel";
|
||||
|
||||
export * as VaultIcons from "./icons";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user