1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-07 04:03:29 +00:00

Merge branch 'main' into pm-18701-optional-payment-modal-after-signup

This commit is contained in:
cyprain-okeke
2025-06-17 16:10:36 +01:00
committed by GitHub
108 changed files with 1958 additions and 1410 deletions

View File

@@ -1,4 +1,4 @@
name: Browser Bug Report
name: Browser Extension Bug Report
description: File a bug report
labels: [bug, browser]
body:

View File

@@ -1,4 +1,4 @@
name: Web Bug Report
name: Web App Bug Report
description: File a bug report
labels: [bug, web]
body:
@@ -77,6 +77,7 @@ body:
- Opera
- Brave
- Vivaldi
- DuckDuckGo
validations:
required: true
- type: input

View File

@@ -502,7 +502,7 @@ jobs:
run: |
npm run pack:win
- name: Pack & Sign (dev)
- name: Pack & Sign
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
env:
ELECTRON_BUILDER_SIGN: 1

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/browser",
"version": "2025.5.1",
"version": "2025.6.0",
"scripts": {
"build": "npm run build:chrome",
"build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",

View File

@@ -5403,5 +5403,9 @@
},
"noPermissionsViewPage": {
"message": "You do not have permissions to view this page. Try logging in with a different account."
},
"wasmNotSupported": {
"message": "WebAssembly is not supported on your browser or is not enabled. WebAssembly is required to use the Bitwarden app.",
"description": "'WebAssembly' is a technical term and should not be translated."
}
}

View File

@@ -65,7 +65,10 @@
{{ "showInlineMenuIdentitiesLabel" | i18n }}
</bit-label>
</bit-form-control>
<bit-form-control *ngIf="enableInlineMenu" class="tw-ml-5">
<bit-form-control
*ngIf="enableInlineMenu && !(restrictedCardType$ | async)"
class="tw-ml-5"
>
<input
bitCheckbox
id="show-inline-menu-cards"
@@ -114,7 +117,7 @@
</a>
</bit-hint>
</bit-form-control>
<bit-form-control>
<bit-form-control *ngIf="!(restrictedCardType$ | async)">
<input
bitCheckbox
id="showCardsSuggestions"

View File

