1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-27 06:43:41 +00:00

Merge branch 'main' into dirt/pm-30718/indexeddb-service-hookup

This commit is contained in:
Leslie Tilton
2026-01-23 13:36:55 -06:00
committed by GitHub
64 changed files with 1839 additions and 485 deletions

View File

@@ -1565,7 +1565,6 @@ export default class MainBackground {
await this.sdkLoadService.loadAndInit();
// Only the "true" background should run migrations
await this.migrationRunner.run();
this.encryptService.init(this.configService);
// This is here instead of in the InitService b/c we don't plan for
// side effects to run in the Browser InitService.

View File

@@ -2,8 +2,6 @@ import { inject, Inject, Injectable, DOCUMENT } from "@angular/core";
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -30,8 +28,6 @@ export class InitService {
private sdkLoadService: SdkLoadService,
private viewCacheService: PopupViewCacheService,
private readonly migrationRunner: MigrationRunner,
private configService: ConfigService,
private encryptService: EncryptService,
@Inject(DOCUMENT) private document: Document,
) {}
@@ -43,7 +39,6 @@ export class InitService {
this.twoFactorService.init();
await this.viewCacheService.init();
await this.sizeService.init();
this.encryptService.init(this.configService);
const htmlEl = window.document.documentElement;
this.themingService.applyThemeChangesTo(this.document);

View File

@@ -1058,7 +1058,6 @@ export class ServiceContainer {
this.containerService.attachToGlobal(global);
await this.i18nService.init();
this.twoFactorService.init();
this.encryptService.init(this.configService);
// If a user has a BW_SESSION key stored in their env (not process.env.BW_SESSION),
// this should set the user key to unlock the vault on init.

View File

@@ -8,7 +8,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { DefaultVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
@@ -54,7 +53,6 @@ export class InitService {
private autotypeService: DesktopAutotypeService,
private sdkLoadService: SdkLoadService,
private biometricMessageHandlerService: BiometricMessageHandlerService,
private configService: ConfigService,
@Inject(DOCUMENT) private document: Document,
private readonly migrationRunner: MigrationRunner,
) {}
@@ -65,7 +63,6 @@ export class InitService {
await this.sshAgentService.init();
this.nativeMessagingService.init();
await this.migrationRunner.waitForCompletion(); // Desktop will run migrations in the main process
this.encryptService.init(this.configService);
const accounts = await firstValueFrom(this.accountService.accounts$);
const setUserKeyInMemoryPromises = [];

View File

@@ -60,7 +60,7 @@
buttonType="primary"
(click)="addSendWithoutType()"
>
{{ "newSend" | i18n }}
{{ "createSend" | i18n }}
</button>
</tools-send-list>
</div>

View File

@@ -79,7 +79,6 @@ export class SendV2Component {
protected readonly sendId = signal<string | null>(null);
protected readonly action = signal<Action>(Action.None);
private readonly selectedSendTypeOverride = signal<SendType | undefined>(undefined);
private sendFormConfigService = inject(DefaultSendFormConfigService);
private sendItemsService = inject(SendItemsService);
@@ -151,10 +150,9 @@ export class SendV2Component {
protected readonly selectedSendType = computed(() => {
const action = this.action();
const typeOverride = this.selectedSendTypeOverride();
if (action === Action.Add && typeOverride !== undefined) {
return typeOverride;
if (action === Action.Add) {
return undefined;
}
const sendId = this.sendId();
@@ -173,24 +171,20 @@ export class SendV2Component {
} else {
this.action.set(Action.Add);
this.sendId.set(null);
this.selectedSendTypeOverride.set(type);
const component = this.addEditComponent();
if (component) {
await component.resetAndLoad();
}
this.cdr.detectChanges();
void this.addEditComponent()?.resetAndLoad();
}
}
/** Used by old UI to add a send without specifying type (defaults to Text) */
/** Used by old UI to add a send without specifying type (defaults to File) */
protected async addSendWithoutType(): Promise<void> {
await this.addSend(SendType.Text);
await this.addSend(SendType.File);
}
protected closeEditPanel(): void {
this.action.set(Action.None);
this.sendId.set(null);
this.selectedSendTypeOverride.set(undefined);
}
protected async savedSend(send: SendView): Promise<void> {

View File

@@ -6,11 +6,11 @@
<bit-nav-group icon="bwi-vault" [text]="'vault' | i18n" route="new-vault">
<app-organization-filter
[activeFilter]="activeFilter()"
[organizations]="organizations$ | async"
[organizations]="organizations()"
[activeOrganizationDataOwnership]="activeOrganizationDataOwnershipPolicy"
[activeSingleOrganizationPolicy]="activeSingleOrganizationPolicy"
/>
<app-type-filter [activeFilter]="activeFilter()" [cipherTypes]="cipherTypes$ | async" />
<app-type-filter [activeFilter]="activeFilter()" [cipherTypes]="cipherTypes()" />
<app-status-filter [hideArchive]="!showArchiveVaultFilter" [activeFilter]="activeFilter()" />
@if (showCollectionsFilter()) {
<bit-nav-group
@@ -20,7 +20,7 @@
[appA11yTitle]="'collections' | i18n"
[disableToggleOnClick]="true"
>
@for (collection of (collections$ | async)?.children ?? []; track collection.node.id) {
@for (collection of collections()?.children ?? []; track collection.node.id) {
<app-collection-filter [activeFilter]="activeFilter()" [collection]="collection" />
}
</bit-nav-group>
@@ -32,7 +32,7 @@
[appA11yTitle]="'folders' | i18n"
[disableToggleOnClick]="true"
>
@for (folder of (folders$ | async)?.children ?? []; track folder.node.id) {
@for (folder of folders()?.children ?? []; track folder.node.id) {
<app-folder-filter
[activeFilter]="activeFilter()"
[folder]="folder"

View File

@@ -2,7 +2,8 @@
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, inject, OnInit, output, computed, signal } from "@angular/core";
import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs";
import { toSignal } from "@angular/core/rxjs-interop";
import { firstValueFrom, Subject, takeUntil } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -12,13 +13,9 @@ import { UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { NavigationModule, DialogService, A11yTitleDirective } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
OrganizationFilter,
CipherTypeFilter,
CollectionFilter,
FolderFilter,
VaultFilter,
VaultFilterServiceAbstraction as VaultFilterService,
@@ -75,13 +72,25 @@ export class VaultFilterComponent implements OnInit {
protected showArchiveVaultFilter = false;
protected activeOrganizationDataOwnershipPolicy: boolean;
protected activeSingleOrganizationPolicy: boolean;
protected organizations$: Observable<TreeNode<OrganizationFilter>>;
protected collections$: Observable<TreeNode<CollectionFilter>>;
protected folders$: Observable<TreeNode<FolderFilter>>;
protected cipherTypes$: Observable<TreeNode<CipherTypeFilter>>;
protected readonly organizations = toSignal(this.vaultFilterService.organizationTree$);
protected readonly collections = toSignal(this.vaultFilterService.collectionTree$);
protected readonly folders = toSignal(this.vaultFilterService.folderTree$);
protected readonly cipherTypes = toSignal(this.vaultFilterService.cipherTypeTree$);
protected readonly showCollectionsFilter = computed<boolean>(() => {
return this.organizations$ != null && !this.activeFilter()?.isMyVaultSelected;
return (
this.organizations() != null &&
!this.activeFilter()?.isMyVaultSelected &&
!this.allOrganizationsDisabled()
);
});
protected readonly allOrganizationsDisabled = computed<boolean>(() => {
if (!this.organizations()) {
return false;
}
const orgs = this.organizations().children.filter((org) => org.node.id !== "MyVault");
return orgs.length > 0 && orgs.every((org) => !org.node.enabled);
});
private async setActivePolicies() {
@@ -98,16 +107,9 @@ export class VaultFilterComponent implements OnInit {
async ngOnInit(): Promise<void> {
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.organizations$ = this.vaultFilterService.organizationTree$;
if (
this.organizations$ != null &&
(await firstValueFrom(this.organizations$)).children.length > 0
) {
if (this.organizations() != null && this.organizations().children.length > 0) {
await this.setActivePolicies();
}
this.cipherTypes$ = this.vaultFilterService.cipherTypeTree$;
this.folders$ = this.vaultFilterService.folderTree$;
this.collections$ = this.vaultFilterService.collectionTree$;
this.showArchiveVaultFilter = await firstValueFrom(
this.cipherArchiveService.hasArchiveFlagEnabled$,

View File

@@ -805,6 +805,8 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
type: CipherViewLikeUtils.getType(cipher),
// Normalize undefined organizationId to null for filter compatibility
organizationId: cipher.organizationId ?? null,
// Normalize empty string folderId to null for filter compatibility
folderId: cipher.folderId ? cipher.folderId : null,
// Explicitly include isDeleted and isArchived since they might be getters
isDeleted: CipherViewLikeUtils.isDeleted(cipher),
isArchived: CipherViewLikeUtils.isArchived(cipher),

View File

@@ -25,7 +25,7 @@
(click)="invite(organization)"
[disabled]="!firstLoaded()"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
<i class="bwi bwi-plus bwi-fw tw-me-2" aria-hidden="true"></i>
{{ "inviteMember" | i18n }}
</button>
}

View File

@@ -8,7 +8,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { DefaultVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { IpcService } from "@bitwarden/common/platform/ipc";
@@ -40,7 +39,6 @@ export class InitService {
private ipcService: IpcService,
private sdkLoadService: SdkLoadService,
private taskService: TaskService,
private configService: ConfigService,
private readonly migrationRunner: MigrationRunner,
@Inject(DOCUMENT) private document: Document,
) {}
@@ -49,7 +47,6 @@ export class InitService {
return async () => {
await this.sdkLoadService.loadAndInit();
await this.migrationRunner.run();
this.encryptService.init(this.configService);
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (activeAccount) {

View File

@@ -1,5 +1,5 @@
<button bitButton [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
<i *ngIf="!hideIcon" class="bwi bwi-plus" aria-hidden="true"></i>
<i *ngIf="!hideIcon" class="bwi bwi-plus tw-me-2" aria-hidden="true"></i>
{{ (hideIcon ? "createSend" : "new") | i18n }}
</button>
<bit-menu #itemOptions>

View File

@@ -17,7 +17,7 @@
</h3>
<p bitTypography="body1" class="tw-mb-6 tw-max-w-sm">
{{ "sendCreatedDescription" | i18n: formattedExpirationTime }}
{{ "sendCreatedDescriptionV2" | i18n: formattedExpirationTime }}
</p>
<bit-form-field class="tw-w-full tw-max-w-sm tw-mb-4">

View File

@@ -87,7 +87,7 @@
@if (showActionButtons) {
<div class="tw-ml-auto">
@if ((userCanArchive$ | async) && !params.isAdminConsoleAction) {
@if (isCipherArchived) {
@if (isCipherArchived && !cipher?.isDeleted) {
<button
type="button"
class="tw-mr-1"

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@@ -5626,13 +5626,13 @@
"message": "Send created successfully!",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendCreatedDescription": {
"message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.",
"sendCreatedDescriptionV2": {
"message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.",
"placeholders": {
"time": {
"content": "$1",
"example": "7 days"
"example": "7 days, 1 hour, 1 day"
}
}
},
@@ -10432,6 +10432,9 @@
"datadogEventIntegrationDesc": {
"message": "Send vault event data to your Datadog instance"
},
"huntressEventIntegrationDesc": {
"message": "Send event data to your Huntress SIEM instance"
},
"failedToSaveIntegration": {
"message": "Failed to save integration. Please try again later."
},
@@ -10543,6 +10546,12 @@
"index": {
"message": "Index"
},
"httpEventCollectorUrl": {
"message": "HTTP Event Collector URL"
},
"httpEventCollectorToken": {
"message": "HTTP Event Collector Token"
},
"selectAPlan": {
"message": "Select a plan"
},

View File

@@ -3,15 +3,21 @@ import { OrganizationIntegrationServiceName } from "../organization-integration-
export class HecConfiguration implements OrgIntegrationConfiguration {
uri: string;
scheme = "Bearer";
scheme: string;
token: string;
service?: string;
bw_serviceName: OrganizationIntegrationServiceName;
constructor(uri: string, token: string, bw_serviceName: OrganizationIntegrationServiceName) {
constructor(
uri: string,
token: string,
bw_serviceName: OrganizationIntegrationServiceName,
scheme: string = "Bearer",
) {
this.uri = uri;
this.token = token;
this.bw_serviceName = bw_serviceName;
this.scheme = scheme;
}
toString(): string {

View File

@@ -0,0 +1,338 @@
import { DatadogConfiguration } from "./configuration/datadog-configuration";
import { HecConfiguration } from "./configuration/hec-configuration";
import { OrgIntegrationBuilder } from "./integration-builder";
import { DatadogTemplate } from "./integration-configuration-config/configuration-template/datadog-template";
import { HecTemplate } from "./integration-configuration-config/configuration-template/hec-template";
import { OrganizationIntegrationServiceName } from "./organization-integration-service-type";
import { OrganizationIntegrationType } from "./organization-integration-type";
describe("OrgIntegrationBuilder", () => {
describe("buildHecConfiguration", () => {
const testUri = "https://hec.example.com:8088/services/collector";
const testToken = "test-token";
it("should create HecConfiguration with correct values", () => {
const config = OrgIntegrationBuilder.buildHecConfiguration(
testUri,
testToken,
OrganizationIntegrationServiceName.Huntress,
);
expect(config).toBeInstanceOf(HecConfiguration);
expect((config as HecConfiguration).uri).toBe(testUri);
expect((config as HecConfiguration).token).toBe(testToken);
expect(config.bw_serviceName).toBe(OrganizationIntegrationServiceName.Huntress);
});
it("should use default Bearer scheme", () => {
const config = OrgIntegrationBuilder.buildHecConfiguration(
testUri,
testToken,
OrganizationIntegrationServiceName.Huntress,
);
expect((config as HecConfiguration).scheme).toBe("Bearer");
});
it("should use custom scheme when provided", () => {
const config = OrgIntegrationBuilder.buildHecConfiguration(
testUri,
testToken,
OrganizationIntegrationServiceName.CrowdStrike,
"Splunk",
);
expect((config as HecConfiguration).scheme).toBe("Splunk");
});
it("should work with CrowdStrike service name", () => {
const config = OrgIntegrationBuilder.buildHecConfiguration(
testUri,
testToken,
OrganizationIntegrationServiceName.CrowdStrike,
);
expect(config.bw_serviceName).toBe(OrganizationIntegrationServiceName.CrowdStrike);
});
});
describe("buildHecTemplate", () => {
it("should create HecTemplate with correct values", () => {
const template = OrgIntegrationBuilder.buildHecTemplate(
"main",
OrganizationIntegrationServiceName.Huntress,
);
expect(template).toBeInstanceOf(HecTemplate);
expect((template as HecTemplate).index).toBe("main");
expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Huntress);
});
it("should handle empty index", () => {
const template = OrgIntegrationBuilder.buildHecTemplate(
"",
OrganizationIntegrationServiceName.Huntress,
);
expect((template as HecTemplate).index).toBe("");
});
});
describe("buildDataDogConfiguration", () => {
const testUri = "https://http-intake.logs.datadoghq.com/api/v2/logs";
const testApiKey = "test-api-key";
it("should create DatadogConfiguration with correct values", () => {
const config = OrgIntegrationBuilder.buildDataDogConfiguration(testUri, testApiKey);
expect(config).toBeInstanceOf(DatadogConfiguration);
expect((config as DatadogConfiguration).uri).toBe(testUri);
expect((config as DatadogConfiguration).apiKey).toBe(testApiKey);
});
it("should always use Datadog service name", () => {
const config = OrgIntegrationBuilder.buildDataDogConfiguration(testUri, testApiKey);
expect(config.bw_serviceName).toBe(OrganizationIntegrationServiceName.Datadog);
});
});
describe("buildDataDogTemplate", () => {
it("should create DatadogTemplate with correct service name", () => {
const template = OrgIntegrationBuilder.buildDataDogTemplate(
OrganizationIntegrationServiceName.Datadog,
);
expect(template).toBeInstanceOf(DatadogTemplate);
expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Datadog);
});
});
describe("buildConfiguration", () => {
describe("HEC type", () => {
it("should build HecConfiguration from JSON string", () => {
const json = JSON.stringify({
Uri: "https://hec.example.com",
Token: "test-token",
Scheme: "Bearer",
bw_serviceName: OrganizationIntegrationServiceName.Huntress,
});
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Hec,
json,
);
expect(config).toBeInstanceOf(HecConfiguration);
expect((config as HecConfiguration).uri).toBe("https://hec.example.com");
expect((config as HecConfiguration).token).toBe("test-token");
expect((config as HecConfiguration).scheme).toBe("Bearer");
});
it("should normalize PascalCase properties to camelCase", () => {
const json = JSON.stringify({
Uri: "https://hec.example.com",
Token: "test-token",
Scheme: "Splunk",
bw_serviceName: OrganizationIntegrationServiceName.CrowdStrike,
});
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Hec,
json,
);
expect((config as HecConfiguration).uri).toBe("https://hec.example.com");
expect((config as HecConfiguration).token).toBe("test-token");
expect((config as HecConfiguration).scheme).toBe("Splunk");
});
});
describe("Datadog type", () => {
it("should build DatadogConfiguration from JSON string", () => {
const json = JSON.stringify({
Uri: "https://datadoghq.com/api",
ApiKey: "dd-api-key",
bw_serviceName: OrganizationIntegrationServiceName.Datadog,
});
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Datadog,
json,
);
expect(config).toBeInstanceOf(DatadogConfiguration);
expect((config as DatadogConfiguration).uri).toBe("https://datadoghq.com/api");
expect((config as DatadogConfiguration).apiKey).toBe("dd-api-key");
});
});
describe("error handling", () => {
it("should throw for unsupported integration type", () => {
const json = JSON.stringify({ uri: "test" });
expect(() =>
OrgIntegrationBuilder.buildConfiguration(999 as OrganizationIntegrationType, json),
).toThrow("Unsupported integration type: 999");
});
it("should throw for invalid JSON", () => {
expect(() =>
OrgIntegrationBuilder.buildConfiguration(OrganizationIntegrationType.Hec, "invalid-json"),
).toThrow("Invalid integration configuration: JSON parse error");
});
it("should handle empty JSON string by using empty object", () => {
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Hec,
"",
);
expect(config).toBeInstanceOf(HecConfiguration);
});
it("should handle undefined values in JSON", () => {
const json = JSON.stringify({});
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Hec,
json,
);
expect(config).toBeInstanceOf(HecConfiguration);
expect((config as HecConfiguration).uri).toBeUndefined();
});
});
});
describe("buildTemplate", () => {
describe("HEC type", () => {
it("should build HecTemplate from JSON string", () => {
const json = JSON.stringify({
index: "main",
bw_serviceName: OrganizationIntegrationServiceName.Huntress,
});
const template = OrgIntegrationBuilder.buildTemplate(OrganizationIntegrationType.Hec, json);
expect(template).toBeInstanceOf(HecTemplate);
expect((template as HecTemplate).index).toBe("main");
expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Huntress);
});
it("should normalize PascalCase properties", () => {
const json = JSON.stringify({
Index: "security",
bw_serviceName: OrganizationIntegrationServiceName.CrowdStrike,
});
const template = OrgIntegrationBuilder.buildTemplate(OrganizationIntegrationType.Hec, json);
expect((template as HecTemplate).index).toBe("security");
});
});
describe("Datadog type", () => {
it("should build DatadogTemplate from JSON string", () => {
const json = JSON.stringify({
bw_serviceName: OrganizationIntegrationServiceName.Datadog,
});
const template = OrgIntegrationBuilder.buildTemplate(
OrganizationIntegrationType.Datadog,
json,
);
expect(template).toBeInstanceOf(DatadogTemplate);
expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Datadog);
});
});
describe("error handling", () => {
it("should throw for unsupported integration type", () => {
const json = JSON.stringify({ index: "test" });
expect(() =>
OrgIntegrationBuilder.buildTemplate(999 as OrganizationIntegrationType, json),
).toThrow("Unsupported integration type: 999");
});
it("should throw for invalid JSON", () => {
expect(() =>
OrgIntegrationBuilder.buildTemplate(OrganizationIntegrationType.Hec, "invalid-json"),
).toThrow("Invalid integration configuration: JSON parse error");
});
});
});
describe("property case normalization", () => {
it("should convert first character to lowercase", () => {
const json = JSON.stringify({
Uri: "https://example.com",
Token: "token",
Scheme: "Bearer",
bw_serviceName: "Huntress",
});
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Hec,
json,
);
// Verify the properties were normalized (accessed via camelCase)
expect((config as HecConfiguration).uri).toBe("https://example.com");
expect((config as HecConfiguration).token).toBe("token");
});
it("should handle nested objects", () => {
// Using Datadog type which has nested enrichment_details
const json = JSON.stringify({
Uri: "https://datadoghq.com",
ApiKey: "key",
NestedObject: {
InnerProperty: "value",
},
});
// This tests that nested properties are also normalized
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Datadog,
json,
);
expect(config).toBeInstanceOf(DatadogConfiguration);
});
it("should handle arrays", () => {
const json = JSON.stringify({
Uri: "https://example.com",
Token: "token",
Items: [{ Name: "item1" }, { Name: "item2" }],
bw_serviceName: "Huntress",
});
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Hec,
json,
);
expect(config).toBeInstanceOf(HecConfiguration);
});
it("should preserve properties that start with lowercase", () => {
const json = JSON.stringify({
uri: "https://example.com",
token: "token",
bw_serviceName: "Huntress",
});
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Hec,
json,
);
expect((config as HecConfiguration).uri).toBe("https://example.com");
expect((config as HecConfiguration).token).toBe("token");
});
});
});

View File

@@ -21,6 +21,11 @@ export interface OrgIntegrationTemplate {
toString(): string;
}
export const Schemas = {
Bearer: "Bearer",
Splunk: "Splunk",
} as const;
/**
* Builder class for creating organization integration configurations and templates
*/
@@ -29,8 +34,9 @@ export class OrgIntegrationBuilder {
uri: string,
token: string,
bw_serviceName: OrganizationIntegrationServiceName,
scheme: string = Schemas.Bearer,
): OrgIntegrationConfiguration {
return new HecConfiguration(uri, token, bw_serviceName);
return new HecConfiguration(uri, token, bw_serviceName, scheme);
}
static buildHecTemplate(
@@ -57,7 +63,12 @@ export class OrgIntegrationBuilder {
switch (type) {
case OrganizationIntegrationType.Hec: {
const hecConfig = this.convertToJson<HecConfiguration>(configuration);
return this.buildHecConfiguration(hecConfig.uri, hecConfig.token, hecConfig.bw_serviceName);
return this.buildHecConfiguration(
hecConfig.uri,
hecConfig.token,
hecConfig.bw_serviceName,
hecConfig.scheme,
);
}
case OrganizationIntegrationType.Datadog: {
const datadogConfig = this.convertToJson<DatadogConfiguration>(configuration);

View File

@@ -2,8 +2,6 @@ import { OrgIntegrationTemplate } from "../../integration-builder";
import { OrganizationIntegrationServiceName } from "../../organization-integration-service-type";
export class HecTemplate implements OrgIntegrationTemplate {
event = "#EventMessage#";
source = "Bitwarden";
index: string;
bw_serviceName: OrganizationIntegrationServiceName;
@@ -12,12 +10,46 @@ export class HecTemplate implements OrgIntegrationTemplate {
this.bw_serviceName = service;
}
toString(): string {
return JSON.stringify({
Event: this.event,
Source: this.source,
Index: this.index,
private toJSON() {
const template: Record<string, any> = {
bw_serviceName: this.bw_serviceName,
});
source: "bitwarden",
service: "event-logs",
event: {
object: "event",
type: "#Type#",
itemId: "#CipherId#",
collectionId: "#CollectionId#",
groupId: "#GroupId#",
policyId: "#PolicyId#",
memberId: "#UserId#",
actingUserId: "#ActingUserId#",
installationId: "#InstallationId#",
date: "#DateIso8601#",
device: "#DeviceType#",
ipAddress: "#IpAddress#",
secretId: "#SecretId#",
projectId: "#ProjectId#",
serviceAccountId: "#ServiceAccountId#",
actingUserName: "#ActingUserName#",
actingUserEmail: "#ActingUserEmail#",
actingUserType: "#ActingUserType#",
userName: "#UserName#",
userEmail: "#UserEmail#",
userType: "#UserType#",
groupName: "#GroupName#",
},
};
// Only include index if it's provided
if (this.index && this.index.trim() !== "") {
template.index = this.index;
}
return template;
}
toString(): string {
return JSON.stringify(this.toJSON());
}
}

View File

@@ -1,6 +1,7 @@
export const OrganizationIntegrationServiceName = Object.freeze({
CrowdStrike: "CrowdStrike",
Datadog: "Datadog",
Huntress: "Huntress",
} as const);
export type OrganizationIntegrationServiceName =

View File

@@ -16,13 +16,15 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { HecConnectDialogResultStatus, openHecConnectDialog } from "../integration-dialog";
import { IntegrationDialogResultStatus, openHecConnectDialog } from "../integration-dialog";
import { IntegrationCardComponent } from "./integration-card.component";
jest.mock("../integration-dialog", () => ({
openHecConnectDialog: jest.fn(),
HecConnectDialogResultStatus: { Edited: "edit", Delete: "delete" },
openDatadogConnectDialog: jest.fn(),
openHuntressConnectDialog: jest.fn(),
IntegrationDialogResultStatus: { Edited: "edit", Delete: "delete" },
}));
describe("IntegrationCardComponent", () => {
@@ -276,7 +278,7 @@ describe("IntegrationCardComponent", () => {
it("should call updateHec if isUpdateAvailable is true", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Edited,
success: IntegrationDialogResultStatus.Edited,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -317,7 +319,7 @@ describe("IntegrationCardComponent", () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Edited,
success: IntegrationDialogResultStatus.Edited,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -354,7 +356,7 @@ describe("IntegrationCardComponent", () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Delete,
success: IntegrationDialogResultStatus.Delete,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -382,7 +384,7 @@ describe("IntegrationCardComponent", () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Delete,
success: IntegrationDialogResultStatus.Delete,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -404,7 +406,7 @@ describe("IntegrationCardComponent", () => {
it("should show toast on error while saving", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Edited,
success: IntegrationDialogResultStatus.Edited,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -427,7 +429,7 @@ describe("IntegrationCardComponent", () => {
it("should show mustBeOwner toast on error while inserting data", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Edited,
success: IntegrationDialogResultStatus.Edited,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -450,7 +452,7 @@ describe("IntegrationCardComponent", () => {
it("should show mustBeOwner toast on error while updating data", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Edited,
success: IntegrationDialogResultStatus.Edited,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -472,7 +474,7 @@ describe("IntegrationCardComponent", () => {
it("should show toast on error while deleting", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Delete,
success: IntegrationDialogResultStatus.Delete,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -495,7 +497,7 @@ describe("IntegrationCardComponent", () => {
it("should show mustbeOwner toast on 404 while deleting", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Delete,
success: IntegrationDialogResultStatus.Delete,
url: "test-url",
bearerToken: "token",
index: "index",

View File

@@ -12,7 +12,12 @@ import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rx
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { OrgIntegrationBuilder } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-builder";
import {
OrgIntegrationBuilder,
OrgIntegrationConfiguration,
OrgIntegrationTemplate,
Schemas,
} from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-builder";
import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type";
import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
@@ -23,7 +28,6 @@ import { OrganizationId } from "@bitwarden/common/types/guid";
import {
BaseCardComponent,
CardContentComponent,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
@@ -32,10 +36,11 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared";
import {
HecConnectDialogResult,
DatadogConnectDialogResult,
HecConnectDialogResultStatus,
DatadogConnectDialogResultStatus,
HuntressConnectDialogResult,
IntegrationDialogResultStatus,
openDatadogConnectDialog,
openHecConnectDialog,
openHuntressConnectDialog,
} from "../integration-dialog/index";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -164,14 +169,12 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
}
async setupConnection() {
let dialog: DialogRef<DatadogConnectDialogResult | HecConnectDialogResult, unknown>;
if (this.integrationSettings?.integrationType === null) {
return;
}
if (this.integrationSettings?.integrationType === OrganizationIntegrationType.Datadog) {
dialog = openDatadogConnectDialog(this.dialogService, {
const dialog = openDatadogConnectDialog(this.dialogService, {
data: {
settings: this.integrationSettings,
},
@@ -179,37 +182,29 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
const result = await lastValueFrom(dialog.closed);
// the dialog was cancelled
if (!result || !result.success) {
return;
}
await this.handleIntegrationDialogResult(
result,
() => this.deleteDatadog(),
(res) => this.saveDatadog(res),
);
} else if (this.integrationSettings.name === OrganizationIntegrationServiceName.Huntress) {
// Huntress uses HEC protocol but has its own dialog
const dialog = openHuntressConnectDialog(this.dialogService, {
data: {
settings: this.integrationSettings,
},
});
try {
if (result.success === HecConnectDialogResultStatus.Delete) {
await this.deleteDatadog();
}
} catch {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("failedToDeleteIntegration"),
});
}
const result = await lastValueFrom(dialog.closed);
try {
if (result.success === DatadogConnectDialogResultStatus.Edited) {
await this.saveDatadog(result as DatadogConnectDialogResult);
}
} catch {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("failedToSaveIntegration"),
});
}
await this.handleIntegrationDialogResult(
result,
() => this.deleteHuntress(),
(res) => this.saveHuntress(res),
);
} else {
// invoke the dialog to connect the integration
dialog = openHecConnectDialog(this.dialogService, {
const dialog = openHecConnectDialog(this.dialogService, {
data: {
settings: this.integrationSettings,
},
@@ -217,15 +212,113 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
const result = await lastValueFrom(dialog.closed);
// the dialog was cancelled
if (!result || !result.success) {
return;
await this.handleIntegrationDialogResult(
result,
() => this.deleteHec(),
(res) => this.saveHec(res),
);
}
}
/**
* Generic save method
*/
private async saveIntegration(
integrationType: OrganizationIntegrationType,
config: OrgIntegrationConfiguration,
template: OrgIntegrationTemplate,
): Promise<void> {
let response = { mustBeOwner: false, success: false };
if (this.isUpdateAvailable) {
// retrieve org integration and configuration ids
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
const orgIntegrationConfigurationId =
this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id;
if (!orgIntegrationId || !orgIntegrationConfigurationId) {
throw Error("Organization Integration ID or Configuration ID is missing");
}
// update existing integration and configuration
response = await this.organizationIntegrationService.update(
this.organizationId,
orgIntegrationId,
integrationType,
orgIntegrationConfigurationId,
config,
template,
);
} else {
// create new integration and configuration
response = await this.organizationIntegrationService.save(
this.organizationId,
integrationType,
config,
template,
);
}
if (response.mustBeOwner) {
this.showMustBeOwnerToast();
return;
}
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("success"),
});
}
/**
* Generic delete method
*/
private async deleteIntegration(): Promise<void> {
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
const orgIntegrationConfigurationId =
this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id;
if (!orgIntegrationId || !orgIntegrationConfigurationId) {
throw Error("Organization Integration ID or Configuration ID is missing");
}
const response = await this.organizationIntegrationService.delete(
this.organizationId,
orgIntegrationId,
orgIntegrationConfigurationId,
);
if (response.mustBeOwner) {
this.showMustBeOwnerToast();
return;
}
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("success"),
});
}
/**
* Generic dialog result handler
* Handles both delete and edit actions with proper error handling
*/
private async handleIntegrationDialogResult<T extends { success: string | null }>(
result: T | undefined,
deleteCallback: () => Promise<void>,
saveCallback: (result: T) => Promise<void>,
): Promise<void> {
// User cancelled the dialog or closed it without saving
if (!result || !result.success) {
return;
}
// Handle delete action
if (result.success === IntegrationDialogResultStatus.Delete) {
try {
if (result.success === HecConnectDialogResultStatus.Delete) {
await this.deleteHec();
}
await deleteCallback();
} catch {
this.toastService.showToast({
variant: "error",
@@ -233,11 +326,13 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
message: this.i18nService.t("failedToDeleteIntegration"),
});
}
return;
}
// Handle edit/save action
if (result.success === IntegrationDialogResultStatus.Edited) {
try {
if (result.success === HecConnectDialogResultStatus.Edited) {
await this.saveHec(result as HecConnectDialogResult);
}
await saveCallback(result);
} catch {
this.toastService.showToast({
variant: "error",
@@ -249,8 +344,6 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
}
async saveHec(result: HecConnectDialogResult) {
let response = { mustBeOwner: false, success: false };
const config = OrgIntegrationBuilder.buildHecConfiguration(
result.url,
result.bearerToken,
@@ -261,148 +354,45 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
this.integrationSettings.name as OrganizationIntegrationServiceName,
);
if (this.isUpdateAvailable) {
// retrieve org integration and configuration ids
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
const orgIntegrationConfigurationId =
this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id;
if (!orgIntegrationId || !orgIntegrationConfigurationId) {
throw Error("Organization Integration ID or Configuration ID is missing");
}
// update existing integration and configuration
response = await this.organizationIntegrationService.update(
this.organizationId,
orgIntegrationId,
OrganizationIntegrationType.Hec,
orgIntegrationConfigurationId,
config,
template,
);
} else {
// create new integration and configuration
response = await this.organizationIntegrationService.save(
this.organizationId,
OrganizationIntegrationType.Hec,
config,
template,
);
}
if (response.mustBeOwner) {
this.showMustBeOwnerToast();
return;
}
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("success"),
});
await this.saveIntegration(OrganizationIntegrationType.Hec, config, template);
}
async deleteHec() {
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
const orgIntegrationConfigurationId =
this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id;
await this.deleteIntegration();
}
if (!orgIntegrationId || !orgIntegrationConfigurationId) {
throw Error("Organization Integration ID or Configuration ID is missing");
}
const response = await this.organizationIntegrationService.delete(
this.organizationId,
orgIntegrationId,
orgIntegrationConfigurationId,
async saveHuntress(result: HuntressConnectDialogResult) {
// Huntress uses "Splunk" scheme for HEC protocol compatibility
const config = OrgIntegrationBuilder.buildHecConfiguration(
result.url,
result.token,
OrganizationIntegrationServiceName.Huntress,
Schemas.Splunk,
);
// Huntress SIEM doesn't require the index field
const template = OrgIntegrationBuilder.buildHecTemplate(
"",
OrganizationIntegrationServiceName.Huntress,
);
if (response.mustBeOwner) {
this.showMustBeOwnerToast();
return;
}
await this.saveIntegration(OrganizationIntegrationType.Hec, config, template);
}
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("success"),
});
async deleteHuntress() {
await this.deleteIntegration();
}
async saveDatadog(result: DatadogConnectDialogResult) {
let response = { mustBeOwner: false, success: false };
const config = OrgIntegrationBuilder.buildDataDogConfiguration(result.url, result.apiKey);
const template = OrgIntegrationBuilder.buildDataDogTemplate(
this.integrationSettings.name as OrganizationIntegrationServiceName,
);
if (this.isUpdateAvailable) {
// retrieve org integration and configuration ids
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
const orgIntegrationConfigurationId =
this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id;
if (!orgIntegrationId || !orgIntegrationConfigurationId) {
throw Error("Organization Integration ID or Configuration ID is missing");
}
// update existing integration and configuration
response = await this.organizationIntegrationService.update(
this.organizationId,
orgIntegrationId,
OrganizationIntegrationType.Datadog,
orgIntegrationConfigurationId,
config,
template,
);
} else {
// create new integration and configuration
response = await this.organizationIntegrationService.save(
this.organizationId,
OrganizationIntegrationType.Datadog,
config,
template,
);
}
if (response.mustBeOwner) {
this.showMustBeOwnerToast();
return;
}
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("success"),
});
await this.saveIntegration(OrganizationIntegrationType.Datadog, config, template);
}
async deleteDatadog() {
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
const orgIntegrationConfigurationId =
this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id;
if (!orgIntegrationId || !orgIntegrationConfigurationId) {
throw Error("Organization Integration ID or Configuration ID is missing");
}
const response = await this.organizationIntegrationService.delete(
this.organizationId,
orgIntegrationId,
orgIntegrationConfigurationId,
);
if (response.mustBeOwner) {
this.showMustBeOwnerToast();
return;
}
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("success"),
});
await this.deleteIntegration();
}
private showMustBeOwnerToast() {

View File

@@ -23,7 +23,7 @@
<bit-form-field>
<bit-label>{{ "apiKey" | i18n }}</bit-label>
<input bitInput type="text" formControlName="apiKey" />
<input bitInput type="password" formControlName="apiKey" />
<bit-hint>{{ "apiKey" | i18n }}</bit-hint>
</bit-form-field>
</ng-container>

View File

@@ -10,11 +10,12 @@ import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/
import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { IntegrationDialogResultStatus } from "../integration-dialog-result-status";
import {
ConnectDatadogDialogComponent,
DatadogConnectDialogParams,
DatadogConnectDialogResult,
DatadogConnectDialogResultStatus,
openDatadogConnectDialog,
} from "./connect-dialog-datadog.component";
@@ -149,7 +150,7 @@ describe("ConnectDialogDatadogComponent", () => {
url: "https://test.com",
apiKey: "token",
service: "Test Service",
success: DatadogConnectDialogResultStatus.Edited,
success: IntegrationDialogResultStatus.Edited,
});
});
});

View File

@@ -7,6 +7,11 @@ import { HecTemplate } from "@bitwarden/bit-common/dirt/organization-integration
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import {
IntegrationDialogResultStatus,
IntegrationDialogResultStatusType,
} from "../integration-dialog-result-status";
export type DatadogConnectDialogParams = {
settings: Integration;
};
@@ -16,17 +21,9 @@ export interface DatadogConnectDialogResult {
url: string;
apiKey: string;
service: string;
success: DatadogConnectDialogResultStatusType | null;
success: IntegrationDialogResultStatusType | null;
}
export const DatadogConnectDialogResultStatus = {
Edited: "edit",
Delete: "delete",
} as const;
export type DatadogConnectDialogResultStatusType =
(typeof DatadogConnectDialogResultStatus)[keyof typeof DatadogConnectDialogResultStatus];
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -78,7 +75,7 @@ export class ConnectDatadogDialogComponent implements OnInit {
this.formGroup.markAllAsTouched();
return;
}
const result = this.getDatadogConnectDialogResult(DatadogConnectDialogResultStatus.Edited);
const result = this.getDatadogConnectDialogResult(IntegrationDialogResultStatus.Edited);
this.dialogRef.close(result);
@@ -95,13 +92,13 @@ export class ConnectDatadogDialogComponent implements OnInit {
});
if (confirmed) {
const result = this.getDatadogConnectDialogResult(DatadogConnectDialogResultStatus.Delete);
const result = this.getDatadogConnectDialogResult(IntegrationDialogResultStatus.Delete);
this.dialogRef.close(result);
}
};
private getDatadogConnectDialogResult(
status: DatadogConnectDialogResultStatusType,
status: IntegrationDialogResultStatusType,
): DatadogConnectDialogResult {
const formJson = this.formGroup.getRawValue();

View File

@@ -23,7 +23,7 @@
<bit-form-field>
<bit-label>{{ "bearerToken" | i18n }}</bit-label>
<input bitInput type="text" formControlName="bearerToken" />
<input bitInput type="password" formControlName="bearerToken" />
<bit-hint>{{ "apiKey" | i18n }}</bit-hint>
</bit-form-field>

View File

@@ -10,11 +10,12 @@ import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/
import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { IntegrationDialogResultStatus } from "../integration-dialog-result-status";
import {
ConnectHecDialogComponent,
HecConnectDialogParams,
HecConnectDialogResult,
HecConnectDialogResultStatus,
openHecConnectDialog,
} from "./connect-dialog-hec.component";
@@ -155,7 +156,7 @@ describe("ConnectDialogHecComponent", () => {
bearerToken: "token",
index: "1",
service: "Test Service",
success: HecConnectDialogResultStatus.Edited,
success: IntegrationDialogResultStatus.Edited,
});
});
});

View File

@@ -7,6 +7,11 @@ import { HecTemplate } from "@bitwarden/bit-common/dirt/organization-integration
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import {
IntegrationDialogResultStatus,
IntegrationDialogResultStatusType,
} from "../integration-dialog-result-status";
export type HecConnectDialogParams = {
settings: Integration;
};
@@ -17,17 +22,9 @@ export interface HecConnectDialogResult {
bearerToken: string;
index: string;
service: string;
success: HecConnectDialogResultStatusType | null;
success: IntegrationDialogResultStatusType | null;
}
export const HecConnectDialogResultStatus = {
Edited: "edit",
Delete: "delete",
} as const;
export type HecConnectDialogResultStatusType =
(typeof HecConnectDialogResultStatus)[keyof typeof HecConnectDialogResultStatus];
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -81,7 +78,7 @@ export class ConnectHecDialogComponent implements OnInit {
this.formGroup.markAllAsTouched();
return;
}
const result = this.getHecConnectDialogResult(HecConnectDialogResultStatus.Edited);
const result = this.getHecConnectDialogResult(IntegrationDialogResultStatus.Edited);
this.dialogRef.close(result);
@@ -98,13 +95,13 @@ export class ConnectHecDialogComponent implements OnInit {
});
if (confirmed) {
const result = this.getHecConnectDialogResult(HecConnectDialogResultStatus.Delete);
const result = this.getHecConnectDialogResult(IntegrationDialogResultStatus.Delete);
this.dialogRef.close(result);
}
};
private getHecConnectDialogResult(
status: HecConnectDialogResultStatusType,
status: IntegrationDialogResultStatusType,
): HecConnectDialogResult {
const formJson = this.formGroup.getRawValue();

View File

@@ -0,0 +1,57 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large" [loading]="loading">
<span bitDialogTitle>
{{ "connectIntegrationButtonDesc" | i18n: connectInfo.settings.name }}
</span>
<div bitDialogContent class="tw-flex tw-flex-col tw-gap-4">
@if (loading) {
<ng-container #spinner>
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
</ng-container>
}
@if (!loading) {
<ng-container>
<bit-form-field>
<bit-label>{{ "httpEventCollectorUrl" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="url"
placeholder="https://hec.huntress.io/services/collector"
/>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "httpEventCollectorToken" | i18n }}</bit-label>
<input bitInput type="password" formControlName="token" />
</bit-form-field>
</ng-container>
}
</div>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary" [disabled]="loading">
@if (isUpdateAvailable) {
{{ "update" | i18n }}
} @else {
{{ "save" | i18n }}
}
</button>
<button type="button" bitButton bitDialogClose buttonType="secondary" [disabled]="loading">
{{ "cancel" | i18n }}
</button>
@if (canDelete) {
<div class="tw-ml-auto">
<button
bitIconButton="bwi-trash"
type="button"
buttonType="danger"
label="{{ 'delete' | i18n }}"
appA11yTitle="{{ 'delete' | i18n }}"
[bitAction]="delete"
></button>
</div>
}
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,206 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { IntegrationType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { IntegrationDialogResultStatus } from "../integration-dialog-result-status";
import {
ConnectHuntressDialogComponent,
HuntressConnectDialogParams,
HuntressConnectDialogResult,
openHuntressConnectDialog,
} from "./connect-dialog-huntress.component";
beforeAll(() => {
// Mock element.animate for jsdom
// the animate function is not available in jsdom, so we provide a mock implementation
// This is necessary for tests that rely on animations
// This mock does not perform any actual animations, it just provides a structure that allows tests
// to run without throwing errors related to missing animate function
if (!HTMLElement.prototype.animate) {
HTMLElement.prototype.animate = function () {
return {
play: () => {},
pause: () => {},
finish: () => {},
cancel: () => {},
reverse: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
onfinish: null,
oncancel: null,
startTime: 0,
currentTime: 0,
playbackRate: 1,
playState: "idle",
replaceState: "active",
effect: null,
finished: Promise.resolve(),
id: "",
remove: () => {},
timeline: null,
ready: Promise.resolve(),
} as unknown as Animation;
};
}
});
describe("ConnectHuntressDialogComponent", () => {
let component: ConnectHuntressDialogComponent;
let fixture: ComponentFixture<ConnectHuntressDialogComponent>;
let dialogRefMock = mock<DialogRef<HuntressConnectDialogResult>>();
const mockI18nService = mock<I18nService>();
const integrationMock: Integration = {
name: "Huntress",
image: "test-image.png",
linkURL: "https://example.com",
imageDarkMode: "test-image-dark.png",
newBadgeExpiration: "2024-12-31",
description: "Test Description",
canSetupConnection: true,
type: IntegrationType.EVENT,
} as Integration;
const connectInfo: HuntressConnectDialogParams = {
settings: integrationMock,
};
beforeEach(async () => {
dialogRefMock = mock<DialogRef<HuntressConnectDialogResult>>();
await TestBed.configureTestingModule({
imports: [ReactiveFormsModule, SharedModule, BrowserAnimationsModule],
providers: [
FormBuilder,
{ provide: DIALOG_DATA, useValue: connectInfo },
{ provide: DialogRef, useValue: dialogRefMock },
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
{ provide: I18nService, useValue: mockI18nService },
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ConnectHuntressDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
mockI18nService.t.mockImplementation((key) => key);
});
it("should create the component", () => {
expect(component).toBeTruthy();
});
it("should initialize form with empty values and service name", () => {
expect(component.formGroup.value).toEqual({
url: "",
token: "",
service: "Huntress",
});
});
it("should have required validators for url and token fields", () => {
component.formGroup.setValue({ url: "", token: "", service: "" });
expect(component.formGroup.valid).toBeFalsy();
component.formGroup.setValue({
url: "https://hec.huntress.io/services/collector",
token: "test-token",
service: "Huntress",
});
expect(component.formGroup.valid).toBeTruthy();
});
it("should require url to be at least 7 characters long", () => {
component.formGroup.setValue({
url: "test",
token: "token",
service: "Huntress",
});
expect(component.formGroup.valid).toBeFalsy();
component.formGroup.setValue({
url: "https://hec.huntress.io",
token: "token",
service: "Huntress",
});
expect(component.formGroup.valid).toBeTruthy();
});
it("should call dialogRef.close with correct result on submit", async () => {
component.formGroup.setValue({
url: "https://hec.huntress.io/services/collector",
token: "test-token",
service: "Huntress",
});
await component.submit();
expect(dialogRefMock.close).toHaveBeenCalledWith({
integrationSettings: integrationMock,
url: "https://hec.huntress.io/services/collector",
token: "test-token",
service: "Huntress",
success: IntegrationDialogResultStatus.Edited,
});
});
it("should not submit when form is invalid", async () => {
component.formGroup.setValue({
url: "",
token: "",
service: "Huntress",
});
await component.submit();
expect(dialogRefMock.close).not.toHaveBeenCalled();
expect(component.formGroup.touched).toBeTruthy();
});
it("should return false for isUpdateAvailable when no config exists", () => {
component.huntressConfig = null;
expect(component.isUpdateAvailable).toBeFalsy();
});
it("should return true for isUpdateAvailable when config exists", () => {
component.huntressConfig = { uri: "test", token: "test" } as any;
expect(component.isUpdateAvailable).toBeTruthy();
});
it("should return false for canDelete when no config exists", () => {
component.huntressConfig = null;
expect(component.canDelete).toBeFalsy();
});
it("should return true for canDelete when config exists", () => {
component.huntressConfig = { uri: "test", token: "test" } as any;
expect(component.canDelete).toBeTruthy();
});
});
describe("openHuntressConnectDialog", () => {
it("should call dialogService.open with correct params", () => {
const dialogServiceMock = mock<DialogService>();
const config: DialogConfig<
HuntressConnectDialogParams,
DialogRef<HuntressConnectDialogResult>
> = {
data: { settings: { name: "Huntress" } as Integration },
} as any;
openHuntressConnectDialog(dialogServiceMock, config);
expect(dialogServiceMock.open).toHaveBeenCalledWith(ConnectHuntressDialogComponent, config);
});
});

View File

@@ -0,0 +1,114 @@
import { ChangeDetectionStrategy, Component, Inject, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { HecConfiguration } from "@bitwarden/bit-common/dirt/organization-integrations/models/configuration/hec-configuration";
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import {
IntegrationDialogResultStatus,
IntegrationDialogResultStatusType,
} from "../integration-dialog-result-status";
export type HuntressConnectDialogParams = {
settings: Integration;
};
export interface HuntressConnectDialogResult {
integrationSettings: Integration;
url: string;
token: string;
service: string;
success: IntegrationDialogResultStatusType | null;
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./connect-dialog-huntress.component.html",
imports: [SharedModule],
})
export class ConnectHuntressDialogComponent implements OnInit {
loading = false;
huntressConfig: HecConfiguration | null = null;
formGroup = this.formBuilder.group({
url: ["", [Validators.required, Validators.minLength(7)]],
token: ["", Validators.required],
service: [""], // Programmatically set in ngOnInit, not shown to user
});
constructor(
@Inject(DIALOG_DATA) protected connectInfo: HuntressConnectDialogParams,
protected formBuilder: FormBuilder,
private dialogRef: DialogRef<HuntressConnectDialogResult>,
private dialogService: DialogService,
) {}
ngOnInit(): void {
this.huntressConfig =
this.connectInfo.settings.organizationIntegration?.getConfiguration<HecConfiguration>() ??
null;
this.formGroup.patchValue({
url: this.huntressConfig?.uri || "",
token: this.huntressConfig?.token || "",
service: this.connectInfo.settings.name,
});
}
get isUpdateAvailable(): boolean {
return !!this.huntressConfig;
}
get canDelete(): boolean {
return !!this.huntressConfig;
}
submit = async (): Promise<void> => {
if (this.formGroup.invalid) {
this.formGroup.markAllAsTouched();
return;
}
const result = this.getHuntressConnectDialogResult(IntegrationDialogResultStatus.Edited);
this.dialogRef.close(result);
return;
};
delete = async (): Promise<void> => {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteItem" },
content: {
key: "deleteItemConfirmation",
},
type: "warning",
});
if (confirmed) {
const result = this.getHuntressConnectDialogResult(IntegrationDialogResultStatus.Delete);
this.dialogRef.close(result);
}
};
private getHuntressConnectDialogResult(
status: IntegrationDialogResultStatusType,
): HuntressConnectDialogResult {
const formJson = this.formGroup.getRawValue();
return {
integrationSettings: this.connectInfo.settings,
url: formJson.url || "",
token: formJson.token || "",
service: formJson.service || "",
success: status,
};
}
}
export function openHuntressConnectDialog(
dialogService: DialogService,
config: DialogConfig<HuntressConnectDialogParams, DialogRef<HuntressConnectDialogResult>>,
) {
return dialogService.open<HuntressConnectDialogResult>(ConnectHuntressDialogComponent, config);
}

View File

@@ -1,2 +1,4 @@
export * from "./connect-dialog/connect-dialog-hec.component";
export * from "./connect-dialog/connect-dialog-datadog.component";
export * from "./connect-dialog/connect-dialog-huntress.component";
export * from "./integration-dialog-result-status";

View File

@@ -0,0 +1,11 @@
/**
* Shared status types for integration dialog results
* Used across all SIEM integration dialogs (HEC, Datadog, Huntress, etc.)
*/
export const IntegrationDialogResultStatus = {
Edited: "edit",
Delete: "delete",
} as const;
export type IntegrationDialogResultStatusType =
(typeof IntegrationDialogResultStatus)[keyof typeof IntegrationDialogResultStatus];

View File

@@ -32,6 +32,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
tabIndex: number = 0;
organization$: Observable<Organization> = new Observable<Organization>();
isEventManagementForDataDogAndCrowdStrikeEnabled: boolean = false;
isEventManagementForHuntressEnabled: boolean = false;
private destroy$ = new Subject<void>();
// initialize the integrations list with default integrations
@@ -258,6 +259,13 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
this.isEventManagementForDataDogAndCrowdStrikeEnabled = isEnabled;
});
this.configService
.getFeatureFlag$(FeatureFlag.EventManagementForHuntress)
.pipe(takeUntil(this.destroy$))
.subscribe((isEnabled) => {
this.isEventManagementForHuntressEnabled = isEnabled;
});
// Add the new event based items to the list
if (this.isEventManagementForDataDogAndCrowdStrikeEnabled) {
const crowdstrikeIntegration: Integration = {
@@ -285,6 +293,21 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
this.integrationsList.push(datadogIntegration);
}
// Add Huntress SIEM integration (separate feature flag)
if (this.isEventManagementForHuntressEnabled) {
const huntressIntegration: Integration = {
name: OrganizationIntegrationServiceName.Huntress,
linkURL: "https://bitwarden.com/help/huntress-siem/",
image: "../../../../../../../images/integrations/logo-huntress-siem.svg",
type: IntegrationType.EVENT,
description: "huntressEventIntegrationDesc",
canSetupConnection: true,
integrationType: OrganizationIntegrationType.Hec,
};
this.integrationsList.push(huntressIntegration);
}
// For all existing event based configurations loop through and assign the
// organizationIntegration for the correct services.
this.organizationIntegrationService.integrations$

View File

@@ -40,7 +40,6 @@ export enum FeatureFlag {
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation",
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption",
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
DataRecoveryTool = "pm-28813-data-recovery-tool",
@@ -57,6 +56,7 @@ export enum FeatureFlag {
/* DIRT */
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
EventManagementForHuntress = "event-management-for-huntress",
PhishingDetection = "phishing-detection",
/* Vault */
@@ -120,6 +120,7 @@ export const DefaultFeatureFlagValue = {
/* DIRT */
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
[FeatureFlag.EventManagementForHuntress]: FALSE,
[FeatureFlag.PhishingDetection]: FALSE,
/* Vault */
@@ -150,7 +151,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
[FeatureFlag.EnrollAeadOnKeyRotation]: FALSE,
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
[FeatureFlag.PM25174_DisableType0Decryption]: FALSE,
[FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
[FeatureFlag.DataRecoveryTool]: FALSE,

View File

@@ -1,16 +1,8 @@
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { EncString } from "../models/enc-string";
export abstract class EncryptService {
/**
* A temporary init method to make the encrypt service listen to feature-flag changes.
* This will be removed once the feature flag has been rolled out.
*/
abstract init(configService: ConfigService): void;
/**
* Encrypts a string to an EncString
* @param plainValue - The value to encrypt

View File

@@ -1,9 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
@@ -15,28 +13,12 @@ import { PureCrypto } from "@bitwarden/sdk-internal";
import { EncryptService } from "../abstractions/encrypt.service";
export class EncryptServiceImplementation implements EncryptService {
private disableType0Decryption = false;
constructor(
protected cryptoFunctionService: CryptoFunctionService,
protected logService: LogService,
protected logMacFailures: boolean,
) {}
init(configService: ConfigService): void {
configService.serverConfig$.subscribe((newConfig) => {
if (newConfig != null) {
this.setDisableType0Decryption(
newConfig.featureStates[FeatureFlag.PM25174_DisableType0Decryption] === true,
);
}
});
}
setDisableType0Decryption(disable: boolean): void {
this.disableType0Decryption = disable;
}
async encryptString(plainValue: string, key: SymmetricCryptoKey): Promise<EncString> {
if (plainValue == null) {
this.logService.warning(
@@ -60,7 +42,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
async decryptString(encString: EncString, key: SymmetricCryptoKey): Promise<string> {
if (this.disableType0Decryption && encString.encryptionType === EncryptionType.AesCbc256_B64) {
if (encString.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
await SdkLoadService.Ready;
@@ -68,7 +50,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
async decryptBytes(encString: EncString, key: SymmetricCryptoKey): Promise<Uint8Array> {
if (this.disableType0Decryption && encString.encryptionType === EncryptionType.AesCbc256_B64) {
if (encString.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
await SdkLoadService.Ready;
@@ -76,7 +58,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
async decryptFileData(encBuffer: EncArrayBuffer, key: SymmetricCryptoKey): Promise<Uint8Array> {
if (this.disableType0Decryption && encBuffer.encryptionType === EncryptionType.AesCbc256_B64) {
if (encBuffer.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
await SdkLoadService.Ready;
@@ -148,10 +130,7 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("No wrappingKey provided for unwrapping.");
}
if (
this.disableType0Decryption &&
wrappedDecapsulationKey.encryptionType === EncryptionType.AesCbc256_B64
) {
if (wrappedDecapsulationKey.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
@@ -171,10 +150,7 @@ export class EncryptServiceImplementation implements EncryptService {
if (wrappingKey == null) {
throw new Error("No wrappingKey provided for unwrapping.");
}
if (
this.disableType0Decryption &&
wrappedEncapsulationKey.encryptionType === EncryptionType.AesCbc256_B64
) {
if (wrappedEncapsulationKey.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
@@ -194,10 +170,7 @@ export class EncryptServiceImplementation implements EncryptService {
if (wrappingKey == null) {
throw new Error("No wrappingKey provided for unwrapping.");
}
if (
this.disableType0Decryption &&
keyToBeUnwrapped.encryptionType === EncryptionType.AesCbc256_B64
) {
if (keyToBeUnwrapped.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}

View File

@@ -163,7 +163,7 @@ describe("EncryptService", () => {
describe("decryptString", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("encrypted_string");
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "encrypted_string");
const result = await encryptService.decryptString(encString, key);
expect(result).toEqual("decrypted_string");
expect(PureCrypto.symmetric_decrypt_string).toHaveBeenCalledWith(
@@ -172,8 +172,7 @@ describe("EncryptService", () => {
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "encrypted_string");
await expect(encryptService.decryptString(encString, key)).rejects.toThrow(
@@ -185,7 +184,7 @@ describe("EncryptService", () => {
describe("decryptBytes", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("encrypted_bytes");
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "encrypted_bytes");
const result = await encryptService.decryptBytes(encString, key);
expect(result).toEqual(new Uint8Array(3));
expect(PureCrypto.symmetric_decrypt_bytes).toHaveBeenCalledWith(
@@ -194,8 +193,7 @@ describe("EncryptService", () => {
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "encrypted_bytes");
await expect(encryptService.decryptBytes(encString, key)).rejects.toThrow(
@@ -216,8 +214,7 @@ describe("EncryptService", () => {
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encBuffer = EncArrayBuffer.fromParts(
EncryptionType.AesCbc256_B64,
@@ -234,7 +231,10 @@ describe("EncryptService", () => {
describe("unwrapDecapsulationKey", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("wrapped_decapsulation_key");
const encString = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"wrapped_decapsulation_key",
);
const result = await encryptService.unwrapDecapsulationKey(encString, key);
expect(result).toEqual(new Uint8Array(4));
expect(PureCrypto.unwrap_decapsulation_key).toHaveBeenCalledWith(
@@ -242,8 +242,7 @@ describe("EncryptService", () => {
key.toEncoded(),
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_decapsulation_key");
await expect(encryptService.unwrapDecapsulationKey(encString, key)).rejects.toThrow(
@@ -267,7 +266,10 @@ describe("EncryptService", () => {
describe("unwrapEncapsulationKey", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("wrapped_encapsulation_key");
const encString = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"wrapped_encapsulation_key",
);
const result = await encryptService.unwrapEncapsulationKey(encString, key);
expect(result).toEqual(new Uint8Array(5));
expect(PureCrypto.unwrap_encapsulation_key).toHaveBeenCalledWith(
@@ -275,8 +277,7 @@ describe("EncryptService", () => {
key.toEncoded(),
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_encapsulation_key");
await expect(encryptService.unwrapEncapsulationKey(encString, key)).rejects.toThrow(
@@ -300,7 +301,10 @@ describe("EncryptService", () => {
describe("unwrapSymmetricKey", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("wrapped_symmetric_key");
const encString = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"wrapped_symmetric_key",
);
const result = await encryptService.unwrapSymmetricKey(encString, key);
expect(result).toEqual(new SymmetricCryptoKey(new Uint8Array(64)));
expect(PureCrypto.unwrap_symmetric_key).toHaveBeenCalledWith(
@@ -308,8 +312,7 @@ describe("EncryptService", () => {
key.toEncoded(),
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_symmetric_key");
await expect(encryptService.unwrapSymmetricKey(encString, key)).rejects.toThrow(

View File

@@ -260,6 +260,13 @@ describe("VaultTimeoutSettingsService", () => {
});
describe("getVaultTimeoutByUserId$", () => {
beforeEach(() => {
// Return the input value unchanged
sessionTimeoutTypeService.getOrPromoteToAvailable.mockImplementation(
async (timeout) => timeout,
);
});
it("should throw an error if no user id is provided", async () => {
expect(() => vaultTimeoutSettingsService.getVaultTimeoutByUserId$(null)).toThrow(
"User id required. Cannot get vault timeout.",
@@ -277,6 +284,9 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
defaultVaultTimeout,
);
expect(result).toBe(defaultVaultTimeout);
});
@@ -299,8 +309,31 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
vaultTimeout,
);
expect(result).toBe(vaultTimeout);
});
it("promotes timeout when unavailable on client", async () => {
const determinedTimeout = VaultTimeoutNumberType.OnMinute;
const promotedValue = VaultTimeoutStringType.OnRestart;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(of([]));
await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
determinedTimeout,
);
expect(result).toBe(promotedValue);
});
});
describe("policy type: custom", () => {
@@ -327,6 +360,9 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
policyMinutes,
);
expect(result).toBe(policyMinutes);
},
);
@@ -345,6 +381,9 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
vaultTimeout,
);
expect(result).toBe(vaultTimeout);
},
);
@@ -365,8 +404,36 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutNumberType.Immediately,
);
expect(result).toBe(VaultTimeoutNumberType.Immediately);
});
it("promotes policy minutes when unavailable on client", async () => {
const promotedValue = VaultTimeoutStringType.Never;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]),
);
await stateProvider.setUserState(
VAULT_TIMEOUT,
VaultTimeoutNumberType.EightHours,
mockUserId,
);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
policyMinutes,
);
expect(result).toBe(promotedValue);
});
});
describe("policy type: immediately", () => {
@@ -383,7 +450,6 @@ describe("VaultTimeoutSettingsService", () => {
"when current timeout is %s, returns immediately or promoted value",
async (currentTimeout) => {
const expectedTimeout = VaultTimeoutNumberType.Immediately;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "immediately" } }] as unknown as Policy[]),
);
@@ -400,6 +466,26 @@ describe("VaultTimeoutSettingsService", () => {
expect(result).toBe(expectedTimeout);
},
);
it("promotes immediately when unavailable on client", async () => {
const promotedValue = VaultTimeoutNumberType.OnMinute;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "immediately" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutNumberType.Immediately,
);
expect(result).toBe(promotedValue);
});
});
describe("policy type: onSystemLock", () => {
@@ -413,7 +499,6 @@ describe("VaultTimeoutSettingsService", () => {
"when current timeout is %s, returns onLocked or promoted value",
async (currentTimeout) => {
const expectedTimeout = VaultTimeoutStringType.OnLocked;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
);
@@ -446,9 +531,31 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
currentTimeout,
);
expect(result).toBe(currentTimeout);
});
it("promotes onLocked when unavailable on client", async () => {
const promotedValue = VaultTimeoutStringType.OnRestart;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutStringType.OnLocked,
);
expect(result).toBe(promotedValue);
});
});
describe("policy type: onAppRestart", () => {
@@ -468,7 +575,9 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutStringType.OnRestart,
);
expect(result).toBe(VaultTimeoutStringType.OnRestart);
});
@@ -488,32 +597,40 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
currentTimeout,
);
expect(result).toBe(currentTimeout);
});
});
describe("policy type: never", () => {
it("when current timeout is never, returns never or promoted value", async () => {
const expectedTimeout = VaultTimeoutStringType.Never;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
it("promotes onRestart when unavailable on client", async () => {
const promotedValue = VaultTimeoutStringType.Never;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "never" } }] as unknown as Policy[]),
of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
await stateProvider.setUserState(
VAULT_TIMEOUT,
VaultTimeoutStringType.OnLocked,
mockUserId,
);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
);
expect(result).toBe(expectedTimeout);
expect(result).toBe(promotedValue);
});
});
describe("policy type: never", () => {
it.each([
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnIdle,
@@ -532,9 +649,32 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
currentTimeout,
);
expect(result).toBe(currentTimeout);
});
it("promotes timeout when unavailable on client", async () => {
const determinedTimeout = VaultTimeoutStringType.Never;
const promotedValue = VaultTimeoutStringType.OnRestart;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "never" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
determinedTimeout,
);
expect(result).toBe(promotedValue);
});
});
});

View File

@@ -179,7 +179,20 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
private async determineVaultTimeout(
currentVaultTimeout: VaultTimeout | null,
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
): Promise<VaultTimeout | null> {
): Promise<VaultTimeout> {
const determinedTimeout = await this.determineVaultTimeoutInternal(
currentVaultTimeout,
maxSessionTimeoutPolicyData,
);
// Ensures the timeout is available on this client
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(determinedTimeout);
}
private async determineVaultTimeoutInternal(
currentVaultTimeout: VaultTimeout | null,
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
): Promise<VaultTimeout> {
// if current vault timeout is null, apply the client specific default
currentVaultTimeout = currentVaultTimeout ?? this.defaultVaultTimeout;
@@ -190,9 +203,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
switch (maxSessionTimeoutPolicyData.type) {
case "immediately":
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutNumberType.Immediately,
);
return VaultTimeoutNumberType.Immediately;
case "custom":
case null:
case undefined:
@@ -211,9 +222,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
currentVaultTimeout === VaultTimeoutStringType.OnIdle ||
currentVaultTimeout === VaultTimeoutStringType.OnSleep
) {
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.OnLocked,
);
return VaultTimeoutStringType.OnLocked;
}
break;
case "onAppRestart":
@@ -227,11 +236,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
}
break;
case "never":
if (currentVaultTimeout === VaultTimeoutStringType.Never) {
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.Never,
);
}
// Policy doesn't override user preference for "never"
break;
}
return currentVaultTimeout;

View File

@@ -7,10 +7,10 @@ import { Icon } from "@bitwarden/assets/svg";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Translation } from "../dialog";
import { LandingContentMaxWidthType } from "../landing-layout";
import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service";
import { AnonLayoutComponent, AnonLayoutMaxWidth } from "./anon-layout.component";
import { AnonLayoutComponent } from "./anon-layout.component";
export interface AnonLayoutWrapperData {
/**
* The optional title of the page.
@@ -35,7 +35,7 @@ export interface AnonLayoutWrapperData {
/**
* Optional flag to set the max-width of the page. Defaults to 'md' if not provided.
*/
maxWidth?: AnonLayoutMaxWidth;
maxWidth?: LandingContentMaxWidthType;
/**
* Hide the card that wraps the default content. Defaults to false.
*/
@@ -59,7 +59,7 @@ export class AnonLayoutWrapperComponent implements OnInit {
protected pageSubtitle?: string | null;
protected pageIcon: Icon | null = null;
protected showReadonlyHostname?: boolean | null;
protected maxWidth?: AnonLayoutMaxWidth | null;
protected maxWidth?: LandingContentMaxWidthType | null;
protected hideCardWrapper?: boolean | null;
protected hideBackgroundIllustration?: boolean | null;

View File

@@ -1,76 +1,26 @@
<main
class="tw-relative tw-flex tw-w-full tw-mx-auto tw-flex-col tw-bg-background-alt tw-p-5 tw-text-main"
[ngClass]="{
'tw-min-h-screen': clientType === 'web',
'tw-min-h-full': clientType === 'browser' || clientType === 'desktop',
}"
>
<div
[class]="
'tw-flex tw-justify-between tw-items-center tw-w-full' + (!hideLogo() ? ' tw-mb-12' : '')
"
>
@if (!hideLogo()) {
<a
[routerLink]="['/']"
class="tw-w-32 sm:tw-w-[200px] tw-self-center sm:tw-self-start tw-block [&>*]:tw-align-top"
>
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
</a>
}
<div class="tw-ms-auto">
<ng-content select="[slot=header-actions]"></ng-content>
</div>
</div>
<bit-landing-layout [hideBackgroundIllustration]="hideBackgroundIllustration()">
<bit-landing-header [hideLogo]="hideLogo()">
<ng-content select="[slot=header-actions]"></ng-content>
</bit-landing-header>
<div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto" [ngClass]="maxWidthClass">
@let iconInput = icon();
<!-- In some scenarios this icon's size is not limited by container width correctly -->
<!-- Targeting the SVG here to try and ensure it never grows too large in even the media queries are not working as expected -->
<div
*ngIf="iconInput !== null"
class="tw-size-20 sm:tw-size-24 [&_svg]:tw-w-full [&_svg]:tw-max-w-24 tw-mx-auto tw-content-center"
>
<bit-icon [icon]="iconInput"></bit-icon>
</div>
@if (title()) {
<!-- Small screens -->
<h1 bitTypography="h2" class="tw-mt-2 sm:tw-hidden">
{{ title() }}
</h1>
<!-- Medium to Larger screens -->
<h1 bitTypography="h1" class="tw-mt-2 tw-hidden sm:tw-block">
{{ title() }}
</h1>
}
@if (subtitle()) {
<div class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div>
}
</div>
<div
class="tw-z-10 tw-grow tw-w-full tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]"
[ngClass]="maxWidthClass"
>
<bit-landing-content [maxWidth]="maxWidth()">
<bit-landing-hero [icon]="icon()" [title]="title()" [subtitle]="subtitle()"></bit-landing-hero>
@if (hideCardWrapper()) {
<div class="tw-mb-6 sm:tw-mb-10">
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
</div>
} @else {
<bit-base-card
class="!tw-rounded-2xl tw-mb-6 sm:tw-mb-10 tw-mx-auto tw-w-full tw-bg-transparent tw-border-none tw-shadow-none sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-100 sm:tw-shadow sm:tw-p-8"
>
<bit-landing-card>
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
</bit-base-card>
</bit-landing-card>
}
<ng-content select="[slot=secondary]"></ng-content>
</div>
<div class="tw-flex tw-flex-col tw-items-center">
<ng-content select="[slot=secondary]"></ng-content>
</div>
</bit-landing-content>
@if (!hideFooter()) {
<footer class="tw-text-center tw-mt-4 sm:tw-mt-6">
<bit-landing-footer>
@if (showReadonlyHostname()) {
<div bitTypography="body2">{{ "accessing" | i18n }} {{ hostname }}</div>
} @else {
@@ -81,22 +31,9 @@
<div bitTypography="body2">&copy; {{ year }} Bitwarden Inc.</div>
<div bitTypography="body2">{{ version }}</div>
}
</footer>
</bit-landing-footer>
}
@if (!hideBackgroundIllustration()) {
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-start-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="leftIllustration"></bit-icon>
</div>
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-end-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="rightIllustration"></bit-icon>
</div>
}
</main>
</bit-landing-layout>
<ng-template #defaultContent>
<ng-content></ng-content>

View File

@@ -11,23 +11,17 @@ import {
import { RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
import {
BackgroundLeftIllustration,
BackgroundRightIllustration,
BitwardenLogo,
Icon,
} from "@bitwarden/assets/svg";
import { BitwardenLogo, Icon } from "@bitwarden/assets/svg";
import { ClientType } from "@bitwarden/common/enums";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BaseCardComponent } from "../card";
import { IconModule } from "../icon";
import { LandingContentMaxWidthType } from "../landing-layout";
import { LandingLayoutModule } from "../landing-layout/landing-layout.module";
import { SharedModule } from "../shared";
import { TypographyModule } from "../typography";
export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -39,7 +33,7 @@ export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
TypographyModule,
SharedModule,
RouterModule,
BaseCardComponent,
LandingLayoutModule,
],
})
export class AnonLayoutComponent implements OnInit, OnChanges {
@@ -49,9 +43,6 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
return ["tw-h-full"];
}
readonly leftIllustration = BackgroundLeftIllustration;
readonly rightIllustration = BackgroundRightIllustration;
readonly title = input<string>();
readonly subtitle = input<string>();
readonly icon = model.required<Icon | null>();
@@ -66,7 +57,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
*
* @default 'md'
*/
readonly maxWidth = model<AnonLayoutMaxWidth>("md");
readonly maxWidth = model<LandingContentMaxWidthType>("md");
protected logo = BitwardenLogo;
protected year: string;
@@ -76,24 +67,6 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
protected hideYearAndVersion = false;
get maxWidthClass(): string {
const maxWidth = this.maxWidth();
switch (maxWidth) {
case "md":
return "tw-max-w-md";
case "lg":
return "tw-max-w-lg";
case "xl":
return "tw-max-w-xl";
case "2xl":
return "tw-max-w-2xl";
case "3xl":
return "tw-max-w-3xl";
case "4xl":
return "tw-max-w-4xl";
}
}
constructor(
private environmentService: EnvironmentService,
private platformUtilsService: PlatformUtilsService,

View File

@@ -25,6 +25,7 @@ export * from "./icon";
export * from "./icon-tile";
export * from "./input";
export * from "./item";
export * from "./landing-layout";
export * from "./layout";
export * from "./link";
export * from "./menu";

View File

@@ -0,0 +1,7 @@
export * from "./landing-layout.component";
export * from "./landing-layout.module";
export * from "./landing-card.component";
export * from "./landing-content.component";
export * from "./landing-footer.component";
export * from "./landing-header.component";
export * from "./landing-hero.component";

View File

@@ -0,0 +1,5 @@
<bit-base-card
class="tw-z-[2] tw-relative !tw-rounded-2xl tw-mb-6 sm:tw-mb-10 tw-mx-auto tw-w-full tw-bg-transparent tw-border-none tw-shadow-none sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-100 sm:tw-shadow sm:tw-p-8"
>
<ng-content></ng-content>
</bit-base-card>

View File

@@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { BaseCardComponent } from "../card";
/**
* Card component for landing pages that wraps content in a styled container.
*
* @remarks
* This component provides:
* - Card-based layout with consistent styling
* - Content projection for forms, text, or other content
* - Proper elevation and border styling
*
* Use this component inside `bit-landing-content` to wrap forms, content sections,
* or any content that should appear in a contained, elevated card.
*
* @example
* ```html
* <bit-landing-card>
* <form>
* <!-- Your form fields here -->
* </form>
* </bit-landing-card>
* ```
*/
@Component({
selector: "bit-landing-card",
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [BaseCardComponent],
templateUrl: "./landing-card.component.html",
})
export class LandingCardComponent {}

View File

@@ -0,0 +1,8 @@
<div
class="tw-flex tw-flex-col tw-flex-1 tw-items-center tw-bg-background-alt tw-p-5 tw-pt-12 tw-text-main"
>
<div [class]="maxWidthClasses()">
<ng-content select="bit-landing-hero"></ng-content>
<ng-content></ng-content>
</div>
</div>

View File

@@ -0,0 +1,63 @@
import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
export const LandingContentMaxWidth = ["md", "lg", "xl", "2xl", "3xl", "4xl"] as const;
export type LandingContentMaxWidthType = (typeof LandingContentMaxWidth)[number];
/**
* Main content container for landing pages with configurable max-width constraints.
*
* @remarks
* This component provides:
* - Centered content area with alternative background color
* - Configurable maximum width to control content readability
* - Content projection slots for hero section and main content
* - Responsive padding and layout
*
* Use this component inside `bit-landing-layout` to wrap your main page content.
* Optionally include a `bit-landing-hero` as the first child for consistent hero section styling.
*
* @example
* ```html
* <bit-landing-content [maxWidth]="'xl'">
* <bit-landing-hero
* [icon]="lockIcon"
* [title]="'Welcome'"
* [subtitle]="'Get started with your account'"
* ></bit-landing-hero>
* <bit-landing-card>
* <!-- Your form or content here -->
* </bit-landing-card>
* </bit-landing-content>
* ```
*/
@Component({
selector: "bit-landing-content",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./landing-content.component.html",
host: {
class: "tw-grow tw-flex tw-flex-col",
},
})
export class LandingContentComponent {
/**
* Max width of the landing layout container.
*
* @default "md"
*/
readonly maxWidth = input<LandingContentMaxWidthType>("md");
private readonly maxWidthClassMap: Record<LandingContentMaxWidthType, string> = {
md: "tw-max-w-md",
lg: "tw-max-w-lg",
xl: "tw-max-w-xl",
"2xl": "tw-max-w-2xl",
"3xl": "tw-max-w-3xl",
"4xl": "tw-max-w-4xl",
};
readonly maxWidthClasses = computed(() => {
const maxWidthClass = this.maxWidthClassMap[this.maxWidth()];
return `tw-flex tw-flex-col tw-w-full ${maxWidthClass}`;
});
}

View File

@@ -0,0 +1,3 @@
<footer class="tw-bg-background-alt tw-text-center tw-p-5 tw-pt-4 sm:tw-pt-6">
<ng-content></ng-content>
</footer>

View File

@@ -0,0 +1,29 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
/**
* Footer component for landing pages.
*
* @remarks
* This component provides:
* - Content projection for custom footer content (e.g., links, copyright, legal)
* - Consistent footer positioning at the bottom of the page
* - Proper z-index to appear above background illustrations
*
* Use this component inside `bit-landing-layout` as the last child to position it at the bottom.
*
* @example
* ```html
* <bit-landing-footer>
* <div class="tw-text-center tw-text-sm">
* <a routerLink="/privacy">Privacy</a>
* <span>© 2024 Bitwarden</span>
* </div>
* </bit-landing-footer>
* ```
*/
@Component({
selector: "bit-landing-footer",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./landing-footer.component.html",
})
export class LandingFooterComponent {}

View File

@@ -0,0 +1,13 @@
<header class="tw-flex tw-w-full tw-bg-background-alt tw-px-5">
@if (!hideLogo()) {
<a
[routerLink]="['/']"
class="tw-w-32 tw-py-5 sm:tw-w-[200px] tw-self-center sm:tw-self-start tw-block [&>*]:tw-align-top"
>
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
</a>
}
<div class="[&:has(*)]:tw-ms-auto [&:has(*)]:tw-py-5">
<ng-content></ng-content>
</div>
</header>

View File

@@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
import { RouterModule } from "@angular/router";
import { BitwardenLogo } from "@bitwarden/assets/svg";
import { IconModule } from "../icon";
import { SharedModule } from "../shared";
/**
* Header component for landing pages with optional Bitwarden logo and header actions slot.
*
* @remarks
* This component provides:
* - Optional Bitwarden logo with link to home page (left-aligned)
* - Default content projection slot for header actions (right-aligned, auto-margin left)
* - Consistent header styling across landing pages
* - Responsive layout that adapts logo size
*
* Use this component inside `bit-landing-layout` as the first child to position it at the top.
* Content projected into this component will automatically align to the right side of the header.
*
* @example
* ```html
* <bit-landing-header [hideLogo]="false">
* <!-- Content here appears in the right-aligned actions slot -->
* <nav>
* <a routerLink="/login">Log in</a>
* <button type="button">Sign up</button>
* </nav>
* </bit-landing-header>
* ```
*/
@Component({
selector: "bit-landing-header",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./landing-header.component.html",
imports: [RouterModule, IconModule, SharedModule],
})
export class LandingHeaderComponent {
readonly hideLogo = input<boolean>(false);
protected readonly logo = BitwardenLogo;
}

View File

@@ -0,0 +1,28 @@
@if (icon() || title() || subtitle()) {
<div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto">
@if (icon()) {
<!-- In some scenarios this icon's size is not limited by container width correctly -->
<!-- Targeting the SVG here to try and ensure it never grows too large in even the media queries are not working as expected -->
<div
class="tw-size-20 sm:tw-size-24 [&_svg]:tw-w-full [&_svg]:tw-max-w-24 tw-mx-auto tw-content-center"
>
<bit-icon [icon]="icon()"></bit-icon>
</div>
}
@if (title()) {
<!-- Small screens -->
<h1 bitTypography="h2" class="tw-mt-2 sm:tw-hidden">
{{ title() }}
</h1>
<!-- Medium to Larger screens -->
<h1 bitTypography="h1" class="tw-mt-2 tw-hidden sm:tw-block">
{{ title() }}
</h1>
}
@if (subtitle()) {
<div class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div>
}
</div>
}

View File

@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
import { Icon } from "@bitwarden/assets/svg";
import { IconModule } from "../icon";
import { TypographyModule } from "../typography";
/**
* Hero section component for landing pages featuring an optional icon, title, and subtitle.
*
* @remarks
* This component provides:
* - Optional icon display (e.g., feature icons, status icons)
* - Large title text with consistent typography
* - Subtitle text for additional context
* - Centered layout with proper spacing
*
* Use this component as the first child inside `bit-landing-content` to create a prominent
* hero section that introduces the page's purpose.
*
* @example
* ```html
* <bit-landing-hero
* [icon]="lockIcon"
* [title]="'Secure Your Passwords'"
* [subtitle]="'Create your account to get started'"
* ></bit-landing-hero>
* ```
*/
@Component({
selector: "bit-landing-hero",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./landing-hero.component.html",
imports: [IconModule, TypographyModule],
})
export class LandingHeroComponent {
readonly icon = input<Icon | null>(null);
readonly title = input<string | undefined>();
readonly subtitle = input<string | undefined>();
}

View File

@@ -0,0 +1,25 @@
<div
class="tw-relative tw-flex tw-size-full tw-mx-auto tw-flex-col"
[class]="{
'tw-min-h-screen': clientType === 'web',
'tw-min-h-full': clientType === 'browser' || clientType === 'desktop',
}"
>
<ng-content select="bit-landing-header"></ng-content>
<main class="tw-relative tw-flex tw-flex-1 tw-size-full tw-mx-auto tw-flex-col">
<ng-content></ng-content>
</main>
@if (!hideBackgroundIllustration()) {
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-start-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="leftIllustration"></bit-icon>
</div>
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-end-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="rightIllustration"></bit-icon>
</div>
}
<ng-content select="bit-landing-footer"></ng-content>
</div>

View File

@@ -0,0 +1,40 @@
import { Component, ChangeDetectionStrategy, inject, input } from "@angular/core";
import { BackgroundLeftIllustration, BackgroundRightIllustration } from "@bitwarden/assets/svg";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { IconModule } from "../icon";
/**
* Root layout component for landing pages providing a full-screen container with optional decorative background illustrations.
*
* @remarks
* This component serves as the outermost wrapper for landing pages and provides:
* - Full-screen layout that adapts to different client types (web, browser, desktop)
* - Optional decorative background illustrations in the bottom corners
* - Content projection slots for header, main content, and footer
*
* @example
* ```html
* <bit-landing-layout [hideBackgroundIllustration]="false">
* <bit-landing-header>...</bit-landing-header>
* <bit-landing-content>...</bit-landing-content>
* <bit-landing-footer>...</bit-landing-footer>
* </bit-landing-layout>
* ```
*/
@Component({
selector: "bit-landing-layout",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./landing-layout.component.html",
imports: [IconModule],
})
export class LandingLayoutComponent {
readonly hideBackgroundIllustration = input<boolean>(false);
protected readonly leftIllustration = BackgroundLeftIllustration;
protected readonly rightIllustration = BackgroundRightIllustration;
private readonly platformUtilsService: PlatformUtilsService = inject(PlatformUtilsService);
protected readonly clientType = this.platformUtilsService.getClientType();
}

View File

@@ -0,0 +1,28 @@
import { NgModule } from "@angular/core";
import { LandingCardComponent } from "./landing-card.component";
import { LandingContentComponent } from "./landing-content.component";
import { LandingFooterComponent } from "./landing-footer.component";
import { LandingHeaderComponent } from "./landing-header.component";
import { LandingHeroComponent } from "./landing-hero.component";
import { LandingLayoutComponent } from "./landing-layout.component";
@NgModule({
imports: [
LandingLayoutComponent,
LandingHeaderComponent,
LandingHeroComponent,
LandingFooterComponent,
LandingContentComponent,
LandingCardComponent,
],
exports: [
LandingLayoutComponent,
LandingHeaderComponent,
LandingHeroComponent,
LandingFooterComponent,
LandingContentComponent,
LandingCardComponent,
],
})
export class LandingLayoutModule {}

View File

@@ -0,0 +1,162 @@
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { ClientType } from "@bitwarden/common/enums";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ButtonModule } from "../button";
import { LandingLayoutComponent } from "./landing-layout.component";
class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
getClientType = () => ClientType.Web;
}
type StoryArgs = LandingLayoutComponent & {
contentLength: "normal" | "long" | "thin";
includeHeader: boolean;
includeFooter: boolean;
};
export default {
title: "Component Library/Landing Layout",
component: LandingLayoutComponent,
decorators: [
moduleMetadata({
imports: [ButtonModule],
providers: [
{
provide: PlatformUtilsService,
useClass: MockPlatformUtilsService,
},
],
}),
],
render: (args) => {
return {
props: args,
template: /*html*/ `
<bit-landing-layout
[hideBackgroundIllustration]="hideBackgroundIllustration"
>
@if (includeHeader) {
<bit-landing-header>
<div class="tw-p-4">
<div class="tw-flex tw-items-center tw-gap-4">
<div class="tw-text-xl tw-font-semibold">Header Content</div>
</div>
</div>
</bit-landing-header>
}
<div>
@switch (contentLength) {
@case ('thin') {
<div class="tw-text-center tw-p-8">
<div class="tw-font-medium">Thin Content</div>
</div>
}
@case ('long') {
<div class="tw-p-8">
<div class="tw-font-medium tw-mb-4">Long Content</div>
<div class="tw-mb-4">Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?</div>
<div class="tw-mb-4">Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?</div>
</div>
}
@default {
<div class="tw-p-8">
<div class="tw-font-medium tw-mb-4">Normal Content</div>
<div>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</div>
</div>
}
}
</div>
@if (includeFooter) {
<bit-landing-footer>
<div class="tw-text-center tw-text-sm tw-text-muted">
<div>Footer Content</div>
</div>
</bit-landing-footer>
}
</bit-landing-layout>
`,
};
},
argTypes: {
hideBackgroundIllustration: { control: "boolean" },
contentLength: {
control: "radio",
options: ["normal", "long", "thin"],
},
includeHeader: { control: "boolean" },
includeFooter: { control: "boolean" },
},
args: {
hideBackgroundIllustration: false,
contentLength: "normal",
includeHeader: false,
includeFooter: false,
},
} satisfies Meta<StoryArgs>;
type Story = StoryObj<StoryArgs>;
export const Default: Story = {
args: {
contentLength: "normal",
},
};
export const WithHeader: Story = {
args: {
includeHeader: true,
},
};
export const WithFooter: Story = {
args: {
includeFooter: true,
},
};
export const WithHeaderAndFooter: Story = {
args: {
includeHeader: true,
includeFooter: true,
},
};
export const LongContent: Story = {
args: {
contentLength: "long",
includeHeader: true,
includeFooter: true,
},
};
export const ThinContent: Story = {
args: {
contentLength: "thin",
includeHeader: true,
includeFooter: true,
},
};
export const NoBackgroundIllustration: Story = {
args: {
hideBackgroundIllustration: true,
includeHeader: true,
includeFooter: true,
},
};
export const MinimalState: Story = {
args: {
contentLength: "thin",
hideBackgroundIllustration: true,
includeHeader: false,
includeFooter: false,
},
};

View File

@@ -27,7 +27,7 @@
<!-- 53rem = ~850px -->
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
<div
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full"
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-bg-sidenav"
>
<bit-nav-divider></bit-nav-divider>
@if (data.open) {

View File

@@ -1,6 +1,7 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, bufferCount, firstValueFrom, lastValueFrom, of, take } from "rxjs";
import { ClientType } from "@bitwarden/client-type";
import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
@@ -259,7 +260,18 @@ describe("keyService", () => {
});
});
it("clears the Auto key if vault timeout is set to anything other than null", async () => {
it("sets an Auto key if vault timeout is set to 10 minutes and is Cli", async () => {
await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId);
platformUtilService.getClientType.mockReturnValue(ClientType.Cli);
await keyService.setUserKey(mockUserKey, mockUserId);
expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(mockUserKey.keyB64, {
userId: mockUserId,
});
});
it("clears the Auto key if vault timeout is set to 10 minutes", async () => {
await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId);
await keyService.setUserKey(mockUserKey, mockUserId);

View File

@@ -14,6 +14,7 @@ import {
switchMap,
} from "rxjs";
import { ClientType } from "@bitwarden/client-type";
import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data";
import { BaseEncryptedOrganizationKey } from "@bitwarden/common/admin-console/models/domain/encrypted-organization-key";
import { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-organization.response";
@@ -671,9 +672,13 @@ export class DefaultKeyService implements KeyServiceAbstraction {
}
protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId: UserId) {
let shouldStoreKey = false;
switch (keySuffix) {
case KeySuffixOptions.Auto: {
// Cli has fixed Never vault timeout, and it should not be affected by a policy.
if (this.platformUtilService.getClientType() == ClientType.Cli) {
return true;
}
// TODO: Sharing the UserKeyDefinition is temporary to get around a circ dep issue between
// the VaultTimeoutSettingsSvc and this service.
// This should be fixed as part of the PM-7082 - Auto Key Service work.
@@ -683,11 +688,14 @@ export class DefaultKeyService implements KeyServiceAbstraction {
.pipe(filter((timeout) => timeout != null)),
);
shouldStoreKey = vaultTimeout == VaultTimeoutStringType.Never;
break;
this.logService.debug(
`[KeyService] Should store auto key for vault timeout ${vaultTimeout}`,
);
return vaultTimeout == VaultTimeoutStringType.Never;
}
}
return shouldStoreKey;
return false;
}
protected async getKeyFromStorage(

View File

@@ -8,7 +8,7 @@
id="newItemDropdown"
[appA11yTitle]="'new' | i18n"
>
<i class="bwi bwi-plus" aria-hidden="true"></i>
<i class="bwi bwi-plus tw-me-2" aria-hidden="true"></i>
{{ "new" | i18n }}
</button>
<bit-menu #addOptions aria-labelledby="newItemDropdown">