@@ -11,7 +11,7 @@ import {
ReactiveFormsModule,
} from "@angular/forms";
import { RouterModule } from "@angular/router";
import { filter, firstValueFrom, Observable, switchMap } from "rxjs";
import { filter, firstValueFrom, map, Observable, shareReplay, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
@@ -44,6 +44,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import {
CardComponent,
CheckboxModule,
@@ -57,6 +58,7 @@ import {
SelectModule,
TypographyModule,
} from "@bitwarden/components";
import { RestrictedItemTypesService } from "@bitwarden/vault";
import { AutofillBrowserSettingsService } from "../../../autofill/services/autofill-browser-settings.service";
import { BrowserApi } from "../../../platform/browser/browser-api";
@@ -111,6 +113,11 @@ export class AutofillComponent implements OnInit {
this.nudgesService.showNudgeSpotlight$(NudgeType.AutofillNudge, account.id),
),
);
protected restrictedCardType$: Observable<boolean> =
this.restrictedItemTypesService.restricted$.pipe(
map((restrictedTypes) => restrictedTypes.some((type) => type.cipherType === CipherType.Card)),
shareReplay({ bufferSize: 1, refCount: true }),
);
protected autofillOnPageLoadForm = new FormGroup({
autofillOnPageLoad: new FormControl(),
@@ -156,6 +163,7 @@ export class AutofillComponent implements OnInit {
private nudgesService: NudgesService,
private accountService: AccountService,
private autofillBrowserSettingsService: AutofillBrowserSettingsService,
private restrictedItemTypesService: RestrictedItemTypesService,
) {
this.autofillOnPageLoadOptions = [
{ name: this.i18nService.t("autoFillOnPageLoadYes"), value: true },

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_extName__",
"short_name": "Bitwarden",
"version": "2025.5.1",
"version": "2025.6.0",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -3,7 +3,7 @@
"minimum_chrome_version": "102.0",
"name": "__MSG_extName__",
"short_name": "Bitwarden",
"version": "2025.5.1",
"version": "2025.6.0",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -35,9 +35,9 @@ if (BrowserApi.isManifestVersion(3)) {
console.info("WebAssembly is supported in this environment");
loadingPromise = import("./wasm");
} else {
// eslint-disable-next-line no-console
console.info("WebAssembly is not supported in this environment");
loadingPromise = import("./fallback");
loadingPromise = new Promise((_, reject) => {
reject(new Error("WebAssembly is not supported in this environment"));
});
}
}
@@ -51,9 +51,7 @@ async function importModule(): Promise<GlobalWithWasmInit["initSdk"]> {
console.info("WebAssembly is supported in this environment");
await import("./wasm");
} else {
// eslint-disable-next-line no-console
console.info("WebAssembly is not supported in this environment");
await import("./fallback");
throw new Error("WebAssembly is not supported in this environment");
}
// the wasm and fallback imports mutate globalThis to add the initSdk function

View File

@@ -1,8 +0,0 @@
import * as sdk from "@bitwarden/sdk-internal";
import * as wasm from "@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm.js";
import { GlobalWithWasmInit } from "./browser-sdk-load.service";
(globalThis as GlobalWithWasmInit).initSdk = () => {
(sdk as any).init(wasm);
};

View File

@@ -11,7 +11,17 @@ import {
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap, map } from "rxjs";
import {
Subject,
takeUntil,
firstValueFrom,
concatMap,
filter,
tap,
catchError,
of,
map,
} from "rxjs";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n";
@@ -23,6 +33,7 @@ import { AnimationControlService } from "@bitwarden/common/platform/abstractions
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { MessageListener } from "@bitwarden/common/platform/messaging";
import { UserId } from "@bitwarden/common/types/guid";
@@ -48,23 +59,45 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn
styles: [],
animations: [routerTransition],
template: `
<div [@routerTransition]="getRouteElevation(outlet)">
<router-outlet #outlet="outlet"></router-outlet>
</div>
<bit-toast-container></bit-toast-container>
@if (showSdkWarning | async) {
<div class="tw-h-screen tw-flex tw-justify-center tw-items-center tw-p-4">
<bit-callout type="danger">
{{ "wasmNotSupported" | i18n }}
<a
bitLink
href="https://bitwarden.com/help/wasm-not-supported/"
target="_blank"
rel="noreferrer"
>
{{ "learnMore" | i18n }}
</a>
</bit-callout>
</div>
} @else {
<div [@routerTransition]="getRouteElevation(outlet)">
<router-outlet #outlet="outlet"></router-outlet>
</div>
<bit-toast-container></bit-toast-container>
}
`,
standalone: false,
})
export class AppComponent implements OnInit, OnDestroy {
private compactModeService = inject(PopupCompactModeService);
private sdkService = inject(SdkService);
private lastActivity: Date;
private activeUserId: UserId;
private recordActivitySubject = new Subject<void>();
private routerAnimations = false;
private destroy$ = new Subject<void>();
// Show a warning if the SDK is not available.
protected showSdkWarning = this.sdkService.client$.pipe(
map(() => false),
catchError(() => of(true)),
);
constructor(
private authService: AuthService,
private i18nService: I18nService,

View File

@@ -20,6 +20,8 @@ import {
ButtonModule,
FormFieldModule,
ToastModule,
CalloutModule,
LinkModule,
} from "@bitwarden/components";
import { AccountComponent } from "../auth/popup/account-switching/account.component";
@@ -87,6 +89,8 @@ import "../platform/popup/locales";
CurrentAccountComponent,
FormFieldModule,
ExtensionAnonLayoutWrapperComponent,
CalloutModule,
LinkModule,
],
declarations: [
AppComponent,

View File

@@ -3,34 +3,12 @@
{{ "new" | i18n }}
</button>
<bit-menu #itemOptions>
<a bitMenuItem [routerLink]="['/add-cipher']" [queryParams]="buildQueryParams(cipherType.Login)">
<i class="bwi bwi-globe" slot="start" aria-hidden="true"></i>
{{ "typeLogin" | i18n }}
</a>
<a bitMenuItem [routerLink]="['/add-cipher']" [queryParams]="buildQueryParams(cipherType.Card)">
<i class="bwi bwi-credit-card" slot="start" aria-hidden="true"></i>
{{ "typeCard" | i18n }}
</a>
<a
bitMenuItem
[routerLink]="['/add-cipher']"
[queryParams]="buildQueryParams(cipherType.Identity)"
>
<i class="bwi bwi-id-card" slot="start" aria-hidden="true"></i>
{{ "typeIdentity" | i18n }}
</a>
<a
bitMenuItem
[routerLink]="['/add-cipher']"
[queryParams]="buildQueryParams(cipherType.SecureNote)"
>
<i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i>
{{ "note" | i18n }}
</a>
<a bitMenuItem [routerLink]="['/add-cipher']" [queryParams]="buildQueryParams(cipherType.SshKey)">
<i class="bwi bwi-key" slot="start" aria-hidden="true"></i>
{{ "typeSshKey" | i18n }}
</a>
@for (menuItem of cipherMenuItems$ | async; track menuItem.type) {
<a bitMenuItem [routerLink]="['/add-cipher']" [queryParams]="buildQueryParams(menuItem.type)">
<i [class]="`bwi ${menuItem.icon}`" slot="start" aria-hidden="true"></i>
{{ menuItem.labelKey | i18n }}
</a>
}
<bit-menu-divider></bit-menu-divider>
<button type="button" bitMenuItem (click)="openFolderDialog()">
<i class="bwi bwi-folder" slot="start" aria-hidden="true"></i>

View File

@@ -2,6 +2,7 @@ import { CommonModule } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, RouterLink } from "@angular/router";
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -12,6 +13,7 @@ import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstraction
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitwarden/components";
import { RestrictedCipherType, RestrictedItemTypesService } from "@bitwarden/vault";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
@@ -23,6 +25,7 @@ describe("NewItemDropdownV2Component", () => {
let fixture: ComponentFixture<NewItemDropdownV2Component>;
let dialogServiceMock: jest.Mocked<DialogService>;
let browserApiMock: jest.Mocked<typeof BrowserApi>;
let restrictedItemTypesServiceMock: jest.Mocked<RestrictedItemTypesService>;
const mockTab = { url: "https://example.com" };
@@ -44,6 +47,9 @@ describe("NewItemDropdownV2Component", () => {
const folderServiceMock = mock<FolderService>();
const folderApiServiceAbstractionMock = mock<FolderApiServiceAbstraction>();
const accountServiceMock = mock<AccountService>();
restrictedItemTypesServiceMock = {
restricted$: new BehaviorSubject<RestrictedCipherType[]>([]),
} as any;
await TestBed.configureTestingModule({
imports: [
@@ -65,6 +71,7 @@ describe("NewItemDropdownV2Component", () => {
{ provide: FolderService, useValue: folderServiceMock },
{ provide: FolderApiServiceAbstraction, useValue: folderApiServiceAbstractionMock },
{ provide: AccountService, useValue: accountServiceMock },
{ provide: RestrictedItemTypesService, useValue: restrictedItemTypesServiceMock },
],
}).compileComponents();
});

View File

@@ -3,12 +3,14 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { RouterLink } from "@angular/router";
import { map, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherMenuItem, CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items";
import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitwarden/components";
import { AddEditFolderDialogComponent } from "@bitwarden/vault";
import { AddEditFolderDialogComponent, RestrictedItemTypesService } from "@bitwarden/vault";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
@@ -34,7 +36,22 @@ export class NewItemDropdownV2Component implements OnInit {
@Input()
initialValues: NewItemInitialValues;
constructor(private dialogService: DialogService) {}
/**
* Observable of cipher menu items that are not restricted by policy
*/
readonly cipherMenuItems$: Observable<CipherMenuItem[]> =
this.restrictedItemTypeService.restricted$.pipe(
map((restrictedTypes) => {
const restrictedTypeArr = restrictedTypes.map((item) => item.cipherType);
return CIPHER_MENU_ITEMS.filter((menuItem) => !restrictedTypeArr.includes(menuItem.type));
}),
);
constructor(
private dialogService: DialogService,
private restrictedItemTypeService: RestrictedItemTypesService,
) {}
async ngOnInit() {
this.tab = await BrowserApi.getTabFromCurrentWindow();

View File

@@ -42,7 +42,7 @@
fullWidth
placeholderIcon="bwi-list"
[placeholderText]="'type' | i18n"
[options]="cipherTypes"
[options]="cipherTypes$ | async"
>
</bit-chip-select>
</form>

View File

@@ -18,7 +18,7 @@ export class VaultListFiltersComponent {
protected organizations$ = this.vaultPopupListFiltersService.organizations$;
protected collections$ = this.vaultPopupListFiltersService.collections$;
protected folders$ = this.vaultPopupListFiltersService.folders$;
protected cipherTypes = this.vaultPopupListFiltersService.cipherTypes;
protected cipherTypes$ = this.vaultPopupListFiltersService.cipherTypes$;
// Combine all filters into a single observable to eliminate the filters from loading separately in the UI.
protected allFilters$ = combineLatest([

View File

@@ -20,6 +20,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { RestrictedCipherType, RestrictedItemTypesService } from "@bitwarden/vault";
import {
CachedFilterState,
@@ -70,6 +71,10 @@ describe("VaultPopupListFiltersService", () => {
const state$ = new BehaviorSubject<boolean>(false);
const update = jest.fn().mockResolvedValue(undefined);
const restrictedItemTypesService = {
restricted$: new BehaviorSubject<RestrictedCipherType[]>([]),
};
beforeEach(() => {
_memberOrganizations$ = new BehaviorSubject<Organization[]>([]); // Fresh instance per test
folderViews$ = new BehaviorSubject([]); // Fresh instance per test
@@ -125,21 +130,46 @@ describe("VaultPopupListFiltersService", () => {
provide: ViewCacheService,
useValue: viewCacheService,
},
{
provide: RestrictedItemTypesService,
useValue: restrictedItemTypesService,
},
],
});
service = TestBed.inject(VaultPopupListFiltersService);
});
describe("cipherTypes", () => {
it("returns all cipher types", () => {
expect(service.cipherTypes.map((c) => c.value)).toEqual([
CipherType.Login,
CipherType.Card,
CipherType.Identity,
CipherType.SecureNote,
CipherType.SshKey,
describe("cipherTypes$", () => {
it("returns all cipher types when no restrictions", (done) => {
restrictedItemTypesService.restricted$.next([]);
service.cipherTypes$.subscribe((cipherTypes) => {
expect(cipherTypes.map((c) => c.value)).toEqual([
CipherType.Login,
CipherType.Card,
CipherType.Identity,
CipherType.SecureNote,
CipherType.SshKey,
]);
done();
});
});
it("filters out restricted cipher types", (done) => {
restrictedItemTypesService.restricted$.next([
{ cipherType: CipherType.Card, allowViewOrgIds: [] },
]);
service.cipherTypes$.subscribe((cipherTypes) => {
expect(cipherTypes.map((c) => c.value)).toEqual([
CipherType.Login,
CipherType.Identity,
CipherType.SecureNote,
CipherType.SshKey,
]);
done();
});
});
});
@@ -452,6 +482,10 @@ describe("VaultPopupListFiltersService", () => {
{ type: CipherType.SecureNote, collectionIds: [], organizationId: null },
] as CipherView[];
beforeEach(() => {
restrictedItemTypesService.restricted$.next([]);
});
it("filters by cipherType", (done) => {
service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction(ciphers)).toEqual([ciphers[0]]);
@@ -690,6 +724,9 @@ function createSeededVaultPopupListFiltersService(
} as any;
const accountServiceMock = mockAccountServiceWith("userId" as UserId);
const restrictedItemTypesServiceMock = {
restricted$: new BehaviorSubject<RestrictedCipherType[]>([]),
} as any;
const formBuilderInstance = new FormBuilder();
const seededCachedSignal = createMockSignal<CachedFilterState>(cachedState);
@@ -713,6 +750,7 @@ function createSeededVaultPopupListFiltersService(
stateProviderMock,
accountServiceMock,
viewCacheServiceMock,
restrictedItemTypesServiceMock,
);
});

View File

@@ -39,7 +39,9 @@ import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items";
import { ChipSelectOption } from "@bitwarden/components";
import { RestrictedItemTypesService } from "@bitwarden/vault";
const FILTER_VISIBILITY_KEY = new KeyDefinition<boolean>(VAULT_SETTINGS_DISK, "filterVisibility", {
deserializer: (obj) => obj,
@@ -178,6 +180,7 @@ export class VaultPopupListFiltersService {
private stateProvider: StateProvider,
private accountService: AccountService,
private viewCacheService: ViewCacheService,
private restrictedItemTypesService: RestrictedItemTypesService,
) {
this.filterForm.controls.organization.valueChanges
.pipe(takeUntilDestroyed())
@@ -210,74 +213,80 @@ export class VaultPopupListFiltersService {
/**
* Observable whose value is a function that filters an array of `CipherView` objects based on the current filters
*/
filterFunction$: Observable<(ciphers: CipherView[]) => CipherView[]> = this.filters$.pipe(
filterFunction$: Observable<(ciphers: CipherView[]) => CipherView[]> = combineLatest([
this.filters$,
this.restrictedItemTypesService.restricted$.pipe(startWith([])),
]).pipe(
map(
(filters) => (ciphers: CipherView[]) =>
ciphers.filter((cipher) => {
// Vault popup lists never shows deleted ciphers
if (cipher.isDeleted) {
return false;
}
if (filters.cipherType !== null && cipher.type !== filters.cipherType) {
return false;
}
if (filters.collection && !cipher.collectionIds?.includes(filters.collection.id)) {
return false;
}
if (filters.folder && cipher.folderId !== filters.folder.id) {
return false;
}
const isMyVault = filters.organization?.id === MY_VAULT_ID;
if (isMyVault) {
if (cipher.organizationId !== null) {
([filters, restrictions]) =>
(ciphers: CipherView[]) =>
ciphers.filter((cipher) => {
// Vault popup lists never shows deleted ciphers
if (cipher.isDeleted) {
return false;
}
} else if (filters.organization) {
if (cipher.organizationId !== filters.organization.id) {
// Check if cipher type is restricted (with organization exemptions)
if (restrictions && restrictions.length > 0) {
const isRestricted = restrictions.some(
(restrictedType) =>
restrictedType.cipherType === cipher.type &&
(cipher.organizationId
? !restrictedType.allowViewOrgIds.includes(cipher.organizationId)
: restrictedType.allowViewOrgIds.length === 0),
);
if (isRestricted) {
return false;
}
}
if (filters.cipherType !== null && cipher.type !== filters.cipherType) {
return false;
}
}
return true;
}),
if (filters.collection && !cipher.collectionIds?.includes(filters.collection.id)) {
return false;
}
if (filters.folder && cipher.folderId !== filters.folder.id) {
return false;
}
const isMyVault = filters.organization?.id === MY_VAULT_ID;
if (isMyVault) {
if (cipher.organizationId !== null) {
return false;
}
} else if (filters.organization) {
if (cipher.organizationId !== filters.organization.id) {
return false;
}
}
return true;
}),
),
);
/**
* All available cipher types
* All available cipher types (filtered by policy restrictions)
*/
readonly cipherTypes: ChipSelectOption<CipherType>[] = [
{
value: CipherType.Login,
label: this.i18nService.t("typeLogin"),
icon: "bwi-globe",
},
{
value: CipherType.Card,
label: this.i18nService.t("typeCard"),
icon: "bwi-credit-card",
},
{
value: CipherType.Identity,
label: this.i18nService.t("typeIdentity"),
icon: "bwi-id-card",
},
{
value: CipherType.SecureNote,
label: this.i18nService.t("note"),
icon: "bwi-sticky-note",
},
{
value: CipherType.SshKey,
label: this.i18nService.t("typeSshKey"),
icon: "bwi-key",
},
];
readonly cipherTypes$: Observable<ChipSelectOption<CipherType>[]> =
this.restrictedItemTypesService.restricted$.pipe(
map((restrictedTypes) => {
const restrictedCipherTypes = restrictedTypes.map((r) => r.cipherType);
return CIPHER_MENU_ITEMS.filter((item) => !restrictedCipherTypes.includes(item.type)).map(
(item) => ({
value: item.type,
label: this.i18nService.t(item.labelKey),
icon: item.icon,
}),
);
}),
);
/** Resets `filterForm` to the original state */
resetFilterForm(): void {

View File

@@ -1,7 +1,7 @@
{
"name": "@bitwarden/cli",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.5.0",
"version": "2025.6.0",
"keywords": [
"bitwarden",
"password",

View File

@@ -1,8 +0,0 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQRQzzQ8nQEouF1FMSHkPx1nejNCzF7g
Yb8MHXLdBFM0uJkWs0vzgLJkttts2eDv3SHJqIH6qHpkLtEvgMXE5WcaAAAAoOO1BebjtQ
XmAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFDPNDydASi4XUUx
IeQ/HWd6M0LMXuBhvwwdct0EUzS4mRazS/OAsmS222zZ4O/dIcmogfqoemQu0S+AxcTlZx
oAAAAhAKnIXk6H0Hs3HblklaZ6UmEjjdE/0t7EdYixpMmtpJ4eAAAAB3Rlc3RrZXk=
-----END OPENSSH PRIVATE KEY-----

View File

@@ -1 +0,0 @@
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFDPNDydASi4XUUxIeQ/HWd6M0LMXuBhvwwdct0EUzS4mRazS/OAsmS222zZ4O/dIcmogfqoemQu0S+AxcTlZxo= testkey

View File

@@ -1,8 +0,0 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABAUTNb0if
fqsoqtfv70CfukAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIHGs3Uw3eyqnFjBI
2eb7Qto4KVc34ZdnBac59Bab54BLAAAAkPA6aovfxQbP6FoOfaRH6u22CxqiUM0bbMpuFf
WETn9FLaBE6LjoHH0ZI5rzNjJaQUNfx0cRcqsIrexw8YINrdVjySmEqrl5hw8gpgy0gGP5
1Y6vKWdHdrxJCA9YMFOfDs0UhPfpLKZCwm2Sg+Bd8arlI8Gy7y4Jj/60v2bZOLhD2IZQnK
NdJ8xATiIINuTy4g==
-----END OPENSSH PRIVATE KEY-----

View File

@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHGs3Uw3eyqnFjBI2eb7Qto4KVc34ZdnBac59Bab54BL testkey

View File

@@ -1,7 +0,0 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACAyQo22TXXNqvF+L8jUSSNeu8UqrsDjvf9pwIwDC9ML6gAAAJDSHpL60h6S
+gAAAAtzc2gtZWQyNTUxOQAAACAyQo22TXXNqvF+L8jUSSNeu8UqrsDjvf9pwIwDC9ML6g
AAAECLdlFLIJbEiFo/f0ROdXMNZAPHGPNhvbbftaPsUZEjaDJCjbZNdc2q8X4vyNRJI167
xSquwOO9/2nAjAML0wvqAAAAB3Rlc3RrZXkBAgMEBQY=
-----END OPENSSH PRIVATE KEY-----

View File

@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDJCjbZNdc2q8X4vyNRJI167xSquwOO9/2nAjAML0wvq testkey

View File

@@ -1,4 +0,0 @@
-----BEGIN PRIVATE KEY-----
MFECAQEwBQYDK2VwBCIEIDY6/OAdDr3PbDss9NsLXK4CxiKUvz5/R9uvjtIzj4Sz
gSEAxsxm1xpZ/4lKIRYm0JrJ5gRZUh7H24/YT/0qGVGzPa0=
-----END PRIVATE KEY-----

View File

@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMbMZtcaWf+JSiEWJtCayeYEWVIex9uP2E/9KhlRsz2t

View File

@@ -1,8 +0,0 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
c2gtZWQyNTUxOQAAACDp0/9zFBCyZs5BFqXCJN5i1DTanzPGHpUeo2LP8FmQ9wAA
AKCyIXPqsiFz6gAAAAtzc2gtZWQyNTUxOQAAACDp0/9zFBCyZs5BFqXCJN5i1DTa
nzPGHpUeo2LP8FmQ9wAAAEDQioomhjmD+sh2nsxfQLJ5YYGASNUAlUZHe9Jx0p47
H+nT/3MUELJmzkEWpcIk3mLUNNqfM8YelR6jYs/wWZD3AAAAEmVkZHNhLWtleS0y
MDI0MTExOAECAwQFBgcICQoL
-----END OPENSSH PRIVATE KEY-----

View File

@@ -1,39 +0,0 @@
-----BEGIN OPENSSH PRIVATE KEY-----
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
2MmKBw==
-----END OPENSSH PRIVATE KEY-----

View File

@@ -1 +0,0 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC/v18xGP3qzRV9iWqyiuwHZ4GpC4K2NO2/i2Yv5A3/bnal7CmiMh/S78lphgxcWtFkwrwlb321FmdHBv6KOW+EzSiPvmsdkkbpfBXB3Qf2SlhZOZZ7lYeu8KAxL3exvvn8O1GGlUjXGUrFgmC60tHWDBc1Ncmo8a2dwDLmA/sbLa8su2dvYEFmRg1vaytLDpkn8GS7zAxrUl/g0W2RwkPsByduUziQuX90v9WAy7MqOlwBRq6t5o8wdDBVODe0VIXC7N1OS42YUsKF+N0XOnLiJrIIKkXpahMDpKZHeHQAdUQzsJVhKoLJR8DNDTYyhnJoQG7Q6m2gDTca9oAWvsBiNoEwCvwrt7cDNCz/GslH9HXQgfWcVXn8+fuZgvjO3CxUI16Ev33m0jWoOKJcgK/ZLRnk8SEvsJ8NO32MeR/qUb7IN/yUcDmPMI/3ecQsakF2cwNzHkyiGVo//yVTpf+vk8b89L+GXbYU5rtswtc2ZEGsQnUkaoNqS8mHqhWQBUk= testkey

View File

@@ -1,38 +0,0 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEAtVIe0gnPtD6299/roT7ntZgVe+qIqIMIruJdI2xTanLGhNpBOlzg
WqokbQK+aXATcaB7iQL1SPxIWV2M4jEBQbZuimIgDQvKbJ4TZPKEe1VdsrfuIo+9pDK7cG
Kc+JiWhKjqeTRMj91/qR1fW5IWOUyE1rkwhTNkwJqtYKZLVmd4TXtQsYMMC+I0cz4krfk1
Yqmaae/gj12h8BvE3Y+Koof4JoLsqPufH+H/bVEayv63RyAQ1/tUv9l+rwJ+svWV4X3zf3
z40hGF43L/NGl90Vutbn7b9G/RgEdiXyLZciP3XbWbLUM+r7mG9KNuSeoixe5jok15UKqC
XXxVb5IEZ73kaubSfz9JtsqtKG/OjOq6Fbl3Ky7kjvJyGpIvesuSInlpzPXqbLUCLJJfOA
PUZ1wi8uuuRNePzQBMMhq8UtAbB2Dy16d+HlgghzQ00NxtbQMfDZBdApfxm3shIxkUcHzb
DSvriHVaGGoOkmHPAmsdMsMiekuUMe9ljdOhmdTxAAAFgF8XjBxfF4wcAAAAB3NzaC1yc2
EAAAGBALVSHtIJz7Q+tvff66E+57WYFXvqiKiDCK7iXSNsU2pyxoTaQTpc4FqqJG0Cvmlw
E3Gge4kC9Uj8SFldjOIxAUG2bopiIA0LymyeE2TyhHtVXbK37iKPvaQyu3BinPiYloSo6n
k0TI/df6kdX1uSFjlMhNa5MIUzZMCarWCmS1ZneE17ULGDDAviNHM+JK35NWKpmmnv4I9d
ofAbxN2PiqKH+CaC7Kj7nx/h/21RGsr+t0cgENf7VL/Zfq8CfrL1leF98398+NIRheNy/z
RpfdFbrW5+2/Rv0YBHYl8i2XIj9121my1DPq+5hvSjbknqIsXuY6JNeVCqgl18VW+SBGe9
5Grm0n8/SbbKrShvzozquhW5dysu5I7ychqSL3rLkiJ5acz16my1AiySXzgD1GdcIvLrrk
TXj80ATDIavFLQGwdg8tenfh5YIIc0NNDcbW0DHw2QXQKX8Zt7ISMZFHB82w0r64h1Whhq
DpJhzwJrHTLDInpLlDHvZY3ToZnU8QAAAAMBAAEAAAGAEL3wpRWtVTf+NnR5QgX4KJsOjs
bI0ABrVpSFo43uxNMss9sgLzagq5ZurxcUBFHKJdF63puEkPTkbEX4SnFaa5of6kylp3a5
fd55rXY8F9Q5xtT3Wr8ZdFYP2xBr7INQUJb1MXRMBnOeBDw3UBH01d0UHexzB7WHXcZacG
Ria+u5XrQebwmJ3PYJwENSaTLrxDyjSplQy4QKfgxeWNPWaevylIG9vtue5Xd9WXdl6Szs
ONfD3mFxQZagPSIWl0kYIjS3P2ZpLe8+sakRcfci8RjEUP7U+QxqY5VaQScjyX1cSYeQLz
t+/6Tb167aNtQ8CVW3IzM2EEN1BrSbVxFkxWFLxogAHct06Kn87nPn2+PWGWOVCBp9KheO
FszWAJ0Kzjmaga2BpOJcrwjSpGopAb1YPIoRPVepVZlQ4gGwy5gXCFwykT9WTBoJfg0BMQ
r3MSNcoc97eBomIWEa34K0FuQ3rVjMv9ylfyLvDBbRqTJ5zebeOuU+yCQHZUKk8klRAAAA
wAsToNZvYWRsOMTWQom0EW1IHzoL8Cyua+uh72zZi/7enm4yHPJiu2KNgQXfB0GEEjHjbo
9peCW3gZGTV+Ee+cAqwYLlt0SMl/VJNxN3rEG7BAqPZb42Ii2XGjaxzFq0cliUGAdo6UEd
swU8d2I7m9vIZm4nDXzsWOBWgonTKBNyL0DQ6KNOGEyj8W0BTCm7Rzwy7EKzFWbIxr4lSc
vDrJ3t6kOd7jZTF58kRMT0nxR0bf43YzF/3/qSvLYhQm/OOAAAAMEA2F6Yp8SrpQDNDFxh
gi4GeywArrQO9r3EHjnBZi/bacxllSzCGXAvp7m9OKC1VD2wQP2JL1VEIZRUTuGGT6itrm
QpX8OgoxlEJrlC5W0kHumZ3MFGd33W11u37gOilmd6+VfVXBziNG2rFohweAgs8X+Sg5AA
nIfMV6ySXUlvLzMHpGeKRRnnQq9Cwn4rDkVQENLd1i4e2nWFhaPTUwVMR8YuOT766bywr3
7vG1PQLF7hnf2c/oPHAru+XD9gJWs5AAAAwQDWiB2G23F4Tvq8FiK2mMusSjQzHupl83rm
o3BSNRCvCjaLx6bWhDPSA1edNEF7VuP6rSp+i+UfSORHwOnlgnrvtcJeoDuA72hUeYuqD/
1C9gghdhKzGTVf/IGTX1tH3rn2Gq9TEyrJs/ITcoOyZprz7VbaD3bP/NEER+m1EHi2TS/3
SXQEtRm+IIBwba+QLUcsrWdQyIO+1OCXywDrAw50s7tjgr/goHgXTcrSXaKcIEOlPgBZH3
YPuVuEtRYgX3kAAAAHdGVzdGtleQECAwQ=
-----END OPENSSH PRIVATE KEY-----

View File

@@ -1 +0,0 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC1Uh7SCc+0Prb33+uhPue1mBV76oiogwiu4l0jbFNqcsaE2kE6XOBaqiRtAr5pcBNxoHuJAvVI/EhZXYziMQFBtm6KYiANC8psnhNk8oR7VV2yt+4ij72kMrtwYpz4mJaEqOp5NEyP3X+pHV9bkhY5TITWuTCFM2TAmq1gpktWZ3hNe1CxgwwL4jRzPiSt+TViqZpp7+CPXaHwG8Tdj4qih/gmguyo+58f4f9tURrK/rdHIBDX+1S/2X6vAn6y9ZXhffN/fPjSEYXjcv80aX3RW61uftv0b9GAR2JfItlyI/ddtZstQz6vuYb0o25J6iLF7mOiTXlQqoJdfFVvkgRnveRq5tJ/P0m2yq0ob86M6roVuXcrLuSO8nIaki96y5IieWnM9epstQIskl84A9RnXCLy665E14/NAEwyGrxS0BsHYPLXp34eWCCHNDTQ3G1tAx8NkF0Cl/GbeyEjGRRwfNsNK+uIdVoYag6SYc8Cax0ywyJ6S5Qx72WN06GZ1PE= testkey

View File

@@ -1,42 +0,0 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIHdTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQXquAya5XFx11QEPm
KCSnlwICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEAQIEEKVtEIkI2ELppfUQ
IwfNzowEggcQtWhXVz3LunYTSRVgnexcHEaGkUF6l6a0mGaLSczl+jdCwbbBxibU
EvN7+WMQ44shOk3LyThg0Irl22/7FuovmYc3TSeoMQH4mTROKF+9793v0UMAIAYd
ZhTsexTGncCOt//bq6Fl+L+qPNEkY/OjS+wI9MbOn/Agbcr8/IFSOxuSixxoTKgq
4QR5Ra3USCLyfm+3BoGPMk3tbEjrwjvzx/eTaWzt6hdc0yX4ehtqExF8WAYB43DW
3Y1slA1T464/f1j4KXhoEXDTBOuvNvnbr7lhap8LERIGYGnQKv2m2Kw57Wultnoe
joEQ+vTl5n92HI77H8tbgSbTYuEQ2n9pDD7AAzYGBn15c4dYEEGJYdHnqfkEF+6F
EgPa+Xhj2qqk5nd1bzPSv6iX7XfAX2sRzfZfoaFETmR0ZKbs0aMsndC5wVvd3LpA
m86VUihQxDvU8F4gizrNYj4NaNRv4lrxBj7Kb6BO/qT3DB8Uqu43oyrvA90iMigi
EvuCViwwhwCpe+AxCqLGrzvIpiZCksTOtSPEvnMehw2WA3yd/n88Nis5zD4b65+q
Tx9Q0Qm1LIi1Bq+s60+W1HK3KfaLrJaoX3JARZoWfxurZwtj+cMlo5zK1Ha2HHqQ
kVn21tOcQU/Yljt3Db+CKZ5Tos/rPywxGnkeMABzJgyajPHkYaSgWZrOEueihfS1
5eDtEMBehEyHfcUrL7XGnn4lOzwQHZIEFnVdV0YGaQY8Wz212IjeWxV09gM2OEP6
PEDI3GSsqOnGkPrnson5tsIUcvpk9smy9AA9qVhNowzeWCWmsF8K9fn/O94tIzyN
2EK0tkf8oDVROlbEh/jDa2aAHqPGCXBEqq1CbZXQpNk4FlRzkjtxdzPNiXLf45xO
IjOTTzgaVYWiKZD9ymNjNPIaDCPB6c4LtUm86xUQzXdztBm1AOI3PrNI6nIHxWbF
bPeEkJMRiN7C9j5nQMgQRB67CeLhzvqUdyfrYhzc7HY479sKDt9Qn8R0wpFw0QSA
G1gpGyxFaBFSdIsil5K4IZYXxh7qTlOKzaqArTI0Dnuk8Y67z8zaxN5BkvOfBd+Q
SoDz6dzn7KIJrK4XP3IoNfs6EVT/tlMPRY3Y/Ug+5YYjRE497cMxW8jdf3ZwgWHQ
JubPH+0IpwNNZOOf4JXALULsDj0N7rJ1iZAY67b+7YMin3Pz0AGQhQdEdqnhaxPh
oMvL9xFewkyujwCmPj1oQi1Uj2tc1i4ZpxY0XmYn/FQiQH9/XLdIlOMSTwGx86bw
90e9VJHfCmflLOpENvv5xr2isNbn0aXNAOQ4drWJaYLselW2Y4N1iqBCWJKFyDGw
4DevhhamEvsrdoKgvnuzfvA44kQGmfTjCuMu7IR5zkxevONNrynKcHkoWATzgxSS
leXCxzc9VA0W7XUSMypHGPNHJCwYZvSWGx0qGI3VREUk2J7OeVjXCFNeHFc2Le3P
dAm+DqRiyPBVX+yW+i7rjZLyypLzmYo9CyhlohOxTeGa6iTxBUZfYGoc0eJNqfgN
/5hkoPFYGkcd/p41SKSg7akrJPRc+uftH0oVI0wVorGSVOvwXRn7QM+wFKlv3DQD
ysMP7cOKqMyhJsqeW74/iWEmhbFIDKexSd/KTQ6PirVlzj7148Fl++yxaZpnZ6MY
iyzifvLcT701GaewIwi9YR9f1BWUXYHTjK3sB3lLPyMbA4w9bRkylcKrbGf85q0E
LXPlfh+1C9JctczDCqr2iLRoc/5j23GeN8RWfUNpZuxjFv9sxkV4iG+UapIuOBIc
Os4//3w24XcTXYqBdX2Y7+238xq6/94+4hIhXAcMFc2Nr3CEAZCuKYChVL9CSA3v
4sZM4rbOz6kWTC2G3SAtkLSk7hCJ6HLXzrnDb4++g3JYJWLeaQ+4ZaxWuKymnehN
xumXCwCn0stmCjXYV/yM3TeVnMfBTIB13KAjbn0czGW00nj79rNJJzkOlp9tIPen
pUPRFPWjgLF+hVQrwqJ3HPmt6Rt6mKzZ4FEpBXMDjvlKabnFvBdl3gbNHSfxhGHi
FzG3phg1CiXaURQUAf21PV+djfBha7kDwMXnpgZ+PIyGDxRj61StV/NSlhg+8GrL
ccoDOkfpy2zn++rmAqA21rTEChFN5djdsJw45GqPKUPOAgxKBsvqpoMIqq/C2pHP
iMiBriZULV9l0tHn5MMcNQbYAmp4BsTo6maHByAVm1/7/VPQn6EieuGroYgSk2H7
pnwM01IUfGGP3NKlq9EiiF1gz8acZ5v8+jkZM2pIzh8Trw0mtwBpnyiyXmpbR/RG
m/TTU/gNQ/94ZaNJ/shPoBwikWXvOm+0Z0ZAwu3xefTyENGhjmb5GXshEN/5WwCm
NNrtUPlkGkYJrnSCVM/lHtjShwbLw2w/1sag1uDuXwirxxYh9r7D6HQ=
-----END ENCRYPTED PRIVATE KEY-----

View File

@@ -1 +0,0 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCcHkc0xfH4w9aW41S9M/BfancSY4QPc2O4G1cRjFfK8QrLEGDA7NiHtoEML0afcurRXD3NVxuKaAns0w6EoS4CjzXUqVHTLA4SUyuapr8k0Eu2xOpbCwC3jDovhckoKloq7BvE6rC2i5wjSMadtIJKt/dqWI3HLjUMz1BxQJAU/qAbicj1SFZSjA/MubVBzcq93XOvByMtlIFu7wami3FTc37rVkGeUFHtK8ZbvG3n1aaTF79bBgSPuoq5BfcMdGr4WfQyGQzgse4v4hQ8yKYrtE0jo0kf06hEORimwOIU/W5IH1r+/xFs7qGKcPnFSZRIFv5LfMPTo8b+OsBRflosyfUumDEX97GZE7DSQl0EJzNvWeKwl7dQ8RUJTkbph2CjrxY77DFim+165Uj/WRr4uq2qMNhA2xNSD19+TA6AHdpGw4WZd37q2/n+EddlaJEH8MzpgtHNG9MiYh5ScZ+AG0QugflozJcQNc7n8N9Lpu1sRoejV5RhurHg/TYwVK8= testkey

View File

@@ -1,40 +0,0 @@
-----BEGIN PRIVATE KEY-----
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
QjDEB37RqGZxqyIx8V2ZYwU=
-----END PRIVATE KEY-----

View File

@@ -1 +0,0 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCn4+QiJojZ9mgc9KYJIvDWGaz4qFhf0CButg6L8zEoHKwuiN+mqcEciCCOa9BNiJmm8NTTehZvrrglGG59zIbqYtDAHjVn+vtb49xPzIv+M651Yqj08lIbR9tEIHKCq7aH8GlDm8NgG9EzJGjlL7okQym4TH1MHl+s4mUyr/qb2unlZBDixAQsphU8iCLftukWCIkmQg4CSj1Gh3WbBlZ+EX5eW0EXuAw4XsSbBTWV9CHRowVIpYqPvEYSpHsoCjEcd988p19hpiGknA0J4z7JfUlNgyT/1chb8GCTDT+2DCBRApbsIg6TOBVS+PR6emAQ3eZzUW0+3/oRM4ip0ujltQy8uU6gvYIAqx5wXGMThVpZcUgahKiSsVo/s4b84iMe4DG3W8jz4qi6yyNv0VedEzPUZ1lXd1GJFoy9uKNuSTe+1ksicAcluZN6LuNsPHcPxFCzOcmoNnVXEKAXInt+ys//5CDVasroZSAHZnDjUD4oNsLI3VIOnGxgXrkwSH0= testkey

View File

@@ -1,30 +0,0 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdz
c2gtcnNhAAAAAwEAAQAAAQEAootgTLcKjSgPLS2+RT3ZElhktL1CwIyM/+3IqEq0
0fl/rRHBT8otklV3Ld7DOR50HVZSoV0u9qs0WOdxcjEJlJACDClmxZmFr0BQ/E2y
V5xzuMZj3Mj+fL26jTmM3ueRHZ0tU5ubSFvINIFyDGG70F7VgkpBA8zsviineMSU
t1iIPi/6feL6h7QAFUk6JdQJpPTs9Nb2+DAQ9lMS2614cxLXkfUIXA4NvHMfZGdU
dq1mBJIAWZ4PPJ6naUcu0lVYjuVEAOE4UoHxr6YlW4yyAF/I1YXBFpcHG7P0egvg
neTPli5Wzum0XDsOPivqr6z2E5k7nzyGXUaP5MjRfDDVLwAAA9D6lTpR+pU6UQAA
AAdzc2gtcnNhAAABAQCii2BMtwqNKA8tLb5FPdkSWGS0vULAjIz/7cioSrTR+X+t
EcFPyi2SVXct3sM5HnQdVlKhXS72qzRY53FyMQmUkAIMKWbFmYWvQFD8TbJXnHO4
xmPcyP58vbqNOYze55EdnS1Tm5tIW8g0gXIMYbvQXtWCSkEDzOy+KKd4xJS3WIg+
L/p94vqHtAAVSTol1Amk9Oz01vb4MBD2UxLbrXhzEteR9QhcDg28cx9kZ1R2rWYE
kgBZng88nqdpRy7SVViO5UQA4ThSgfGvpiVbjLIAX8jVhcEWlwcbs/R6C+Cd5M+W
LlbO6bRcOw4+K+qvrPYTmTufPIZdRo/kyNF8MNUvAAAAAwEAAQAAAQB6YVPVDq9s
DfA3RMyQF3vbOyA/kIu0q13xx1cflnfD7AT8CnUwnPloxt5fc+wqkko8WGUIRz93
yvkzwrYAkvkymKZh/734IpmrlFIlVF5lZk8enIhNkCtDQho2AFGW9mSlFlUtMOhe
N3RqS9fRiLg+r1gzq7J9qQnKNpO48tFBpLkIqr8nZOVhEn8IASrQYBUoocClNrv6
Pdl8ni5jqnZ/0K0nq4+41Ag1VMI4LUcRCucid8ci1HKdOmGXkvClbzuFMWv3UC0k
qDgzg/gOIgj75I7B34FYVx47UGZ6jmC7iRkHd6RiCHYkmsDSjRQHR6eRbtLPdl9w
TlG2NrwkbSlhAAAAgQCapfJLqew9aK8PKfe3FwiC9sb0itCAXPXHhD+pQ6Tl9UMZ
hmnG2g9qbowCprz3/kyix+nWL/Kx7eKAZYH2MBz6cxfqs2A+BSuxvX/hsnvF96BP
u1I47rGrd0NC78DTY2NDO4Ccirx6uN+AoCl4cC+KC00Kykww6TTEBrQsdQTk5QAA
AIEA7JwbIIMwDiQUt3EY/VU0SYvg67aOiyOYEWplSWCGdT58jnfS1H95kGVw+qXR
eSQ0VNv6LBz7XDRpfQlNXDNJRnDZuHBbk+T9ZwnynRLWuzK7VqZBPJoNoyLFSMW2
DBhLVKIrg0MsBAnRBMDUlVDlzs2LoNLEra3dj8Zb9vMdlbEAAACBAK/db27GfXXg
OikZkIqWiFgBArtj0T4iFc7BLUJUeFtl0RP9LLjfvaxSdA1cmVYzzkgmuc2iZLF0
37zuPkDrfYVRiw8rSihT3D+WDt3/Tt013WCuxVQOQSW+Qtw6yZpM92DKncbvYsUy
5DNklW1+TYxyn2ltM7SaZjmF8UeoTnDfAAAAEHJzYS1rZXktMjAyNDExMTgBAgME
BQYHCAkK
-----END OPENSSH PRIVATE KEY-----

View File

@@ -1,27 +0,0 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAootgTLcKjSgPLS2+RT3ZElhktL1CwIyM/+3IqEq00fl/rRHB
T8otklV3Ld7DOR50HVZSoV0u9qs0WOdxcjEJlJACDClmxZmFr0BQ/E2yV5xzuMZj
3Mj+fL26jTmM3ueRHZ0tU5ubSFvINIFyDGG70F7VgkpBA8zsviineMSUt1iIPi/6
feL6h7QAFUk6JdQJpPTs9Nb2+DAQ9lMS2614cxLXkfUIXA4NvHMfZGdUdq1mBJIA
WZ4PPJ6naUcu0lVYjuVEAOE4UoHxr6YlW4yyAF/I1YXBFpcHG7P0egvgneTPli5W
zum0XDsOPivqr6z2E5k7nzyGXUaP5MjRfDDVLwIDAQABAoIBAHphU9UOr2wN8DdE
zJAXe9s7ID+Qi7SrXfHHVx+Wd8PsBPwKdTCc+WjG3l9z7CqSSjxYZQhHP3fK+TPC
tgCS+TKYpmH/vfgimauUUiVUXmVmTx6ciE2QK0NCGjYAUZb2ZKUWVS0w6F43dGpL
19GIuD6vWDOrsn2pCco2k7jy0UGkuQiqvydk5WESfwgBKtBgFSihwKU2u/o92Xye
LmOqdn/QrSerj7jUCDVUwjgtRxEK5yJ3xyLUcp06YZeS8KVvO4Uxa/dQLSSoODOD
+A4iCPvkjsHfgVhXHjtQZnqOYLuJGQd3pGIIdiSawNKNFAdHp5Fu0s92X3BOUbY2
vCRtKWECgYEA7JwbIIMwDiQUt3EY/VU0SYvg67aOiyOYEWplSWCGdT58jnfS1H95
kGVw+qXReSQ0VNv6LBz7XDRpfQlNXDNJRnDZuHBbk+T9ZwnynRLWuzK7VqZBPJoN
oyLFSMW2DBhLVKIrg0MsBAnRBMDUlVDlzs2LoNLEra3dj8Zb9vMdlbECgYEAr91v
bsZ9deA6KRmQipaIWAECu2PRPiIVzsEtQlR4W2XRE/0suN+9rFJ0DVyZVjPOSCa5
zaJksXTfvO4+QOt9hVGLDytKKFPcP5YO3f9O3TXdYK7FVA5BJb5C3DrJmkz3YMqd
xu9ixTLkM2SVbX5NjHKfaW0ztJpmOYXxR6hOcN8CgYASLZAb+Fg5zeXVjhfYZrJk
sB1wno7m+64UMHNlpsfNvCY/n88Pyldhk5mReCnWv8RRfLEEsJlTJSexloReAAay
JbtkYyV2AFLDls0P6kGbEjO4XX+Hk2JW1TYI+D+bQEaRUwA6zm9URBjN3661Zgix
0bLXgTnhCgmKoTexik4MkQKBgEZR14XGzlG81+SpOTeBG4F83ffJ4NfkTy395jf4
iKubGa/Rcvl1VWU7DvZsyU9Dpb8J5Q+JWJPwdKoZ5UCWKPmO8nidSai4Z3/xY352
4LTpHdzT5UlH7drGqftfck9FaUEFo3LxM2BAiijWlj1S3HVFO+Ku7JbRigCEQ0bw
0HSnAoGBAJql8kup7D1orw8p97cXCIL2xvSK0IBc9ceEP6lDpOX1QxmGacbaD2pu
jAKmvPf+TKLH6dYv8rHt4oBlgfYwHPpzF+qzYD4FK7G9f+Gye8X3oE+7Ujjusat3
Q0LvwNNjY0M7gJyKvHq434CgKXhwL4oLTQrKTDDpNMQGtCx1BOTl
-----END RSA PRIVATE KEY-----

View File

@@ -1,7 +1,7 @@
{
"name": "@bitwarden/desktop",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.6.0",
"version": "2025.6.1",
"keywords": [
"bitwarden",
"password",

View File

@@ -222,6 +222,9 @@ const routes: Routes = [
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: DesktopDefaultOverlayPosition,
},
},
],
},
@@ -242,6 +245,9 @@ const routes: Routes = [
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: DesktopDefaultOverlayPosition,
},
},
],
},
@@ -276,6 +282,9 @@ const routes: Routes = [
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: DesktopDefaultOverlayPosition,
},
},
],
},

View File

@@ -1,3 +1,4 @@
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricsService } from "@bitwarden/key-management";
@@ -6,10 +7,10 @@ import { BiometricsService } from "@bitwarden/key-management";
* specifically for the main process.
*/
export abstract class DesktopBiometricsService extends BiometricsService {
abstract setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise<void>;
abstract setBiometricProtectedUnlockKeyForUser(
userId: UserId,
value: SymmetricCryptoKey,
): Promise<void>;
abstract deleteBiometricUnlockKeyForUser(userId: UserId): Promise<void>;
abstract setupBiometrics(): Promise<void>;
abstract setClientKeyHalfForUser(userId: UserId, value: string | null): Promise<void>;
}

View File

@@ -1,5 +1,6 @@
import { ipcMain } from "electron";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { UserId } from "@bitwarden/common/types/guid";
@@ -37,17 +38,12 @@ export class MainBiometricsIPCListener {
}
return await this.biometricService.setBiometricProtectedUnlockKeyForUser(
message.userId as UserId,
message.key,
SymmetricCryptoKey.fromString(message.key),
);
case BiometricAction.RemoveKeyForUser:
return await this.biometricService.deleteBiometricUnlockKeyForUser(
message.userId as UserId,
);
case BiometricAction.SetClientKeyHalf:
return await this.biometricService.setClientKeyHalfForUser(
message.userId as UserId,
message.key,
);
case BiometricAction.Setup:
return await this.biometricService.setupBiometrics();

View File

@@ -1,10 +1,10 @@
import { mock, MockProxy } from "jest-mock-extended";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import {
BiometricsService,
@@ -13,6 +13,7 @@ import {
} from "@bitwarden/key-management";
import { WindowMain } from "../../main/window.main";
import { MainCryptoFunctionService } from "../../platform/main/main-crypto-function.service";
import { MainBiometricsService } from "./main-biometrics.service";
import OsBiometricsServiceLinux from "./os-biometrics-linux.service";
@@ -27,21 +28,25 @@ jest.mock("@bitwarden/desktop-napi", () => {
};
});
const unlockKey = new SymmetricCryptoKey(new Uint8Array(64));
describe("MainBiometricsService", function () {
const i18nService = mock<I18nService>();
const windowMain = mock<WindowMain>();
const logService = mock<LogService>();
const messagingService = mock<MessagingService>();
const biometricStateService = mock<BiometricStateService>();
const cryptoFunctionService = mock<MainCryptoFunctionService>();
const encryptService = mock<EncryptService>();
it("Should call the platformspecific methods", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
const mockService = mock<OsBiometricService>();
@@ -57,9 +62,10 @@ describe("MainBiometricsService", function () {
i18nService,
windowMain,
logService,
messagingService,
"win32",
biometricStateService,
encryptService,
cryptoFunctionService,
);
const internalService = (sut as any).osBiometricsService;
@@ -72,9 +78,10 @@ describe("MainBiometricsService", function () {
i18nService,
windowMain,
logService,
messagingService,
"darwin",
biometricStateService,
encryptService,
cryptoFunctionService,
);
const internalService = (sut as any).osBiometricsService;
expect(internalService).not.toBeNull();
@@ -86,9 +93,10 @@ describe("MainBiometricsService", function () {
i18nService,
windowMain,
logService,
messagingService,
"linux",
biometricStateService,
encryptService,
cryptoFunctionService,
);
const internalService = (sut as any).osBiometricsService;
@@ -106,9 +114,10 @@ describe("MainBiometricsService", function () {
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
innerService = mock();
@@ -131,9 +140,9 @@ describe("MainBiometricsService", function () {
];
for (const [supportsBiometric, needsSetup, canAutoSetup, expected] of testCases) {
innerService.osSupportsBiometric.mockResolvedValue(supportsBiometric as boolean);
innerService.osBiometricsNeedsSetup.mockResolvedValue(needsSetup as boolean);
innerService.osBiometricsCanAutoSetup.mockResolvedValue(canAutoSetup as boolean);
innerService.supportsBiometrics.mockResolvedValue(supportsBiometric as boolean);
innerService.needsSetup.mockResolvedValue(needsSetup as boolean);
innerService.canAutoSetup.mockResolvedValue(canAutoSetup as boolean);
const actual = await sut.getBiometricsStatus();
expect(actual).toBe(expected);
@@ -175,12 +184,23 @@ describe("MainBiometricsService", function () {
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(
requirePasswordOnStart as boolean,
);
(sut as any).clientKeyHalves = new Map();
const userId = "test" as UserId;
if (hasKeyHalf) {
(sut as any).clientKeyHalves.set(userId, "test");
if (!requirePasswordOnStart) {
(sut as any).osBiometricsService.getBiometricsFirstUnlockStatusForUser = jest
.fn()
.mockResolvedValue(BiometricsStatus.Available);
} else {
if (hasKeyHalf) {
(sut as any).osBiometricsService.getBiometricsFirstUnlockStatusForUser = jest
.fn()
.mockResolvedValue(BiometricsStatus.Available);
} else {
(sut as any).osBiometricsService.getBiometricsFirstUnlockStatusForUser = jest
.fn()
.mockResolvedValue(BiometricsStatus.UnlockNeeded);
}
}
const userId = "test" as UserId;
const actual = await sut.getBiometricsStatusForUser(userId);
expect(actual).toBe(expected);
}
@@ -193,50 +213,17 @@ describe("MainBiometricsService", function () {
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
const osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
await sut.setupBiometrics();
expect(osBiometricsService.osBiometricsSetup).toHaveBeenCalled();
});
});
describe("setClientKeyHalfForUser", () => {
let sut: MainBiometricsService;
beforeEach(() => {
sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
);
});
it("should set the client key half for the user", async () => {
const userId = "test" as UserId;
const keyHalf = "testKeyHalf";
await sut.setClientKeyHalfForUser(userId, keyHalf);
expect((sut as any).clientKeyHalves.has(userId)).toBe(true);
expect((sut as any).clientKeyHalves.get(userId)).toBe(keyHalf);
});
it("should reset the client key half for the user", async () => {
const userId = "test" as UserId;
await sut.setClientKeyHalfForUser(userId, null);
expect((sut as any).clientKeyHalves.has(userId)).toBe(true);
expect((sut as any).clientKeyHalves.get(userId)).toBe(null);
expect(osBiometricsService.runSetup).toHaveBeenCalled();
});
});
@@ -246,9 +233,10 @@ describe("MainBiometricsService", function () {
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
const osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
@@ -268,9 +256,10 @@ describe("MainBiometricsService", function () {
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
@@ -278,34 +267,24 @@ describe("MainBiometricsService", function () {
it("should return null if no biometric key is returned ", async () => {
const userId = "test" as UserId;
(sut as any).clientKeyHalves.set(userId, "testKeyHalf");
osBiometricsService.getBiometricKey.mockResolvedValue(null);
const userKey = await sut.unlockWithBiometricsForUser(userId);
expect(userKey).toBeNull();
expect(osBiometricsService.getBiometricKey).toHaveBeenCalledWith(
"Bitwarden_biometric",
`${userId}_user_biometric`,
"testKeyHalf",
);
expect(osBiometricsService.getBiometricKey).toHaveBeenCalledWith(userId);
});
it("should return the biometric key if a valid key is returned", async () => {
const userId = "test" as UserId;
(sut as any).clientKeyHalves.set(userId, "testKeyHalf");
const biometricKey = Utils.fromBufferToB64(new Uint8Array(64));
const biometricKey = new SymmetricCryptoKey(new Uint8Array(64));
osBiometricsService.getBiometricKey.mockResolvedValue(biometricKey);
const userKey = await sut.unlockWithBiometricsForUser(userId);
expect(userKey).not.toBeNull();
expect(userKey!.keyB64).toBe(biometricKey);
expect(userKey!.keyB64).toBe(biometricKey.toBase64());
expect(userKey!.inner().type).toBe(EncryptionType.AesCbc256_HmacSha256_B64);
expect(osBiometricsService.getBiometricKey).toHaveBeenCalledWith(
"Bitwarden_biometric",
`${userId}_user_biometric`,
"testKeyHalf",
);
expect(osBiometricsService.getBiometricKey).toHaveBeenCalledWith(userId);
});
});
@@ -318,37 +297,21 @@ describe("MainBiometricsService", function () {
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
});
it("should throw an error if no client key half is provided", async () => {
const userId = "test" as UserId;
const unlockKey = "testUnlockKey";
await expect(sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey)).rejects.toThrow(
"No client key half provided for user",
);
});
it("should call the platform specific setBiometricKey method", async () => {
const userId = "test" as UserId;
const unlockKey = "testUnlockKey";
(sut as any).clientKeyHalves.set(userId, "testKeyHalf");
await sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey);
expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith(
"Bitwarden_biometric",
`${userId}_user_biometric`,
unlockKey,
"testKeyHalf",
);
expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith(userId, unlockKey);
});
});
@@ -358,9 +321,10 @@ describe("MainBiometricsService", function () {
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
const osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
@@ -369,10 +333,7 @@ describe("MainBiometricsService", function () {
await sut.deleteBiometricUnlockKeyForUser(userId);
expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith(
"Bitwarden_biometric",
`${userId}_user_biometric`,
);
expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith(userId);
});
});
@@ -384,9 +345,10 @@ describe("MainBiometricsService", function () {
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
});
@@ -413,9 +375,10 @@ describe("MainBiometricsService", function () {
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
const shouldAutoPrompt = await sut.getShouldAutopromptNow();

View File

@@ -1,6 +1,7 @@
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
@@ -13,16 +14,16 @@ import { OsBiometricService } from "./os-biometrics.service";
export class MainBiometricsService extends DesktopBiometricsService {
private osBiometricsService: OsBiometricService;
private clientKeyHalves = new Map<string, string | null>();
private shouldAutoPrompt = true;
constructor(
private i18nService: I18nService,
private windowMain: WindowMain,
private logService: LogService,
private messagingService: MessagingService,
private platform: NodeJS.Platform,
platform: NodeJS.Platform,
private biometricStateService: BiometricStateService,
private encryptService: EncryptService,
private cryptoFunctionService: CryptoFunctionService,
) {
super();
if (platform === "win32") {
@@ -32,6 +33,9 @@ export class MainBiometricsService extends DesktopBiometricsService {
this.i18nService,
this.windowMain,
this.logService,
this.biometricStateService,
this.encryptService,
this.cryptoFunctionService,
);
} else if (platform === "darwin") {
// eslint-disable-next-line
@@ -40,7 +44,11 @@ export class MainBiometricsService extends DesktopBiometricsService {
} else if (platform === "linux") {
// eslint-disable-next-line
const OsBiometricsServiceLinux = require("./os-biometrics-linux.service").default;
this.osBiometricsService = new OsBiometricsServiceLinux(this.i18nService, this.windowMain);
this.osBiometricsService = new OsBiometricsServiceLinux(
this.biometricStateService,
this.encryptService,
this.cryptoFunctionService,
);
} else {
throw new Error("Unsupported platform");
}
@@ -55,11 +63,11 @@ export class MainBiometricsService extends DesktopBiometricsService {
* @returns the status of the biometrics of the platform
*/
async getBiometricsStatus(): Promise<BiometricsStatus> {
if (!(await this.osBiometricsService.osSupportsBiometric())) {
if (!(await this.osBiometricsService.supportsBiometrics())) {
return BiometricsStatus.HardwareUnavailable;
} else {
if (await this.osBiometricsService.osBiometricsNeedsSetup()) {
if (await this.osBiometricsService.osBiometricsCanAutoSetup()) {
if (await this.osBiometricsService.needsSetup()) {
if (await this.osBiometricsService.canAutoSetup()) {
return BiometricsStatus.AutoSetupNeeded;
} else {
return BiometricsStatus.ManualSetupNeeded;
@@ -80,20 +88,12 @@ export class MainBiometricsService extends DesktopBiometricsService {
if (!(await this.biometricStateService.getBiometricUnlockEnabled(userId))) {
return BiometricsStatus.NotEnabledLocally;
}
const platformStatus = await this.getBiometricsStatus();
if (!(platformStatus === BiometricsStatus.Available)) {
return platformStatus;
}
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
const clientKeyHalfB64 = this.clientKeyHalves.get(userId);
const clientKeyHalfSatisfied = !requireClientKeyHalf || !!clientKeyHalfB64;
if (!clientKeyHalfSatisfied) {
return BiometricsStatus.UnlockNeeded;
}
return BiometricsStatus.Available;
return await this.osBiometricsService.getBiometricsFirstUnlockStatusForUser(userId);
}
async authenticateBiometric(): Promise<boolean> {
@@ -101,11 +101,7 @@ export class MainBiometricsService extends DesktopBiometricsService {
}
async setupBiometrics(): Promise<void> {
return await this.osBiometricsService.osBiometricsSetup();
}
async setClientKeyHalfForUser(userId: UserId, value: string | null): Promise<void> {
this.clientKeyHalves.set(userId, value);
return await this.osBiometricsService.runSetup();
}
async authenticateWithBiometrics(): Promise<boolean> {
@@ -113,43 +109,23 @@ export class MainBiometricsService extends DesktopBiometricsService {
}
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
const biometricKey = await this.osBiometricsService.getBiometricKey(
"Bitwarden_biometric",
`${userId}_user_biometric`,
this.clientKeyHalves.get(userId) ?? undefined,
);
if (biometricKey == null) {
return null;
}
return SymmetricCryptoKey.fromString(biometricKey) as UserKey;
return (await this.osBiometricsService.getBiometricKey(userId)) as UserKey;
}
async setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise<void> {
const service = "Bitwarden_biometric";
const storageKey = `${userId}_user_biometric`;
if (!this.clientKeyHalves.has(userId)) {
throw new Error("No client key half provided for user");
}
return await this.osBiometricsService.setBiometricKey(
service,
storageKey,
value,
this.clientKeyHalves.get(userId) ?? undefined,
);
async setBiometricProtectedUnlockKeyForUser(
userId: UserId,
key: SymmetricCryptoKey,
): Promise<void> {
return await this.osBiometricsService.setBiometricKey(userId, key);
}
async deleteBiometricUnlockKeyForUser(userId: UserId): Promise<void> {
return await this.osBiometricsService.deleteBiometricKey(
"Bitwarden_biometric",
`${userId}_user_biometric`,
);
return await this.osBiometricsService.deleteBiometricKey(userId);
}
/**
* Set whether to auto-prompt the user for biometric unlock; this can be used to prevent auto-prompting being initiated by a process reload.
* Reasons for enabling auto prompt include: Starting the app, un-minimizing the app, manually account switching
* Reasons for enabling auto-prompt include: Starting the app, un-minimizing the app, manually account switching
* @param value Whether to auto-prompt the user for biometric unlock
*/
async setShouldAutopromptNow(value: boolean): Promise<void> {

View File

@@ -1,10 +1,14 @@
import { spawn } from "child_process";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { biometrics, passwords } from "@bitwarden/desktop-napi";
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
import { WindowMain } from "../../main/window.main";
import { isFlatpak, isLinux, isSnapStore } from "../../utils";
import { OsBiometricService } from "./os-biometrics.service";
@@ -28,59 +32,62 @@ const polkitPolicy = `<?xml version="1.0" encoding="UTF-8"?>
const policyFileName = "com.bitwarden.Bitwarden.policy";
const policyPath = "/usr/share/polkit-1/actions/";
const SERVICE = "Bitwarden_biometric";
function getLookupKeyForUser(userId: UserId): string {
return `${userId}_user_biometric`;
}
export default class OsBiometricsServiceLinux implements OsBiometricService {
constructor(
private i18nservice: I18nService,
private windowMain: WindowMain,
private biometricStateService: BiometricStateService,
private encryptService: EncryptService,
private cryptoFunctionService: CryptoFunctionService,
) {}
private _iv: string | null = null;
// Use getKeyMaterial helper instead of direct access
private _osKeyHalf: string | null = null;
private clientKeyHalves = new Map<UserId, Uint8Array | null>();
async setBiometricKey(
service: string,
key: string,
value: string,
clientKeyPartB64: string | undefined,
): Promise<void> {
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
const clientKeyPartB64 = Utils.fromBufferToB64(
await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key),
);
const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 });
await biometrics.setBiometricSecret(
service,
key,
value,
SERVICE,
getLookupKeyForUser(userId),
key.toBase64(),
storageDetails.key_material,
storageDetails.ivB64,
);
}
async deleteBiometricKey(service: string, key: string): Promise<void> {
await passwords.deletePassword(service, key);
async deleteBiometricKey(userId: UserId): Promise<void> {
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId));
}
async getBiometricKey(
service: string,
storageKey: string,
clientKeyPartB64: string | undefined,
): Promise<string | null> {
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
const success = await this.authenticateBiometric();
if (!success) {
throw new Error("Biometric authentication failed");
}
const value = await passwords.getPassword(service, storageKey);
const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId));
if (value == null || value == "") {
return null;
} else {
const clientKeyHalf = this.clientKeyHalves.get(userId);
const clientKeyPartB64 = Utils.fromBufferToB64(clientKeyHalf);
const encValue = new EncString(value);
this.setIv(encValue.iv);
const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 });
const storedValue = await biometrics.getBiometricSecret(
service,
storageKey,
SERVICE,
getLookupKeyForUser(userId),
storageDetails.key_material,
);
return storedValue;
return SymmetricCryptoKey.fromString(storedValue);
}
}
@@ -89,7 +96,7 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
return await biometrics.prompt(hwnd, "");
}
async osSupportsBiometric(): Promise<boolean> {
async supportsBiometrics(): Promise<boolean> {
// We assume all linux distros have some polkit implementation
// that either has bitwarden set up or not, which is reflected in osBiomtricsNeedsSetup.
// Snap does not have access at the moment to polkit
@@ -99,7 +106,7 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
return await passwords.isAvailable();
}
async osBiometricsNeedsSetup(): Promise<boolean> {
async needsSetup(): Promise<boolean> {
if (isSnapStore()) {
return false;
}
@@ -108,7 +115,7 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
return !(await biometrics.available());
}
async osBiometricsCanAutoSetup(): Promise<boolean> {
async canAutoSetup(): Promise<boolean> {
// We cannot auto setup on snap or flatpak since the filesystem is sandboxed.
// The user needs to manually set up the polkit policy outside of the sandbox
// since we allow access to polkit via dbus for the sandboxed clients, the authentication works from
@@ -116,7 +123,7 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
return isLinux() && !isSnapStore() && !isFlatpak();
}
async osBiometricsSetup(): Promise<void> {
async runSetup(): Promise<void> {
const process = spawn("pkexec", [
"bash",
"-c",
@@ -165,4 +172,46 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
ivB64: this._iv,
};
}
private async getOrCreateBiometricEncryptionClientKeyHalf(
userId: UserId,
key: SymmetricCryptoKey,
): Promise<Uint8Array | null> {
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
if (!requireClientKeyHalf) {
return null;
}
if (this.clientKeyHalves.has(userId)) {
return this.clientKeyHalves.get(userId) || null;
}
// Retrieve existing key half if it exists
let clientKeyHalf: Uint8Array | null = null;
const encryptedClientKeyHalf =
await this.biometricStateService.getEncryptedClientKeyHalf(userId);
if (encryptedClientKeyHalf != null) {
clientKeyHalf = await this.encryptService.decryptBytes(encryptedClientKeyHalf, key);
}
if (clientKeyHalf == null) {
// Set a key half if it doesn't exist
const keyBytes = await this.cryptoFunctionService.randomBytes(32);
const encKey = await this.encryptService.encryptBytes(keyBytes, key);
await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId);
}
this.clientKeyHalves.set(userId, clientKeyHalf);
return clientKeyHalf;
}
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
const clientKeyHalfB64 = this.clientKeyHalves.get(userId);
const clientKeyHalfSatisfied = !requireClientKeyHalf || !!clientKeyHalfB64;
if (!clientKeyHalfSatisfied) {
return BiometricsStatus.UnlockNeeded;
}
return BiometricsStatus.Available;
}
}

View File

@@ -1,14 +1,22 @@
import { systemPreferences } from "electron";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { passwords } from "@bitwarden/desktop-napi";
import { BiometricsStatus } from "@bitwarden/key-management";
import { OsBiometricService } from "./os-biometrics.service";
const SERVICE = "Bitwarden_biometric";
function getLookupKeyForUser(userId: UserId): string {
return `${userId}_user_biometric`;
}
export default class OsBiometricsServiceMac implements OsBiometricService {
constructor(private i18nservice: I18nService) {}
async osSupportsBiometric(): Promise<boolean> {
async supportsBiometrics(): Promise<boolean> {
return systemPreferences.canPromptTouchID();
}
@@ -21,44 +29,52 @@ export default class OsBiometricsServiceMac implements OsBiometricService {
}
}
async getBiometricKey(service: string, key: string): Promise<string | null> {
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
const success = await this.authenticateBiometric();
if (!success) {
throw new Error("Biometric authentication failed");
}
const keyB64 = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId));
if (keyB64 == null) {
return null;
}
return await passwords.getPassword(service, key);
return SymmetricCryptoKey.fromString(keyB64);
}
async setBiometricKey(service: string, key: string, value: string): Promise<void> {
if (await this.valueUpToDate(service, key, value)) {
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
if (await this.valueUpToDate(userId, key)) {
return;
}
return await passwords.setPassword(service, key, value);
return await passwords.setPassword(SERVICE, getLookupKeyForUser(userId), key.toBase64());
}
async deleteBiometricKey(service: string, key: string): Promise<void> {
return await passwords.deletePassword(service, key);
async deleteBiometricKey(user: UserId): Promise<void> {
return await passwords.deletePassword(SERVICE, getLookupKeyForUser(user));
}
private async valueUpToDate(service: string, key: string, value: string): Promise<boolean> {
private async valueUpToDate(user: UserId, key: SymmetricCryptoKey): Promise<boolean> {
try {
const existing = await passwords.getPassword(service, key);
return existing === value;
const existing = await passwords.getPassword(SERVICE, getLookupKeyForUser(user));
return existing === key.toBase64();
} catch {
return false;
}
}
async osBiometricsNeedsSetup() {
async needsSetup() {
return false;
}
async osBiometricsCanAutoSetup(): Promise<boolean> {
async canAutoSetup(): Promise<boolean> {
return false;
}
async osBiometricsSetup(): Promise<void> {}
async runSetup(): Promise<void> {}
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
return BiometricsStatus.Available;
}
}

View File

@@ -0,0 +1,143 @@
import { mock } from "jest-mock-extended";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
jest.mock("@bitwarden/desktop-napi", () => ({
biometrics: {
available: jest.fn(),
setBiometricSecret: jest.fn(),
getBiometricSecret: jest.fn(),
deriveKeyMaterial: jest.fn(),
prompt: jest.fn(),
},
passwords: {
getPassword: jest.fn(),
deletePassword: jest.fn(),
},
}));
describe("OsBiometricsServiceWindows", () => {
let service: OsBiometricsServiceWindows;
let biometricStateService: BiometricStateService;
beforeEach(() => {
const i18nService = mock<I18nService>();
const logService = mock<LogService>();
biometricStateService = mock<BiometricStateService>();
const encryptionService = mock<EncryptService>();
const cryptoFunctionService = mock<CryptoFunctionService>();
service = new OsBiometricsServiceWindows(
i18nService,
null,
logService,
biometricStateService,
encryptionService,
cryptoFunctionService,
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("getBiometricsFirstUnlockStatusForUser", () => {
const userId = "test-user-id" as UserId;
it("should return Available when requirePasswordOnRestart is false", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(false);
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.Available);
});
it("should return Available when requirePasswordOnRestart is true and client key half is set", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true);
(service as any).clientKeyHalves = new Map<string, Uint8Array>();
(service as any).clientKeyHalves.set(userId, new Uint8Array([1, 2, 3, 4]));
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.Available);
});
it("should return UnlockNeeded when requirePasswordOnRestart is true and client key half is not set", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true);
(service as any).clientKeyHalves = new Map<string, Uint8Array>();
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.UnlockNeeded);
});
});
describe("getOrCreateBiometricEncryptionClientKeyHalf", () => {
const userId = "test-user-id" as UserId;
const key = new SymmetricCryptoKey(new Uint8Array(64));
let encryptionService: EncryptService;
let cryptoFunctionService: CryptoFunctionService;
beforeEach(() => {
encryptionService = mock<EncryptService>();
cryptoFunctionService = mock<CryptoFunctionService>();
service = new OsBiometricsServiceWindows(
mock<I18nService>(),
null,
mock<LogService>(),
biometricStateService,
encryptionService,
cryptoFunctionService,
);
});
it("should return null if getRequirePasswordOnRestart is false", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(false);
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
expect(result).toBeNull();
});
it("should return cached key half if already present", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true);
const cachedKeyHalf = new Uint8Array([10, 20, 30]);
(service as any).clientKeyHalves.set(userId.toString(), cachedKeyHalf);
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
expect(result).toBe(cachedKeyHalf);
});
it("should decrypt and return existing encrypted client key half", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true);
biometricStateService.getEncryptedClientKeyHalf = jest
.fn()
.mockResolvedValue(new Uint8Array([1, 2, 3]));
const decrypted = new Uint8Array([4, 5, 6]);
encryptionService.decryptBytes = jest.fn().mockResolvedValue(decrypted);
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
expect(biometricStateService.getEncryptedClientKeyHalf).toHaveBeenCalledWith(userId);
expect(encryptionService.decryptBytes).toHaveBeenCalledWith(new Uint8Array([1, 2, 3]), key);
expect(result).toEqual(decrypted);
expect((service as any).clientKeyHalves.get(userId.toString())).toEqual(decrypted);
});
it("should generate, encrypt, store, and cache a new key half if none exists", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true);
biometricStateService.getEncryptedClientKeyHalf = jest.fn().mockResolvedValue(null);
const randomBytes = new Uint8Array([7, 8, 9]);
cryptoFunctionService.randomBytes = jest.fn().mockResolvedValue(randomBytes);
const encrypted = new Uint8Array([10, 11, 12]);
encryptionService.encryptBytes = jest.fn().mockResolvedValue(encrypted);
biometricStateService.setEncryptedClientKeyHalf = jest.fn().mockResolvedValue(undefined);
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
expect(cryptoFunctionService.randomBytes).toHaveBeenCalledWith(32);
expect(encryptionService.encryptBytes).toHaveBeenCalledWith(randomBytes, key);
expect(biometricStateService.setEncryptedClientKeyHalf).toHaveBeenCalledWith(
encrypted,
userId,
);
expect(result).toBeNull();
expect((service as any).clientKeyHalves.get(userId.toString())).toBeNull();
});
});
});

View File

@@ -1,10 +1,14 @@
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { biometrics, passwords } from "@bitwarden/desktop-napi";
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
import { WindowMain } from "../../main/window.main";
@@ -13,87 +17,107 @@ import { OsBiometricService } from "./os-biometrics.service";
const KEY_WITNESS_SUFFIX = "_witness";
const WITNESS_VALUE = "known key";
const SERVICE = "Bitwarden_biometric";
function getLookupKeyForUser(userId: UserId): string {
return `${userId}_user_biometric`;
}
export default class OsBiometricsServiceWindows implements OsBiometricService {
// Use set helper method instead of direct access
private _iv: string | null = null;
// Use getKeyMaterial helper instead of direct access
private _osKeyHalf: string | null = null;
private clientKeyHalves = new Map<UserId, Uint8Array>();
constructor(
private i18nService: I18nService,
private windowMain: WindowMain,
private logService: LogService,
private biometricStateService: BiometricStateService,
private encryptService: EncryptService,
private cryptoFunctionService: CryptoFunctionService,
) {}
async osSupportsBiometric(): Promise<boolean> {
async supportsBiometrics(): Promise<boolean> {
return await biometrics.available();
}
async getBiometricKey(
service: string,
storageKey: string,
clientKeyHalfB64: string,
): Promise<string | null> {
const value = await passwords.getPassword(service, storageKey);
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId));
let clientKeyHalfB64: string | null = null;
if (this.clientKeyHalves.has(userId)) {
clientKeyHalfB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId));
}
if (value == null || value == "") {
return null;
} else if (!EncString.isSerializedEncString(value)) {
// Update to format encrypted with client key half
const storageDetails = await this.getStorageDetails({
clientKeyHalfB64,
clientKeyHalfB64: clientKeyHalfB64,
});
await biometrics.setBiometricSecret(
service,
storageKey,
SERVICE,
getLookupKeyForUser(userId),
value,
storageDetails.key_material,
storageDetails.ivB64,
);
return value;
return SymmetricCryptoKey.fromString(value);
} else {
const encValue = new EncString(value);
this.setIv(encValue.iv);
const storageDetails = await this.getStorageDetails({
clientKeyHalfB64,
clientKeyHalfB64: clientKeyHalfB64,
});
return await biometrics.getBiometricSecret(service, storageKey, storageDetails.key_material);
return SymmetricCryptoKey.fromString(
await biometrics.getBiometricSecret(
SERVICE,
getLookupKeyForUser(userId),
storageDetails.key_material,
),
);
}
}
async setBiometricKey(
service: string,
storageKey: string,
value: string,
clientKeyPartB64: string | undefined,
): Promise<void> {
const parsedValue = SymmetricCryptoKey.fromString(value);
if (await this.valueUpToDate({ value: parsedValue, clientKeyPartB64, service, storageKey })) {
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
const clientKeyHalf = await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
if (
await this.valueUpToDate({
value: key,
clientKeyPartB64: Utils.fromBufferToB64(clientKeyHalf),
service: SERVICE,
storageKey: getLookupKeyForUser(userId),
})
) {
return;
}
const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 });
const storageDetails = await this.getStorageDetails({
clientKeyHalfB64: Utils.fromBufferToB64(clientKeyHalf),
});
const storedValue = await biometrics.setBiometricSecret(
service,
storageKey,
value,
SERVICE,
getLookupKeyForUser(userId),
key.toBase64(),
storageDetails.key_material,
storageDetails.ivB64,
);
const parsedStoredValue = new EncString(storedValue);
await this.storeValueWitness(
parsedValue,
key,
parsedStoredValue,
service,
storageKey,
clientKeyPartB64,
SERVICE,
getLookupKeyForUser(userId),
Utils.fromBufferToB64(clientKeyHalf),
);
}
async deleteBiometricKey(service: string, key: string): Promise<void> {
await passwords.deletePassword(service, key);
await passwords.deletePassword(service, key + KEY_WITNESS_SUFFIX);
async deleteBiometricKey(userId: UserId): Promise<void> {
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId));
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId) + KEY_WITNESS_SUFFIX);
}
async authenticateBiometric(): Promise<boolean> {
@@ -240,13 +264,58 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
return result;
}
async osBiometricsNeedsSetup() {
async needsSetup() {
return false;
}
async osBiometricsCanAutoSetup(): Promise<boolean> {
async canAutoSetup(): Promise<boolean> {
return false;
}
async osBiometricsSetup(): Promise<void> {}
async runSetup(): Promise<void> {}
async getOrCreateBiometricEncryptionClientKeyHalf(
userId: UserId,
key: SymmetricCryptoKey,
): Promise<Uint8Array | null> {
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
if (!requireClientKeyHalf) {
return null;
}
if (this.clientKeyHalves.has(userId)) {
return this.clientKeyHalves.get(userId);
}
// Retrieve existing key half if it exists
let clientKeyHalf: Uint8Array | null = null;
const encryptedClientKeyHalf =
await this.biometricStateService.getEncryptedClientKeyHalf(userId);
if (encryptedClientKeyHalf != null) {
clientKeyHalf = await this.encryptService.decryptBytes(encryptedClientKeyHalf, key);
}
if (clientKeyHalf == null) {
// Set a key half if it doesn't exist
const keyBytes = await this.cryptoFunctionService.randomBytes(32);
const encKey = await this.encryptService.encryptBytes(keyBytes, key);
await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId);
}
this.clientKeyHalves.set(userId, clientKeyHalf);
return clientKeyHalf;
}
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
if (!requireClientKeyHalf) {
return BiometricsStatus.Available;
}
if (this.clientKeyHalves.has(userId)) {
return BiometricsStatus.Available;
} else {
return BiometricsStatus.UnlockNeeded;
}
}
}

View File

@@ -1,32 +1,28 @@
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricsStatus } from "@bitwarden/key-management";
export interface OsBiometricService {
osSupportsBiometric(): Promise<boolean>;
supportsBiometrics(): Promise<boolean>;
/**
* Check whether support for biometric unlock requires setup. This can be automatic or manual.
*
* @returns true if biometrics support requires setup, false if it does not (is already setup, or did not require it in the first place)
*/
osBiometricsNeedsSetup: () => Promise<boolean>;
needsSetup(): Promise<boolean>;
/**
* Check whether biometrics can be automatically setup, or requires user interaction.
*
* @returns true if biometrics support can be automatically setup, false if it requires user interaction.
*/
osBiometricsCanAutoSetup: () => Promise<boolean>;
canAutoSetup(): Promise<boolean>;
/**
* Starts automatic biometric setup, which places the required configuration files / changes the required settings.
*/
osBiometricsSetup: () => Promise<void>;
runSetup(): Promise<void>;
authenticateBiometric(): Promise<boolean>;
getBiometricKey(
service: string,
key: string,
clientKeyHalfB64: string | undefined,
): Promise<string | null>;
setBiometricKey(
service: string,
key: string,
value: string,
clientKeyHalfB64: string | undefined,
): Promise<void>;
deleteBiometricKey(service: string, key: string): Promise<void>;
getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null>;
setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void>;
deleteBiometricKey(userId: UserId): Promise<void>;
getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus>;
}

View File

@@ -34,8 +34,14 @@ export class RendererBiometricsService extends DesktopBiometricsService {
return await ipc.keyManagement.biometric.getBiometricsStatusForUser(id);
}
async setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise<void> {
return await ipc.keyManagement.biometric.setBiometricProtectedUnlockKeyForUser(userId, value);
async setBiometricProtectedUnlockKeyForUser(
userId: UserId,
value: SymmetricCryptoKey,
): Promise<void> {
return await ipc.keyManagement.biometric.setBiometricProtectedUnlockKeyForUser(
userId,
value.toBase64(),
);
}
async deleteBiometricUnlockKeyForUser(userId: UserId): Promise<void> {
@@ -46,10 +52,6 @@ export class RendererBiometricsService extends DesktopBiometricsService {
return await ipc.keyManagement.biometric.setupBiometrics();
}
async setClientKeyHalfForUser(userId: UserId, value: string | null): Promise<void> {
return await ipc.keyManagement.biometric.setClientKeyHalf(userId, value);
}
async getShouldAutopromptNow(): Promise<boolean> {
return await ipc.keyManagement.biometric.getShouldAutoprompt();
}

View File

@@ -9,14 +9,11 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { BiometricStateService, KdfConfigService } from "@bitwarden/key-management";
import {
makeEncString,
makeStaticByteArray,
makeSymmetricCryptoKey,
FakeAccountService,
mockAccountServiceWith,
@@ -80,7 +77,6 @@ describe("ElectronKeyService", () => {
await keyService.setUserKey(userKey, mockUserId);
expect(biometricService.setClientKeyHalfForUser).not.toHaveBeenCalled();
expect(biometricService.setBiometricProtectedUnlockKeyForUser).not.toHaveBeenCalled();
expect(biometricStateService.setEncryptedClientKeyHalf).not.toHaveBeenCalled();
expect(biometricStateService.getBiometricUnlockEnabled).toHaveBeenCalledWith(mockUserId);
@@ -96,14 +92,12 @@ describe("ElectronKeyService", () => {
await keyService.setUserKey(userKey, mockUserId);
expect(biometricService.setClientKeyHalfForUser).toHaveBeenCalledWith(mockUserId, null);
expect(biometricService.setBiometricProtectedUnlockKeyForUser).toHaveBeenCalledWith(
mockUserId,
userKey.keyB64,
userKey,
);
expect(biometricStateService.setEncryptedClientKeyHalf).not.toHaveBeenCalled();
expect(biometricStateService.getBiometricUnlockEnabled).toHaveBeenCalledWith(mockUserId);
expect(biometricStateService.getRequirePasswordOnStart).toHaveBeenCalledWith(mockUserId);
});
describe("require password on start enabled", () => {
@@ -111,73 +105,11 @@ describe("ElectronKeyService", () => {
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(true);
});
it("sets new biometric client key half and biometric unlock key when no biometric client key half stored", async () => {
const clientKeyHalfBytes = makeStaticByteArray(32);
const clientKeyHalf = Utils.fromBufferToUtf8(clientKeyHalfBytes);
const encryptedClientKeyHalf = makeEncString();
biometricStateService.getEncryptedClientKeyHalf.mockResolvedValue(null);
cryptoFunctionService.randomBytes.mockResolvedValue(
clientKeyHalfBytes.buffer as CsprngArray,
);
encryptService.encryptString.mockResolvedValue(encryptedClientKeyHalf);
it("sets biometric key", async () => {
await keyService.setUserKey(userKey, mockUserId);
expect(biometricService.setClientKeyHalfForUser).toHaveBeenCalledWith(
mockUserId,
clientKeyHalf,
);
expect(biometricService.setBiometricProtectedUnlockKeyForUser).toHaveBeenCalledWith(
mockUserId,
userKey.keyB64,
);
expect(biometricStateService.setEncryptedClientKeyHalf).toHaveBeenCalledWith(
encryptedClientKeyHalf,
mockUserId,
);
expect(biometricStateService.getBiometricUnlockEnabled).toHaveBeenCalledWith(
mockUserId,
);
expect(biometricStateService.getRequirePasswordOnStart).toHaveBeenCalledWith(
mockUserId,
);
expect(biometricStateService.getEncryptedClientKeyHalf).toHaveBeenCalledWith(
mockUserId,
);
expect(cryptoFunctionService.randomBytes).toHaveBeenCalledWith(32);
expect(encryptService.encryptString).toHaveBeenCalledWith(clientKeyHalf, userKey);
});
it("sets decrypted biometric client key half and biometric unlock key when existing biometric client key half stored", async () => {
const encryptedClientKeyHalf = makeEncString();
const clientKeyHalf = Utils.fromBufferToUtf8(makeStaticByteArray(32));
biometricStateService.getEncryptedClientKeyHalf.mockResolvedValue(
encryptedClientKeyHalf,
);
encryptService.decryptString.mockResolvedValue(clientKeyHalf);
await keyService.setUserKey(userKey, mockUserId);
expect(biometricService.setClientKeyHalfForUser).toHaveBeenCalledWith(
mockUserId,
clientKeyHalf,
);
expect(biometricService.setBiometricProtectedUnlockKeyForUser).toHaveBeenCalledWith(
mockUserId,
userKey.keyB64,
);
expect(biometricStateService.setEncryptedClientKeyHalf).not.toHaveBeenCalled();
expect(biometricStateService.getBiometricUnlockEnabled).toHaveBeenCalledWith(
mockUserId,
);
expect(biometricStateService.getRequirePasswordOnStart).toHaveBeenCalledWith(
mockUserId,
);
expect(biometricStateService.getEncryptedClientKeyHalf).toHaveBeenCalledWith(
mockUserId,
);
expect(encryptService.decryptString).toHaveBeenCalledWith(
encryptedClientKeyHalf,
userKey,
);
});

View File

@@ -8,9 +8,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
import { CsprngString } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import {
@@ -77,10 +75,7 @@ export class ElectronKeyService extends DefaultKeyService {
}
private async storeBiometricsProtectedUserKey(userKey: UserKey, userId: UserId): Promise<void> {
// May resolve to null, in which case no client key have is required
const clientEncKeyHalf = await this.getBiometricEncryptionClientKeyHalf(userKey, userId);
await this.biometricService.setClientKeyHalfForUser(userId, clientEncKeyHalf);
await this.biometricService.setBiometricProtectedUnlockKeyForUser(userId, userKey.keyB64);
await this.biometricService.setBiometricProtectedUnlockKeyForUser(userId, userKey);
}
protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId: UserId): Promise<boolean> {
@@ -91,34 +86,4 @@ export class ElectronKeyService extends DefaultKeyService {
await this.biometricService.deleteBiometricUnlockKeyForUser(userId);
await super.clearAllStoredUserKeys(userId);
}
private async getBiometricEncryptionClientKeyHalf(
userKey: UserKey,
userId: UserId,
): Promise<CsprngString | null> {
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
if (!requireClientKeyHalf) {
return null;
}
// Retrieve existing key half if it exists
let clientKeyHalf: CsprngString | null = null;
const encryptedClientKeyHalf =
await this.biometricStateService.getEncryptedClientKeyHalf(userId);
if (encryptedClientKeyHalf != null) {
clientKeyHalf = (await this.encryptService.decryptString(
encryptedClientKeyHalf,
userKey,
)) as CsprngString;
}
if (clientKeyHalf == null) {
// Set a key half if it doesn't exist
const keyBytes = await this.cryptoFunctionService.randomBytes(32);
clientKeyHalf = Utils.fromBufferToUtf8(keyBytes) as CsprngString;
const encKey = await this.encryptService.encryptString(clientKeyHalf, userKey);
await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId);
}
return clientKeyHalf;
}
}

View File

@@ -25,12 +25,13 @@ const biometric = {
action: BiometricAction.GetStatusForUser,
userId: userId,
} satisfies BiometricMessage),
setBiometricProtectedUnlockKeyForUser: (userId: string, value: string): Promise<void> =>
ipcRenderer.invoke("biometric", {
setBiometricProtectedUnlockKeyForUser: (userId: string, keyB64: string): Promise<void> => {
return ipcRenderer.invoke("biometric", {
action: BiometricAction.SetKeyForUser,
userId: userId,
key: value,
} satisfies BiometricMessage),
key: keyB64,
} satisfies BiometricMessage);
},
deleteBiometricUnlockKeyForUser: (userId: string): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.RemoveKeyForUser,
@@ -40,12 +41,6 @@ const biometric = {
ipcRenderer.invoke("biometric", {
action: BiometricAction.Setup,
} satisfies BiometricMessage),
setClientKeyHalf: (userId: string, value: string | null): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.SetClientKeyHalf,
userId: userId,
key: value,
} satisfies BiometricMessage),
getShouldAutoprompt: (): Promise<boolean> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.GetShouldAutoprompt,

View File

@@ -10,6 +10,7 @@ import { Subject, firstValueFrom } from "rxjs";
import { SsoUrlService } from "@bitwarden/auth/common";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { ClientType } from "@bitwarden/common/enums";
import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation";
import { RegionConfig } from "@bitwarden/common/platform/abstractions/environment.service";
import { Message, MessageSender } from "@bitwarden/common/platform/messaging";
// eslint-disable-next-line no-restricted-imports -- For dependency creation
@@ -187,14 +188,19 @@ export class Main {
this.desktopSettingsService = new DesktopSettingsService(stateProvider);
const biometricStateService = new DefaultBiometricStateService(stateProvider);
const encryptService = new EncryptServiceImplementation(
this.mainCryptoFunctionService,
this.logService,
true,
);
this.biometricsService = new MainBiometricsService(
this.i18nService,
this.windowMain,
this.logService,
this.messagingService,
process.platform,
biometricStateService,
encryptService,
this.mainCryptoFunctionService,
);
this.windowMain = new WindowMain(

View File

@@ -1,12 +1,12 @@
{
"name": "@bitwarden/desktop",
"version": "2025.6.0",
"version": "2025.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@bitwarden/desktop",
"version": "2025.6.0",
"version": "2025.6.1",
"license": "GPL-3.0",
"dependencies": {
"@bitwarden/desktop-napi": "file:../desktop_native/napi"

View File

@@ -2,7 +2,7 @@
"name": "@bitwarden/desktop",
"productName": "Bitwarden",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.6.0",
"version": "2025.6.1",
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"license": "GPL-3.0",

View File

@@ -9,8 +9,6 @@ export enum BiometricAction {
SetKeyForUser = "setKeyForUser",
RemoveKeyForUser = "removeKeyForUser",
SetClientKeyHalf = "setClientKeyHalf",
Setup = "setup",
GetShouldAutoprompt = "getShouldAutoprompt",
@@ -18,21 +16,13 @@ export enum BiometricAction {
}
export type BiometricMessage =
| {
action: BiometricAction.SetClientKeyHalf;
userId: string;
key: string | null;
}
| {
action: BiometricAction.SetKeyForUser;
userId: string;
key: string;
}
| {
action: Exclude<
BiometricAction,
BiometricAction.SetClientKeyHalf | BiometricAction.SetKeyForUser
>;
action: Exclude<BiometricAction, BiometricAction.SetKeyForUser>;
userId?: string;
data?: any;
};

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/web-vault",
"version": "2025.6.0",
"version": "2025.6.1",
"scripts": {
"build:oss": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",
"build:bit": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-web/webpack.config.js",

View File

@@ -22,7 +22,7 @@
<p *ngIf="!dataSource.filteredData.length">{{ "noGroupsInList" | i18n }}</p>
<!-- The padding on the bottom of the cdk-virtual-scroll-viewport element is required to prevent table row content
from overflowing the <main> element. -->
<cdk-virtual-scroll-viewport scrollWindow [itemSize]="rowHeight" class="tw-pb-8">
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
<bit-table *ngIf="dataSource.filteredData.length" [dataSource]="dataSource">
<ng-container header>
<tr>

View File

@@ -75,7 +75,7 @@
</bit-callout>
<!-- The padding on the bottom of the cdk-virtual-scroll-viewport element is required to prevent table row content
from overflowing the <main> element. -->
<cdk-virtual-scroll-viewport scrollWindow [itemSize]="rowHeight" class="tw-pb-8">
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
<bit-table [dataSource]="dataSource">
<ng-container header>
<tr>

View File

@@ -3,6 +3,7 @@ import { NgModule } from "@angular/core";
import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component";
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
import { ScrollLayoutDirective } from "@bitwarden/components";
import { LooseComponentsModule } from "../../../shared";
import { SharedOrganizationModule } from "../shared";
@@ -27,6 +28,7 @@ import { MembersComponent } from "./members.component";
PasswordCalloutComponent,
ScrollingModule,
PasswordStrengthV2Component,
ScrollLayoutDirective,
],
declarations: [
BulkConfirmDialogComponent,

View File

@@ -1,6 +1,8 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { NgModule } from "@angular/core";
import { ScrollLayoutDirective } from "@bitwarden/components";
import { LooseComponentsModule } from "../../shared";
import { CoreOrganizationModule } from "./core";
@@ -18,6 +20,7 @@ import { AccessSelectorModule } from "./shared/components/access-selector";
OrganizationsRoutingModule,
LooseComponentsModule,
ScrollingModule,
ScrollLayoutDirective,
],
declarations: [GroupsComponent, GroupAddEditComponent],
})

View File

@@ -591,5 +591,5 @@ export function openCollectionDialog(
dialogService: DialogService,
config: DialogConfig<CollectionDialogParams, DialogRef<CollectionDialogResult>>,
) {
return dialogService.open(CollectionDialogComponent, config);
return dialogService.open<CollectionDialogResult>(CollectionDialogComponent, config);
}

View File

@@ -9,7 +9,13 @@ import { DeviceType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { MessageListener } from "@bitwarden/common/platform/messaging";
import { DialogService, ToastService, TableModule, PopoverModule } from "@bitwarden/components";
import {
DialogService,
ToastService,
TableModule,
PopoverModule,
LayoutComponent,
} from "@bitwarden/components";
import { SharedModule } from "../../../shared";
import { VaultBannersService } from "../../../vault/individual-vault/vault-banners/services/vault-banners.service";
@@ -115,6 +121,12 @@ describe("DeviceManagementComponent", () => {
showError: jest.fn(),
},
},
{
provide: LayoutComponent,
useValue: {
mainContent: jest.fn(),
},
},
],
}).compileComponents();

View File

@@ -1,4 +1,3 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import {
AbstractControl,
@@ -19,7 +18,10 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrgKey } from "@bitwarden/common/types/key";
import {
DialogRef,
ButtonModule,
DialogConfig,
DIALOG_DATA,
DialogModule,
DialogService,
FormFieldModule,

View File

@@ -1,4 +1,3 @@
import { DialogRef } from "@angular/cdk/dialog";
import { formatDate } from "@angular/common";
import { Component, OnInit, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
@@ -16,7 +15,7 @@ import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { StateProvider } from "@bitwarden/common/platform/state";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { DialogService, ToastService } from "@bitwarden/components";
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { AddSponsorshipDialogComponent } from "./add-sponsorship-dialog.component";

View File

@@ -105,11 +105,11 @@
></button>
<bit-menu #cipherOptions>
<ng-container *ngIf="isNotDeletedLoginCipher">
<button bitMenuItem type="button" (click)="copy('username')">
<button bitMenuItem type="button" (click)="copy('username')" *ngIf="cipher.login.username">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyUsername" | i18n }}
</button>
<button bitMenuItem type="button" (click)="copy('password')" *ngIf="cipher.viewPassword">
<button bitMenuItem type="button" (click)="copy('password')" *ngIf="cipher.login.password">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyPassword" | i18n }}
</button>

View File

@@ -1,4 +1,4 @@
<cdk-virtual-scroll-viewport [itemSize]="RowHeight" scrollWindow class="tw-pb-8">
<cdk-virtual-scroll-viewport [itemSize]="RowHeight" bitScrollLayout class="tw-pb-8">
<bit-table [dataSource]="dataSource" layout="fixed">
<ng-container header>
<tr>

View File

@@ -3,7 +3,7 @@ import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { TableModule } from "@bitwarden/components";
import { ScrollLayoutDirective, TableModule } from "@bitwarden/components";
import { CollectionNameBadgeComponent } from "../../../admin-console/organizations/collections";
import { GroupBadgeModule } from "../../../admin-console/organizations/collections/group-badge/group-badge.module";
@@ -26,6 +26,7 @@ import { VaultItemsComponent } from "./vault-items.component";
CollectionNameBadgeComponent,
GroupBadgeModule,
PipesModule,
ScrollLayoutDirective,
],
declarations: [VaultItemsComponent, VaultCipherRowComponent, VaultCollectionRowComponent],
exports: [VaultItemsComponent],

View File

@@ -2,7 +2,13 @@
// @ts-strict-ignore
import { importProvidersFrom } from "@angular/core";
import { RouterModule } from "@angular/router";
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import {
applicationConfig,
componentWrapperDecorator,
Meta,
moduleMetadata,
StoryObj,
} from "@storybook/angular";
import { BehaviorSubject, of } from "rxjs";
import {
@@ -29,6 +35,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { LayoutComponent } from "@bitwarden/components";
import { RestrictedItemTypesService } from "@bitwarden/vault";
import { GroupView } from "../../../admin-console/organizations/core";
@@ -49,8 +56,9 @@ export default {
title: "Web/Vault/Items",
component: VaultItemsComponent,
decorators: [
componentWrapperDecorator((story) => `<bit-layout>${story}</bit-layout>`),
moduleMetadata({
imports: [VaultItemsModule, RouterModule],
imports: [VaultItemsModule, RouterModule, LayoutComponent],
providers: [
{
provide: EnvironmentService,

View File

@@ -55,7 +55,7 @@
>
{{ "providerUsersNeedConfirmed" | i18n }}
</bit-callout>
<cdk-virtual-scroll-viewport scrollWindow [itemSize]="rowHeight" class="tw-pb-8">
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
<bit-table [dataSource]="dataSource">
<ng-container header>
<tr>

View File

@@ -4,7 +4,7 @@ import { NgModule } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CardComponent, SearchModule } from "@bitwarden/components";
import { CardComponent, ScrollLayoutDirective, SearchModule } from "@bitwarden/components";
import { DangerZoneComponent } from "@bitwarden/web-vault/app/auth/settings/account/danger-zone.component";
import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing";
import { PaymentComponent } from "@bitwarden/web-vault/app/billing/shared/payment/payment.component";
@@ -53,6 +53,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
ScrollingModule,
VerifyBankAccountComponent,
CardComponent,
ScrollLayoutDirective,
PaymentComponent,
],
declarations: [

View File

@@ -1,4 +1,3 @@
import { BasePortalOutlet } from "@angular/cdk/portal";
import { Component, Inject, OnInit } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
@@ -33,8 +32,7 @@ export const openCreateClientDialog = (
dialogService: DialogService,
dialogConfig: DialogConfig<
CreateClientDialogParams,
DialogRef<CreateClientDialogResultType, unknown>,
BasePortalOutlet
DialogRef<CreateClientDialogResultType, unknown>
>,
) =>
dialogService.open<CreateClientDialogResultType, CreateClientDialogParams>(

View File

@@ -36,7 +36,7 @@
</ng-container>
<bit-table-scroll *ngIf="!(isLoading$ | async)" [dataSource]="dataSource" [rowSize]="53">
<ng-container header>
<th bitCell bitSortable="name" default>{{ "members" | i18n }}</th>
<th bitCell bitSortable="email" default>{{ "members" | i18n }}</th>
<th bitCell bitSortable="groupsCount" class="tw-w-[278px]">{{ "groups" | i18n }}</th>
<th bitCell bitSortable="collectionsCount" class="tw-w-[278px]">{{ "collections" | i18n }}</th>
<th bitCell bitSortable="itemsCount" class="tw-w-[278px]">{{ "items" | i18n }}</th>

View File

@@ -2,7 +2,15 @@ import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { Guid } from "@bitwarden/common/types/guid";
export class MemberAccessDetails extends BaseResponse {
export class MemberAccessResponse extends BaseResponse {
userName: string;
email: string;
twoFactorEnabled: boolean;
accountRecoveryEnabled: boolean;
userGuid: Guid;
usesKeyConnector: boolean;
cipherIds: Guid[] = [];
collectionId: string;
groupId: string;
groupName: string;
@@ -14,6 +22,14 @@ export class MemberAccessDetails extends BaseResponse {
constructor(response: any) {
super(response);
this.userName = this.getResponseProperty("UserName");
this.email = this.getResponseProperty("Email");
this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled");
this.accountRecoveryEnabled = this.getResponseProperty("AccountRecoveryEnabled");
this.userGuid = this.getResponseProperty("UserGuid");
this.usesKeyConnector = this.getResponseProperty("UsesKeyConnector");
this.cipherIds = this.getResponseProperty("CipherIds") || [];
this.groupId = this.getResponseProperty("GroupId");
this.collectionId = this.getResponseProperty("CollectionId");
this.groupName = this.getResponseProperty("GroupName");
@@ -24,34 +40,3 @@ export class MemberAccessDetails extends BaseResponse {
this.manage = this.getResponseProperty("Manage");
}
}
export class MemberAccessResponse extends BaseResponse {
userName: string;
email: string;
twoFactorEnabled: boolean;
accountRecoveryEnabled: boolean;
collectionsCount: number;
groupsCount: number;
totalItemCount: number;
accessDetails: MemberAccessDetails[] = [];
userGuid: Guid;
usesKeyConnector: boolean;
constructor(response: any) {
super(response);
this.userName = this.getResponseProperty("UserName");
this.email = this.getResponseProperty("Email");
this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled");
this.accountRecoveryEnabled = this.getResponseProperty("AccountRecoveryEnabled");
this.collectionsCount = this.getResponseProperty("CollectionsCount");
this.groupsCount = this.getResponseProperty("GroupsCount");
this.totalItemCount = this.getResponseProperty("TotalItemCount");
this.userGuid = this.getResponseProperty("UserGuid");
this.usesKeyConnector = this.getResponseProperty("UsesKeyConnector");
const details = this.getResponseProperty("AccessDetails");
if (details != null) {
this.accessDetails = details.map((o: any) => new MemberAccessDetails(o));
}
}
}

View File

@@ -1,9 +1,7 @@
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { Guid } from "@bitwarden/common/types/guid";
import {
MemberAccessDetails,
MemberAccessResponse,
} from "../response/member-access-report.response";
import { MemberAccessResponse } from "../response/member-access-report.response";
export const memberAccessReportsMock: MemberAccessResponse[] = [
{
@@ -11,223 +9,290 @@ export const memberAccessReportsMock: MemberAccessResponse[] = [
email: "sjohnson@email.com",
twoFactorEnabled: true,
accountRecoveryEnabled: true,
groupsCount: 2,
collectionsCount: 4,
totalItemCount: 20,
userGuid: "1234",
userGuid: "1001" as Guid,
usesKeyConnector: false,
accessDetails: [
{
groupId: "",
collectionId: "c1",
collectionName: new EncString("Collection 1"),
groupName: "",
itemCount: 10,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
{
groupId: "",
collectionId: "c2",
collectionName: new EncString("Collection 2"),
groupName: "",
itemCount: 20,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
{
groupId: "",
collectionId: "c3",
collectionName: new EncString("Collection 3"),
groupName: "",
itemCount: 30,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
{
groupId: "g1",
collectionId: "c1",
collectionName: new EncString("Collection 1"),
groupName: "Group 1",
itemCount: 30,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
{
groupId: "g1",
collectionId: "c2",
collectionName: new EncString("Collection 2"),
groupName: "Group 1",
itemCount: 20,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
],
} as MemberAccessResponse,
groupId: "",
collectionId: "c1",
collectionName: new EncString("Collection 1"),
groupName: "",
itemCount: 10,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "Sarah Johnson",
email: "sjohnson@email.com",
twoFactorEnabled: true,
accountRecoveryEnabled: true,
userGuid: "1001" as Guid,
usesKeyConnector: false,
groupId: "",
collectionId: "c2",
collectionName: new EncString("Collection 2"),
groupName: "",
itemCount: 20,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "Sarah Johnson",
email: "sjohnson@email.com",
twoFactorEnabled: true,
accountRecoveryEnabled: true,
userGuid: "1001" as Guid,
usesKeyConnector: false,
groupId: "",
collectionId: "c3",
collectionName: new EncString("Collection 3"),
groupName: "",
itemCount: 30,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "Sarah Johnson",
email: "sjohnson@email.com",
twoFactorEnabled: true,
accountRecoveryEnabled: true,
userGuid: "1001",
usesKeyConnector: false,
groupId: "g1",
collectionId: "c1",
collectionName: new EncString("Collection 1"),
groupName: "Group 1",
itemCount: 30,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "Sarah Johnson",
email: "sjohnson@email.com",
twoFactorEnabled: true,
accountRecoveryEnabled: true,
userGuid: "1001",
usesKeyConnector: false,
groupId: "g1",
collectionId: "c2",
collectionName: new EncString("Collection 2"),
groupName: "Group 1",
itemCount: 20,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "James Lull",
email: "jlull@email.com",
twoFactorEnabled: false,
accountRecoveryEnabled: false,
groupsCount: 2,
collectionsCount: 4,
totalItemCount: 20,
userGuid: "1234",
userGuid: "2001",
usesKeyConnector: false,
accessDetails: [
{
groupId: "g4",
collectionId: "c4",
groupName: "Group 4",
collectionName: new EncString("Collection 4"),
itemCount: 5,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
{
groupId: "g4",
collectionId: "c5",
groupName: "Group 4",
collectionName: new EncString("Collection 5"),
itemCount: 15,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
{
groupId: "",
collectionId: "c4",
groupName: "",
collectionName: new EncString("Collection 4"),
itemCount: 5,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
{
groupId: "",
collectionId: "c5",
groupName: "",
collectionName: new EncString("Collection 5"),
itemCount: 15,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
],
} as MemberAccessResponse,
groupId: "g4",
collectionId: "c4",
groupName: "Group 4",
collectionName: new EncString("Collection 4"),
itemCount: 5,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "James Lull",
email: "jlull@email.com",
twoFactorEnabled: false,
accountRecoveryEnabled: false,
userGuid: "2001",
usesKeyConnector: false,
groupId: "g4",
collectionId: "c5",
groupName: "Group 4",
collectionName: new EncString("Collection 5"),
itemCount: 15,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "James Lull",
email: "jlull@email.com",
twoFactorEnabled: false,
accountRecoveryEnabled: false,
userGuid: "2001",
usesKeyConnector: false,
groupId: "",
collectionId: "c4",
groupName: "",
collectionName: new EncString("Collection 4"),
itemCount: 5,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "James Lull",
email: "jlull@email.com",
twoFactorEnabled: false,
accountRecoveryEnabled: false,
userGuid: "2001",
usesKeyConnector: false,
groupId: "",
collectionId: "c5",
groupName: "",
collectionName: new EncString("Collection 5"),
itemCount: 15,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "Beth Williams",
email: "bwilliams@email.com",
twoFactorEnabled: true,
accountRecoveryEnabled: true,
groupsCount: 2,
collectionsCount: 4,
totalItemCount: 20,
userGuid: "1234",
userGuid: "3001",
usesKeyConnector: false,
accessDetails: [
{
groupId: "",
collectionId: "c6",
groupName: "",
collectionName: new EncString("Collection 6"),
itemCount: 25,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
{
groupId: "g6",
collectionId: "c4",
groupName: "Group 6",
collectionName: new EncString("Collection 4"),
itemCount: 35,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
],
} as MemberAccessResponse,
groupId: "",
collectionId: "c6",
groupName: "",
collectionName: new EncString("Collection 6"),
itemCount: 25,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "Beth Williams",
email: "bwilliams@email.com",
twoFactorEnabled: true,
accountRecoveryEnabled: true,
userGuid: "3001",
usesKeyConnector: false,
groupId: "g6",
collectionId: "c4",
groupName: "Group 6",
collectionName: new EncString("Collection 4"),
itemCount: 35,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "Ray Williams",
email: "rwilliams@email.com",
twoFactorEnabled: false,
accountRecoveryEnabled: false,
groupsCount: 2,
collectionsCount: 4,
totalItemCount: 20,
userGuid: "1234",
userGuid: "4000",
usesKeyConnector: false,
accessDetails: [
{
groupId: "",
collectionId: "c7",
groupName: "",
collectionName: new EncString("Collection 7"),
itemCount: 8,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
{
groupId: "",
collectionId: "c8",
groupName: "",
collectionName: new EncString("Collection 8"),
itemCount: 12,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
{
groupId: "",
collectionId: "c9",
groupName: "",
collectionName: new EncString("Collection 9"),
itemCount: 16,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
{
groupId: "g9",
collectionId: "c7",
groupName: "Group 9",
collectionName: new EncString("Collection 7"),
itemCount: 8,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
{
groupId: "g10",
collectionId: "c8",
groupName: "Group 10",
collectionName: new EncString("Collection 8"),
itemCount: 12,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
{
groupId: "g11",
collectionId: "c9",
groupName: "Group 11",
collectionName: new EncString("Collection 9"),
itemCount: 16,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
],
} as MemberAccessResponse,
groupId: "",
collectionId: "c7",
groupName: "",
collectionName: new EncString("Collection 7"),
itemCount: 8,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "Ray Williams",
email: "rwilliams@email.com",
twoFactorEnabled: false,
accountRecoveryEnabled: false,
userGuid: "4000",
usesKeyConnector: false,
groupId: "",
collectionId: "c8",
groupName: "",
collectionName: new EncString("Collection 8"),
itemCount: 12,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "Ray Williams",
email: "rwilliams@email.com",
twoFactorEnabled: false,
accountRecoveryEnabled: false,
userGuid: "4000",
usesKeyConnector: false,
groupId: "",
collectionId: "c9",
groupName: "",
collectionName: new EncString("Collection 9"),
itemCount: 16,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "Ray Williams",
email: "rwilliams@email.com",
twoFactorEnabled: false,
accountRecoveryEnabled: false,
userGuid: "4000",
usesKeyConnector: false,
groupId: "g9",
collectionId: "c7",
groupName: "Group 9",
collectionName: new EncString("Collection 7"),
itemCount: 8,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "Ray Williams",
email: "rwilliams@email.com",
twoFactorEnabled: false,
accountRecoveryEnabled: false,
userGuid: "4000",
usesKeyConnector: false,
groupId: "g10",
collectionId: "c8",
groupName: "Group 10",
collectionName: new EncString("Collection 8"),
itemCount: 12,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "Ray Williams",
email: "rwilliams@email.com",
twoFactorEnabled: false,
accountRecoveryEnabled: false,
userGuid: "4000",
usesKeyConnector: false,
groupId: "g11",
collectionId: "c9",
groupName: "Group 11",
collectionName: new EncString("Collection 9"),
itemCount: 16,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
];
export const memberAccessWithoutAccessDetailsReportsMock: MemberAccessResponse[] = [
@@ -236,34 +301,33 @@ export const memberAccessWithoutAccessDetailsReportsMock: MemberAccessResponse[]
email: "asmith@email.com",
twoFactorEnabled: true,
accountRecoveryEnabled: true,
groupsCount: 2,
collectionsCount: 4,
totalItemCount: 20,
userGuid: "1234",
userGuid: "1234" as Guid,
usesKeyConnector: false,
accessDetails: [
{
groupId: "",
collectionId: "c1",
collectionName: new EncString("Collection 1"),
groupName: "Alice Group 1",
itemCount: 10,
readOnly: false,
hidePasswords: false,
manage: false,
} as MemberAccessDetails,
],
} as MemberAccessResponse,
groupId: "",
collectionId: "c1",
collectionName: new EncString("Collection 1"),
groupName: "Alice Group 1",
itemCount: 10,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
{
userName: "Robert Brown",
email: "rbrown@email.com",
twoFactorEnabled: false,
accountRecoveryEnabled: false,
groupsCount: 2,
collectionsCount: 4,
totalItemCount: 20,
userGuid: "5678",
userGuid: "5678" as Guid,
usesKeyConnector: false,
accessDetails: [] as MemberAccessDetails[],
} as MemberAccessResponse,
groupId: "",
collectionId: "c1",
collectionName: new EncString("Collection 1"),
groupName: "",
itemCount: 10,
readOnly: false,
hidePasswords: false,
manage: false,
cipherIds: [],
} as unknown as MemberAccessResponse,
];

View File

@@ -35,36 +35,36 @@ describe("ImportService", () => {
{
name: "Sarah Johnson",
email: "sjohnson@email.com",
collectionsCount: 4,
groupsCount: 2,
itemsCount: 20,
collectionsCount: 3,
groupsCount: 1,
itemsCount: 0,
userGuid: expect.any(String),
usesKeyConnector: expect.any(Boolean),
},
{
name: "James Lull",
email: "jlull@email.com",
collectionsCount: 4,
groupsCount: 2,
itemsCount: 20,
collectionsCount: 2,
groupsCount: 1,
itemsCount: 0,
userGuid: expect.any(String),
usesKeyConnector: expect.any(Boolean),
},
{
name: "Beth Williams",
email: "bwilliams@email.com",
collectionsCount: 4,
groupsCount: 2,
itemsCount: 20,
collectionsCount: 2,
groupsCount: 1,
itemsCount: 0,
userGuid: expect.any(String),
usesKeyConnector: expect.any(Boolean),
},
{
name: "Ray Williams",
email: "rwilliams@email.com",
collectionsCount: 4,
groupsCount: 2,
itemsCount: 20,
collectionsCount: 3,
groupsCount: 3,
itemsCount: 0,
userGuid: expect.any(String),
usesKeyConnector: expect.any(Boolean),
},
@@ -82,8 +82,8 @@ describe("ImportService", () => {
(item) =>
(item.name === "Sarah Johnson" &&
item.group === "Group 1" &&
item.totalItems === "20") ||
(item.name === "James Lull" && item.group === "Group 4" && item.totalItems === "5"),
item.totalItems === "0") ||
(item.name === "James Lull" && item.group === "Group 4" && item.totalItems === "0"),
)
.map((item) => ({
name: item.name,
@@ -102,7 +102,7 @@ describe("ImportService", () => {
twoStepLogin: "memberAccessReportTwoFactorEnabledTrue",
accountRecovery: "memberAccessReportAuthenticationEnabledTrue",
group: "Group 1",
totalItems: "20",
totalItems: "0",
}),
expect.objectContaining({
email: "jlull@email.com",
@@ -110,7 +110,7 @@ describe("ImportService", () => {
twoStepLogin: "memberAccessReportTwoFactorEnabledFalse",
accountRecovery: "memberAccessReportAuthenticationEnabledFalse",
group: "Group 4",
totalItems: "5",
totalItems: "0",
}),
]),
);
@@ -131,7 +131,7 @@ describe("ImportService", () => {
twoStepLogin: "memberAccessReportTwoFactorEnabledTrue",
accountRecovery: "memberAccessReportAuthenticationEnabledTrue",
group: "Alice Group 1",
totalItems: "10",
totalItems: "0",
}),
expect.objectContaining({
email: "rbrown@email.com",

View File

@@ -5,13 +5,13 @@ import { Injectable } from "@angular/core";
import { CollectionAccessSelectionView } from "@bitwarden/admin-console/common";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { Guid, OrganizationId } from "@bitwarden/common/types/guid";
import {
getPermissionList,
convertToPermission,
} from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/access-selector";
import { MemberAccessDetails } from "../response/member-access-report.response";
import { MemberAccessResponse } from "../response/member-access-report.response";
import { MemberAccessExportItem } from "../view/member-access-export.view";
import { MemberAccessReportView } from "../view/member-access-report.view";
@@ -34,15 +34,44 @@ export class MemberAccessReportService {
organizationId: OrganizationId,
): Promise<MemberAccessReportView[]> {
const memberAccessData = await this.reportApiService.getMemberAccessData(organizationId);
const memberAccessReportViewCollection = memberAccessData.map((userData) => ({
name: userData.userName,
email: userData.email,
collectionsCount: userData.collectionsCount,
groupsCount: userData.groupsCount,
itemsCount: userData.totalItemCount,
userGuid: userData.userGuid,
usesKeyConnector: userData.usesKeyConnector,
}));
// group member access data by userGuid
const userMap = new Map<Guid, MemberAccessResponse[]>();
memberAccessData.forEach((userData) => {
const userGuid = userData.userGuid;
if (!userMap.has(userGuid)) {
userMap.set(userGuid, []);
}
userMap.get(userGuid)?.push(userData);
});
// aggregate user data
const memberAccessReportViewCollection: MemberAccessReportView[] = [];
userMap.forEach((userDataArray, userGuid) => {
const collectionCount = this.getDistinctCount<string>(
userDataArray.map((data) => data.collectionId).filter((id) => !!id),
);
const groupCount = this.getDistinctCount<string>(
userDataArray.map((data) => data.groupId).filter((id) => !!id),
);
const itemsCount = this.getDistinctCount<Guid>(
userDataArray
.flatMap((data) => data.cipherIds)
.filter((id) => id !== "00000000-0000-0000-0000-000000000000"),
);
const aggregatedData = {
userGuid: userGuid,
name: userDataArray[0].userName,
email: userDataArray[0].email,
collectionsCount: collectionCount,
groupsCount: groupCount,
itemsCount: itemsCount,
usesKeyConnector: userDataArray.some((data) => data.usesKeyConnector),
};
memberAccessReportViewCollection.push(aggregatedData);
});
return memberAccessReportViewCollection;
}
@@ -50,13 +79,8 @@ export class MemberAccessReportService {
organizationId: OrganizationId,
): Promise<MemberAccessExportItem[]> {
const memberAccessReports = await this.reportApiService.getMemberAccessData(organizationId);
const collectionNames = memberAccessReports.flatMap((item) =>
item.accessDetails.map((dtl) => {
if (dtl.collectionName) {
return dtl.collectionName.encryptedString;
}
}),
);
const collectionNames = memberAccessReports.map((item) => item.collectionName.encryptedString);
const collectionNameMap = new Map(collectionNames.map((col) => [col, ""]));
for await (const key of collectionNameMap.keys()) {
const decrypted = new EncString(key);
@@ -64,56 +88,35 @@ export class MemberAccessReportService {
collectionNameMap.set(key, decrypted.decryptedValue);
}
const exportItems = memberAccessReports.flatMap((report) => {
// to include users without access details
// which means a user has no groups, collections or items
if (report.accessDetails.length === 0) {
return [
{
email: report.email,
name: report.userName,
twoStepLogin: report.twoFactorEnabled
? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue")
: this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"),
accountRecovery: report.accountRecoveryEnabled
? this.i18nService.t("memberAccessReportAuthenticationEnabledTrue")
: this.i18nService.t("memberAccessReportAuthenticationEnabledFalse"),
group: this.i18nService.t("memberAccessReportNoGroup"),
collection: this.i18nService.t("memberAccessReportNoCollection"),
collectionPermission: this.i18nService.t("memberAccessReportNoCollectionPermission"),
totalItems: "0",
},
];
}
const userDetails = report.accessDetails.map((detail) => {
const collectionName = collectionNameMap.get(detail.collectionName.encryptedString);
return {
email: report.email,
name: report.userName,
twoStepLogin: report.twoFactorEnabled
? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue")
: this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"),
accountRecovery: report.accountRecoveryEnabled
? this.i18nService.t("memberAccessReportAuthenticationEnabledTrue")
: this.i18nService.t("memberAccessReportAuthenticationEnabledFalse"),
group: detail.groupName
? detail.groupName
: this.i18nService.t("memberAccessReportNoGroup"),
collection: collectionName
? collectionName
: this.i18nService.t("memberAccessReportNoCollection"),
collectionPermission: detail.collectionId
? this.getPermissionText(detail)
: this.i18nService.t("memberAccessReportNoCollectionPermission"),
totalItems: detail.itemCount.toString(),
};
});
return userDetails;
const exportItems = memberAccessReports.map((report) => {
const collectionName = collectionNameMap.get(report.collectionName.encryptedString);
return {
email: report.email,
name: report.userName,
twoStepLogin: report.twoFactorEnabled
? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue")
: this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"),
accountRecovery: report.accountRecoveryEnabled
? this.i18nService.t("memberAccessReportAuthenticationEnabledTrue")
: this.i18nService.t("memberAccessReportAuthenticationEnabledFalse"),
group: report.groupName
? report.groupName
: this.i18nService.t("memberAccessReportNoGroup"),
collection: collectionName
? collectionName
: this.i18nService.t("memberAccessReportNoCollection"),
collectionPermission: report.collectionId
? this.getPermissionText(report)
: this.i18nService.t("memberAccessReportNoCollectionPermission"),
totalItems: report.cipherIds
.filter((_) => _ != "00000000-0000-0000-0000-000000000000")
.length.toString(),
};
});
return exportItems.flat();
}
private getPermissionText(accessDetails: MemberAccessDetails): string {
private getPermissionText(accessDetails: MemberAccessResponse): string {
const permissionList = getPermissionList();
const collectionSelectionView = new CollectionAccessSelectionView({
id: accessDetails.groupId ?? accessDetails.collectionId,
@@ -125,4 +128,9 @@ export class MemberAccessReportService {
permissionList.find((p) => p.perm === convertToPermission(collectionSelectionView))?.labelId,
);
}
private getDistinctCount<T>(items: T[]): number {
const uniqueItems = new Set(items);
return uniqueItems.size;
}
}

View File

@@ -159,7 +159,11 @@ export class NudgesService {
*/
hasActiveBadges$(userId: UserId): Observable<boolean> {
// Add more nudge types here if they have the settings badge feature
const nudgeTypes = [NudgeType.EmptyVaultNudge, NudgeType.DownloadBitwarden];
const nudgeTypes = [
NudgeType.EmptyVaultNudge,
NudgeType.DownloadBitwarden,
NudgeType.AutofillNudge,
];
const nudgeTypesWithBadge$ = nudgeTypes.map((nudge) => {
return this.getNudgeService(nudge)

View File

@@ -212,6 +212,7 @@ export class DefaultSdkService implements SdkService {
},
},
privateKey,
signingKey: undefined,
});
// We initialize the org crypto even if the org_keys are

View File

@@ -0,0 +1,24 @@
import { CipherType } from "../enums";
/**
* Represents a menu item for creating a new cipher of a specific type
*/
export type CipherMenuItem = {
/** The cipher type this menu item represents */
type: CipherType;
/** The icon class name (e.g., "bwi-globe") */
icon: string;
/** The i18n key for the label text */
labelKey: string;
};
/**
* All available cipher menu items with their associated icons and labels
*/
export const CIPHER_MENU_ITEMS = Object.freeze([
{ type: CipherType.Login, icon: "bwi-globe", labelKey: "typeLogin" },
{ type: CipherType.Card, icon: "bwi-credit-card", labelKey: "typeCard" },
{ type: CipherType.Identity, icon: "bwi-id-card", labelKey: "typeIdentity" },
{ type: CipherType.SecureNote, icon: "bwi-sticky-note", labelKey: "note" },
{ type: CipherType.SshKey, icon: "bwi-key", labelKey: "typeSshKey" },
] as const) satisfies readonly CipherMenuItem[];

View File

@@ -1,11 +1,17 @@
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { provideAnimations } from "@angular/platform-browser/animations";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { NoopAnimationsModule, provideAnimations } from "@angular/platform-browser/animations";
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { getAllByRole, userEvent } from "@storybook/test";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule } from "../button";
import { IconButtonModule } from "../icon-button";
import { LayoutComponent } from "../layout";
import { SharedModule } from "../shared";
import { positionFixedWrapperDecorator } from "../stories/storybook-decorators";
import { I18nMockService } from "../utils/i18n-mock.service";
import { DialogModule } from "./dialog.module";
@@ -16,7 +22,12 @@ interface Animal {
}
@Component({
template: `<button bitButton type="button" (click)="openDialog()">Open Dialog</button>`,
template: `
<bit-layout>
<button class="tw-mr-2" bitButton type="button" (click)="openDialog()">Open Dialog</button>
<button bitButton type="button" (click)="openDrawer()">Open Drawer</button>
</bit-layout>
`,
imports: [ButtonModule],
})
class StoryDialogComponent {
@@ -29,6 +40,14 @@ class StoryDialogComponent {
},
});
}
openDrawer() {
this.dialogService.openDrawer(StoryDialogContentComponent, {
data: {
animal: "panda",
},
});
}
}
@Component({
@@ -64,7 +83,21 @@ export default {
title: "Component Library/Dialogs/Service",
component: StoryDialogComponent,
decorators: [
positionFixedWrapperDecorator(),
moduleMetadata({
declarations: [StoryDialogContentComponent],
imports: [
SharedModule,
ButtonModule,
NoopAnimationsModule,
DialogModule,
IconButtonModule,
RouterTestingModule,
LayoutComponent,
],
providers: [DialogService],
}),
applicationConfig({
providers: [
provideAnimations(),
DialogService,
@@ -73,7 +106,13 @@ export default {
useFactory: () => {
return new I18nMockService({
close: "Close",
loading: "Loading",
search: "Search",
skipToContent: "Skip to content",
submenu: "submenu",
toggleCollapse: "toggle collapse",
toggleSideNavigation: "Toggle side navigation",
yes: "Yes",
no: "No",
});
},
},
@@ -90,4 +129,21 @@ export default {
type Story = StoryObj<StoryDialogComponent>;
export const Default: Story = {};
export const Default: Story = {
play: async (context) => {
const canvas = context.canvasElement;
const button = getAllByRole(canvas, "button")[0];
await userEvent.click(button);
},
};
/** Drawers must be a descendant of `bit-layout`. */
export const Drawer: Story = {
play: async (context) => {
const canvas = context.canvasElement;
const button = getAllByRole(canvas, "button")[1];
await userEvent.click(button);
},
};

View File

@@ -1,31 +1,25 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
DEFAULT_DIALOG_CONFIG,
Dialog,
DialogConfig,
DialogRef,
DIALOG_SCROLL_STRATEGY,
Dialog as CdkDialog,
DialogConfig as CdkDialogConfig,
DialogRef as CdkDialogRefBase,
DIALOG_DATA,
DialogCloseOptions,
} from "@angular/cdk/dialog";
import { ComponentType, Overlay, OverlayContainer, ScrollStrategy } from "@angular/cdk/overlay";
import {
Inject,
Injectable,
Injector,
OnDestroy,
Optional,
SkipSelf,
TemplateRef,
} from "@angular/core";
import { ComponentType, ScrollStrategy } from "@angular/cdk/overlay";
import { ComponentPortal, Portal } from "@angular/cdk/portal";
import { Injectable, Injector, TemplateRef, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router } from "@angular/router";
import { filter, firstValueFrom, Subject, switchMap, takeUntil } from "rxjs";
import { filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DrawerService } from "../drawer/drawer.service";
import { SimpleConfigurableDialogComponent } from "./simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component";
import { SimpleDialogOptions, Translation } from "./simple-dialog/types";
import { SimpleDialogOptions } from "./simple-dialog/types";
/**
* The default `BlockScrollStrategy` does not work well with virtual scrolling.
@@ -48,61 +42,163 @@ class CustomBlockScrollStrategy implements ScrollStrategy {
detach() {}
}
export abstract class DialogRef<R = unknown, C = unknown>
implements Pick<CdkDialogRef<R, C>, "close" | "closed" | "disableClose" | "componentInstance">
{
abstract readonly isDrawer?: boolean;
// --- From CdkDialogRef ---
abstract close(result?: R, options?: DialogCloseOptions): void;
abstract readonly closed: Observable<R | undefined>;
abstract disableClose: boolean | undefined;
/**
* @deprecated
* Does not work with drawer dialogs.
**/
abstract componentInstance: C | null;
}
export type DialogConfig<D = unknown, R = unknown> = Pick<
CdkDialogConfig<D, R>,
"data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width"
>;
class DrawerDialogRef<R = unknown, C = unknown> implements DialogRef<R, C> {
readonly isDrawer = true;
private _closed = new Subject<R | undefined>();
closed = this._closed.asObservable();
disableClose = false;
/** The portal containing the drawer */
portal?: Portal<unknown>;
constructor(private drawerService: DrawerService) {}
close(result?: R, _options?: DialogCloseOptions): void {
if (this.disableClose) {
return;
}
this.drawerService.close(this.portal!);
this._closed.next(result);
this._closed.complete();
}
componentInstance: C | null = null;
}
/**
* DialogRef that delegates functionality to the CDK implementation
**/
export class CdkDialogRef<R = unknown, C = unknown> implements DialogRef<R, C> {
readonly isDrawer = false;
/** This is not available until after construction, @see DialogService.open. */
cdkDialogRefBase!: CdkDialogRefBase<R, C>;
// --- Delegated to CdkDialogRefBase ---
close(result?: R, options?: DialogCloseOptions): void {
this.cdkDialogRefBase.close(result, options);
}
get closed(): Observable<R | undefined> {
return this.cdkDialogRefBase.closed;
}
get disableClose(): boolean | undefined {
return this.cdkDialogRefBase.disableClose;
}
set disableClose(value: boolean | undefined) {
this.cdkDialogRefBase.disableClose = value;
}
// Delegate the `componentInstance` property to the CDK DialogRef
get componentInstance(): C | null {
return this.cdkDialogRefBase.componentInstance;
}
}
@Injectable()
export class DialogService extends Dialog implements OnDestroy {
private _destroy$ = new Subject<void>();
export class DialogService {
private dialog = inject(CdkDialog);
private drawerService = inject(DrawerService);
private injector = inject(Injector);
private router = inject(Router, { optional: true });
private authService = inject(AuthService, { optional: true });
private i18nService = inject(I18nService);
private backDropClasses = ["tw-fixed", "tw-bg-black", "tw-bg-opacity-30", "tw-inset-0"];
private defaultScrollStrategy = new CustomBlockScrollStrategy();
private activeDrawer: DrawerDialogRef<any, any> | null = null;
constructor(
/** Parent class constructor */
_overlay: Overlay,
_injector: Injector,
@Optional() @Inject(DEFAULT_DIALOG_CONFIG) _defaultOptions: DialogConfig,
@Optional() @SkipSelf() _parentDialog: Dialog,
_overlayContainer: OverlayContainer,
@Inject(DIALOG_SCROLL_STRATEGY) scrollStrategy: any,
/** Not in parent class */
@Optional() router: Router,
@Optional() authService: AuthService,
protected i18nService: I18nService,
) {
super(_overlay, _injector, _defaultOptions, _parentDialog, _overlayContainer, scrollStrategy);
constructor() {
/**
* TODO: This logic should exist outside of `libs/components`.
* @see https://bitwarden.atlassian.net/browse/CL-657
**/
/** Close all open dialogs if the vault locks */
if (router && authService) {
router.events
if (this.router && this.authService) {
this.router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
switchMap(() => authService.getAuthStatus()),
switchMap(() => this.authService!.getAuthStatus()),
filter((v) => v !== AuthenticationStatus.Unlocked),
takeUntil(this._destroy$),
takeUntilDestroyed(),
)
.subscribe(() => this.closeAll());
}
}
override ngOnDestroy(): void {
this._destroy$.next();
this._destroy$.complete();
super.ngOnDestroy();
}
override open<R = unknown, D = unknown, C = unknown>(
open<R = unknown, D = unknown, C = unknown>(
componentOrTemplateRef: ComponentType<C> | TemplateRef<C>,
config?: DialogConfig<D, DialogRef<R, C>>,
): DialogRef<R, C> {
config = {
/**
* This is a bit circular in nature:
* We need the DialogRef instance for the DI injector that is passed *to* `Dialog.open`,
* but we get the base CDK DialogRef instance *from* `Dialog.open`.
*
* To break the circle, we define CDKDialogRef as a wrapper for the CDKDialogRefBase.
* This allows us to create the class instance and provide the base instance later, almost like "deferred inheritance".
**/
const ref = new CdkDialogRef<R, C>();
const injector = this.createInjector({
data: config?.data,
dialogRef: ref,
});
// Merge the custom config with the default config
const _config = {
backdropClass: this.backDropClasses,
scrollStrategy: this.defaultScrollStrategy,
injector,
...config,
};
return super.open(componentOrTemplateRef, config);
ref.cdkDialogRefBase = this.dialog.open<R, D, C>(componentOrTemplateRef, _config);
return ref;
}
/** Opens a dialog in the side drawer */
openDrawer<R = unknown, D = unknown, C = unknown>(
component: ComponentType<C>,
config?: DialogConfig<D, DialogRef<R, C>>,
): DialogRef<R, C> {
this.activeDrawer?.close();
/**
* This is also circular. When creating the DrawerDialogRef, we do not yet have a portal instance to provide to the injector.
* Similar to `this.open`, we get around this with mutability.
*/
this.activeDrawer = new DrawerDialogRef(this.drawerService);
const portal = new ComponentPortal(
component,
null,
this.createInjector({ data: config?.data, dialogRef: this.activeDrawer }),
);
this.activeDrawer.portal = portal;
this.drawerService.open(portal);
return this.activeDrawer;
}
/**
@@ -113,8 +209,7 @@ export class DialogService extends Dialog implements OnDestroy {
*/
async openSimpleDialog(simpleDialogOptions: SimpleDialogOptions): Promise<boolean> {
const dialogRef = this.openSimpleDialogRef(simpleDialogOptions);
return firstValueFrom(dialogRef.closed);
return firstValueFrom(dialogRef.closed.pipe(map((v: boolean | undefined) => !!v)));
}
/**
@@ -134,20 +229,29 @@ export class DialogService extends Dialog implements OnDestroy {
});
}
protected translate(translation: string | Translation, defaultKey?: string): string {
if (translation == null && defaultKey == null) {
return null;
}
/** Close all open dialogs */
closeAll(): void {
return this.dialog.closeAll();
}
if (translation == null) {
return this.i18nService.t(defaultKey);
}
// Translation interface use implies we must localize.
if (typeof translation === "object") {
return this.i18nService.t(translation.key, ...(translation.placeholders ?? []));
}
return translation;
/** The injector that is passed to the opened dialog */
private createInjector(opts: { data: unknown; dialogRef: DialogRef }): Injector {
return Injector.create({
providers: [
{
provide: DIALOG_DATA,
useValue: opts.data,
},
{
provide: DialogRef,
useValue: opts.dialogRef,
},
{
provide: CdkDialogRefBase,
useValue: opts.dialogRef,
},
],
parent: this.injector,
});
}
}

View File

@@ -1,12 +1,22 @@
@let isDrawer = dialogRef?.isDrawer;
<section
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-rounded-xl tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-text-main"
[ngClass]="width"
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-text-main"
[ngClass]="[width, isDrawer ? 'tw-h-screen tw-border-t-0' : 'tw-rounded-xl']"
@fadeIn
cdkTrapFocus
cdkTrapFocusAutoCapture
>
@let showHeaderBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().top;
<header
class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 tw-p-4"
class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid"
[ngClass]="{
'tw-p-4': !isDrawer,
'tw-p-6 tw-pb-4': isDrawer,
'tw-border-secondary-300': showHeaderBorder,
'tw-border-transparent': !showHeaderBorder,
}"
>
<h1
<h2
bitDialogTitleContainer
bitTypography="h3"
noMargin
@@ -19,7 +29,7 @@
</span>
}
<ng-content select="[bitDialogTitle]"></ng-content>
</h1>
</h2>
<button
type="button"
bitIconButton="bwi-close"
@@ -32,9 +42,11 @@
</header>
<div
class="tw-relative tw-flex tw-flex-col tw-overflow-hidden"
class="tw-relative tw-flex-1 tw-flex tw-flex-col tw-overflow-hidden"
[ngClass]="{
'tw-min-h-60': loading,
'tw-bg-background': background === 'default',
'tw-bg-background-alt': background === 'alt',
}"
>
@if (loading) {
@@ -43,20 +55,28 @@
</div>
}
<div
cdkScrollable
[ngClass]="{
'tw-p-4': !disablePadding,
'tw-p-4': !disablePadding && !isDrawer,
'tw-px-6 tw-py-4': !disablePadding && isDrawer,
'tw-overflow-y-auto': !loading,
'tw-invisible tw-overflow-y-hidden': loading,
'tw-bg-background': background === 'default',
'tw-bg-background-alt': background === 'alt',
}"
>
<ng-content select="[bitDialogContent]"></ng-content>
</div>
</div>
@let showFooterBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().bottom;
<footer
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-4"
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background"
[ngClass]="[isDrawer ? 'tw-px-6 tw-py-4' : 'tw-p-4']"
[ngClass]="{
'tw-px-6 tw-py-4': isDrawer,
'tw-p-4': !isDrawer,
'tw-border-secondary-300': showFooterBorder,
'tw-border-transparent': !showFooterBorder,
}"
>
<ng-content select="[bitDialogFooter]"></ng-content>
</footer>

View File

@@ -1,14 +1,18 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CdkTrapFocus } from "@angular/cdk/a11y";
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { CdkScrollable } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import { Component, HostBinding, Input } from "@angular/core";
import { Component, HostBinding, Input, inject, viewChild } from "@angular/core";
import { I18nPipe } from "@bitwarden/ui-common";
import { BitIconButtonComponent } from "../../icon-button/icon-button.component";
import { TypographyDirective } from "../../typography/typography.directive";
import { hasScrolledFrom } from "../../utils/has-scrolled-from";
import { fadeIn } from "../animations";
import { DialogRef } from "../dialog.service";
import { DialogCloseDirective } from "../directives/dialog-close.directive";
import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive";
@@ -16,6 +20,9 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
selector: "bit-dialog",
templateUrl: "./dialog.component.html",
animations: [fadeIn],
host: {
"(keydown.esc)": "handleEsc($event)",
},
imports: [
CommonModule,
DialogTitleContainerDirective,
@@ -23,9 +30,15 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
BitIconButtonComponent,
DialogCloseDirective,
I18nPipe,
CdkTrapFocus,
CdkScrollable,
],
})
export class DialogComponent {
protected dialogRef = inject(DialogRef, { optional: true });
private scrollableBody = viewChild.required(CdkScrollable);
protected bodyHasScrolledFrom = hasScrolledFrom(this.scrollableBody);
/** Background color */
@Input()
background: "default" | "alt" = "default";
@@ -63,21 +76,31 @@ export class DialogComponent {
@HostBinding("class") get classes() {
// `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header
return ["tw-flex", "tw-flex-col", "tw-w-screen", "tw-p-4", "tw-max-h-[90vh]"].concat(
this.width,
);
return ["tw-flex", "tw-flex-col", "tw-w-screen"]
.concat(
this.width,
this.dialogRef?.isDrawer
? ["tw-min-h-screen", "md:tw-w-[23rem]"]
: ["tw-p-4", "tw-w-screen", "tw-max-h-[90vh]"],
)
.flat();
}
handleEsc(event: Event) {
this.dialogRef?.close();
event.stopPropagation();
}
get width() {
switch (this.dialogSize) {
case "small": {
return "tw-max-w-sm";
return "md:tw-max-w-sm";
}
case "large": {
return "tw-max-w-3xl";
return "md:tw-max-w-3xl";
}
default: {
return "tw-max-w-xl";
return "md:tw-max-w-xl";
}
}
}

View File

@@ -22,6 +22,9 @@ For alerts or simple confirmation actions, like speedbumps, use the
Dialogs's should be used sparingly as they do call extra attention to themselves and can be
interruptive if overused.
For non-blocking, supplementary content, open dialogs as a
[Drawer](?path=/story/component-library-dialogs-service--drawer) (requires `bit-layout`).
## Placement
Dialogs should be centered vertically and horizontally on screen. Dialogs height should expand to

View File

@@ -1,4 +1,4 @@
export * from "./dialog.module";
export * from "./simple-dialog/types";
export * from "./dialog.service";
export { DialogConfig, DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
export { DIALOG_DATA } from "@angular/cdk/dialog";

View File

@@ -1,7 +1,7 @@
import { CdkScrollable } from "@angular/cdk/scrolling";
import { ChangeDetectionStrategy, Component, Signal, inject } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { map } from "rxjs";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { hasScrolledFrom } from "../utils/has-scrolled-from";
/**
* Body container for `bit-drawer`
@@ -13,7 +13,7 @@ import { map } from "rxjs";
host: {
class:
"tw-p-4 tw-pt-0 tw-block tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200",
"[class.tw-border-t-secondary-300]": "isScrolled()",
"[class.tw-border-t-secondary-300]": "this.hasScrolledFrom().top",
},
hostDirectives: [
{
@@ -23,13 +23,5 @@ import { map } from "rxjs";
template: ` <ng-content></ng-content> `,
})
export class DrawerBodyComponent {
private scrollable = inject(CdkScrollable);
/** TODO: share this utility with browser popup header? */
protected isScrolled: Signal<boolean> = toSignal(
this.scrollable
.elementScrolled()
.pipe(map(() => this.scrollable.measureScrollOffset("top") > 0)),
{ initialValue: false },
);
protected hasScrolledFrom = hasScrolledFrom();
}

View File

@@ -10,7 +10,7 @@ import {
viewChild,
} from "@angular/core";
import { DrawerHostDirective } from "./drawer-host.directive";
import { DrawerService } from "./drawer.service";
/**
* A drawer is a panel of supplementary content that is adjacent to the page's main content.
@@ -24,7 +24,7 @@ import { DrawerHostDirective } from "./drawer-host.directive";
templateUrl: "drawer.component.html",
})
export class DrawerComponent {
private drawerHost = inject(DrawerHostDirective);
private drawerHost = inject(DrawerService);
private portal = viewChild.required(CdkPortal);
/**

View File

@@ -12,6 +12,8 @@ import { DrawerComponent } from "@bitwarden/components";
# Drawer
**Note: `bit-drawer` is deprecated. Use `bit-dialog` and `DialogService.openDrawer(...)` instead.**
A drawer is a panel of supplementary content that is adjacent to the page's main content.
<Primary />

View File

@@ -0,0 +1,20 @@
import { Portal } from "@angular/cdk/portal";
import { Injectable, signal } from "@angular/core";
@Injectable({ providedIn: "root" })
export class DrawerService {
private _portal = signal<Portal<unknown> | undefined>(undefined);
/** The portal to display */
portal = this._portal.asReadonly();
open(portal: Portal<unknown>) {
this._portal.set(portal);
}
close(portal: Portal<unknown>) {
if (portal === this.portal()) {
this._portal.set(undefined);
}
}
}

View File

@@ -1 +1,2 @@
export * from "./layout.component";
export * from "./scroll-layout.directive";

View File

@@ -1,43 +1,54 @@
<div
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
>
<nav class="tw-bg-background-alt3 tw-rounded-md tw-rounded-t-none tw-py-2 tw-text-alt2">
<a
bitLink
class="tw-mx-6 focus-visible:before:!tw-ring-0"
[fragment]="mainContentId"
[routerLink]="[]"
(click)="focusMainContent()"
linkType="light"
>{{ "skipToContent" | i18n }}</a
>
</nav>
</div>
@let mainContentId = "main-content";
<div class="tw-flex tw-w-full">
<ng-content select="bit-side-nav, [slot=side-nav]"></ng-content>
<main
[id]="mainContentId"
tabindex="-1"
class="tw-overflow-auto tw-min-w-0 tw-flex-1 tw-bg-background tw-p-6 md:tw-ms-0 tw-ms-16"
>
<ng-content></ng-content>
<div class="tw-flex tw-w-full" cdkTrapFocus>
<div
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
>
<nav class="tw-bg-background-alt3 tw-rounded-md tw-rounded-t-none tw-py-2 tw-text-alt2">
<a
#skipLink
bitLink
class="tw-mx-6 focus-visible:before:!tw-ring-0"
[fragment]="mainContentId"
[routerLink]="[]"
(click)="focusMainContent()"
linkType="light"
>{{ "skipToContent" | i18n }}</a
>
</nav>
</div>
<ng-content select="bit-side-nav, [slot=side-nav]"></ng-content>
<main
#main
[id]="mainContentId"
tabindex="-1"
bitScrollLayoutHost
class="tw-overflow-auto tw-max-h-screen tw-min-w-0 tw-flex-1 tw-bg-background tw-p-6 md:tw-ms-0 tw-ms-16"
>
<ng-content></ng-content>
<!-- overlay backdrop for side-nav -->
@if (
{
open: sideNavService.open$ | async,
};
as data
) {
<div
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
>
@if (data.open) {
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
}
</div>
}
</main>
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
<!-- overlay backdrop for side-nav -->
@if (
{
open: sideNavService.open$ | async,
};
as data
) {
<div
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
>
@if (data.open) {
<div
(click)="sideNavService.toggle()"
class="tw-pointer-events-auto tw-size-full"
></div>
}
</div>
}
</main>
</div>
<div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-screen md:tw-w-auto">
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
</div>
</div>

View File

@@ -1,26 +1,61 @@
import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y";
import { PortalModule } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
import { Component, inject } from "@angular/core";
import { Component, ElementRef, inject, viewChild } from "@angular/core";
import { RouterModule } from "@angular/router";
import { DrawerHostDirective } from "../drawer/drawer-host.directive";
import { DrawerService } from "../drawer/drawer.service";
import { LinkModule } from "../link";
import { SideNavService } from "../navigation/side-nav.service";
import { SharedModule } from "../shared";
import { ScrollLayoutHostDirective } from "./scroll-layout.directive";
@Component({
selector: "bit-layout",
templateUrl: "layout.component.html",
imports: [CommonModule, SharedModule, LinkModule, RouterModule, PortalModule],
imports: [
CommonModule,
SharedModule,
LinkModule,
RouterModule,
PortalModule,
A11yModule,
CdkTrapFocus,
ScrollLayoutHostDirective,
],
host: {
"(document:keydown.tab)": "handleKeydown($event)",
},
hostDirectives: [DrawerHostDirective],
})
export class LayoutComponent {
protected mainContentId = "main-content";
protected sideNavService = inject(SideNavService);
protected drawerPortal = inject(DrawerHostDirective).portal;
protected drawerPortal = inject(DrawerService).portal;
focusMainContent() {
document.getElementById(this.mainContentId)?.focus();
private mainContent = viewChild.required<ElementRef<HTMLElement>>("main");
protected focusMainContent() {
this.mainContent().nativeElement.focus();
}
/**
* Angular CDK's focus trap utility is silly and will not respect focus order.
* This is a workaround to explicitly focus the skip link when tab is first pressed, if no other item already has focus.
*
* @see https://github.com/angular/components/issues/10247#issuecomment-384060265
**/
private skipLink = viewChild.required<ElementRef<HTMLElement>>("skipLink");
handleKeydown(ev: KeyboardEvent) {
if (isNothingFocused()) {
ev.preventDefault();
this.skipLink().nativeElement.focus();
}
}
}
const isNothingFocused = (): boolean => {
return [document.documentElement, document.body, null].includes(
document.activeElement as HTMLElement,
);
};

View File

@@ -0,0 +1,98 @@
import { CdkVirtualScrollable, VIRTUAL_SCROLLABLE } from "@angular/cdk/scrolling";
import {
Directive,
ElementRef,
Injectable,
OnDestroy,
OnInit,
effect,
inject,
signal,
} from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import { filter, fromEvent, Observable, switchMap } from "rxjs";
/**
* A service is needed because we can't inject a directive defined in the template of a parent component. The parent's template is initialized after projected content.
**/
@Injectable({ providedIn: "root" })
export class ScrollLayoutService {
scrollableRef = signal<ElementRef<HTMLElement> | null>(null);
scrollableRef$ = toObservable(this.scrollableRef);
}
/**
* Marks the primary scrollable area of a layout component.
*
* Stores the element reference in a global service so it can be referenced by `ScrollLayoutDirective` even when it isn't a direct child of this directive.
**/
@Directive({
selector: "[bitScrollLayoutHost]",
standalone: true,
host: {
class: "cdk-virtual-scrollable",
},
})
export class ScrollLayoutHostDirective implements OnDestroy {
private ref = inject(ElementRef);
private service = inject(ScrollLayoutService);
constructor() {
this.service.scrollableRef.set(this.ref as ElementRef<HTMLElement>);
}
ngOnDestroy(): void {
this.service.scrollableRef.set(null);
}
}
/**
* Sets the scroll viewport to the element marked with `ScrollLayoutHostDirective`.
*
* `ScrollLayoutHostDirective` is set on the primary scrollable area of a layout component (`bit-layout`, `popup-page`, etc).
*
* @see "Virtual Scrolling" in Storybook.
*/
@Directive({
selector: "[bitScrollLayout]",
standalone: true,
providers: [{ provide: VIRTUAL_SCROLLABLE, useExisting: ScrollLayoutDirective }],
})
export class ScrollLayoutDirective extends CdkVirtualScrollable implements OnInit {
private service = inject(ScrollLayoutService);
constructor() {
super();
effect(() => {
const scrollableRef = this.service.scrollableRef();
if (!scrollableRef) {
// eslint-disable-next-line no-console
console.error("ScrollLayoutDirective can't find scroll host");
return;
}
this.elementRef = scrollableRef;
});
}
override elementScrolled(): Observable<Event> {
return this.service.scrollableRef$.pipe(
filter((ref) => ref !== null),
switchMap((ref) => fromEvent(ref.nativeElement, "scroll")),
);
}
override getElementRef(): ElementRef<HTMLElement> {
return this.service.scrollableRef()!;
}
override measureBoundingClientRectWithScrollOffset(
from: "left" | "top" | "right" | "bottom",
): number {
return (
this.service.scrollableRef()!.nativeElement.getBoundingClientRect()[from] -
this.measureScrollOffset(from)
);
}
}

View File

@@ -3,14 +3,23 @@ import { Component, OnInit } from "@angular/core";
import { DialogModule, DialogService } from "../../../dialog";
import { IconButtonModule } from "../../../icon-button";
import { ScrollLayoutDirective } from "../../../layout";
import { SectionComponent } from "../../../section";
import { TableDataSource, TableModule } from "../../../table";
@Component({
selector: "dialog-virtual-scroll-block",
imports: [DialogModule, IconButtonModule, SectionComponent, TableModule, ScrollingModule],
standalone: true,
imports: [
DialogModule,
IconButtonModule,
SectionComponent,
TableModule,
ScrollingModule,
ScrollLayoutDirective,
],
template: /*html*/ `<bit-section>
<cdk-virtual-scroll-viewport scrollWindow itemSize="47">
<cdk-virtual-scroll-viewport bitScrollLayout itemSize="63.5">
<bit-table [dataSource]="dataSource">
<ng-container header>
<tr>

View File

@@ -11,8 +11,69 @@ import { KitchenSinkToggleList } from "./kitchen-sink-toggle-list.component";
@Component({
imports: [KitchenSinkSharedModule],
template: `
<bit-dialog title="Dialog Title" dialogSize="large">
<span bitDialogContent> Dialog body text goes here. </span>
<bit-dialog title="Dialog Title" dialogSize="small">
<ng-container bitDialogContent>
<p bitTypography="body1">
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. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<bit-form-field>
<bit-label>What did foo say to bar?</bit-label>
<input bitInput value="Baz" />
</bit-form-field>
<p bitTypography="body1">
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. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
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. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
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. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
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. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
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. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
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. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
</ng-container>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" (click)="dialogRef.close()">OK</button>
<button type="button" bitButton buttonType="secondary" bitDialogClose>Cancel</button>
@@ -88,72 +149,6 @@ class KitchenSinkDialog {
</bit-section>
</bit-tab>
</bit-tab-group>
<bit-drawer [(open)]="drawerOpen">
<bit-drawer-header title="Foo ipsum"></bit-drawer-header>
<bit-drawer-body>
<p bitTypography="body1">
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. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<bit-form-field>
<bit-label>What did foo say to bar?</bit-label>
<input bitInput value="Baz" />
</bit-form-field>
<p bitTypography="body1">
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. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
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. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
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. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
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. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
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. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
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. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
</bit-drawer-body>
</bit-drawer>
`,
})
export class KitchenSinkMainComponent {
@@ -166,7 +161,7 @@ export class KitchenSinkMainComponent {
}
openDrawer() {
this.drawerOpen.set(true);
this.dialogService.openDrawer(KitchenSinkDialog);
}
navItems = [

View File

@@ -14,7 +14,6 @@ import {
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService } from "../../dialog";
import { LayoutComponent } from "../../layout";
import { I18nMockService } from "../../utils/i18n-mock.service";
import { positionFixedWrapperDecorator } from "../storybook-decorators";
@@ -39,8 +38,20 @@ export default {
KitchenSinkTable,
KitchenSinkToggleList,
],
}),
applicationConfig({
providers: [
DialogService,
provideNoopAnimations(),
importProvidersFrom(
RouterModule.forRoot(
[
{ path: "", redirectTo: "bitwarden", pathMatch: "full" },
{ path: "bitwarden", component: KitchenSinkMainComponent },
{ path: "virtual-scroll", component: DialogVirtualScrollBlockComponent },
],
{ useHash: true },
),
),
{
provide: I18nService,
useFactory: () => {
@@ -58,21 +69,6 @@ export default {
},
],
}),
applicationConfig({
providers: [
provideNoopAnimations(),
importProvidersFrom(
RouterModule.forRoot(
[
{ path: "", redirectTo: "bitwarden", pathMatch: "full" },
{ path: "bitwarden", component: KitchenSinkMainComponent },
{ path: "virtual-scroll", component: DialogVirtualScrollBlockComponent },
],
{ useHash: true },
),
),
],
}),
],
} as Meta;

Some files were not shown because too many files have changed in this diff Show More