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:
2
.github/ISSUE_TEMPLATE/browser.yml
vendored
2
.github/ISSUE_TEMPLATE/browser.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Browser Bug Report
|
||||
name: Browser Extension Bug Report
|
||||
description: File a bug report
|
||||
labels: [bug, browser]
|
||||
body:
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/web.yml
vendored
3
.github/ISSUE_TEMPLATE/web.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/build-desktop.yml
vendored
2
.github/workflows/build-desktop.yml
vendored
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
fullWidth
|
||||
placeholderIcon="bwi-list"
|
||||
[placeholderText]="'type' | i18n"
|
||||
[options]="cipherTypes"
|
||||
[options]="cipherTypes$ | async"
|
||||
>
|
||||
</bit-chip-select>
|
||||
</form>
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
|
||||
1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQRQzzQ8nQEouF1FMSHkPx1nejNCzF7g
|
||||
Yb8MHXLdBFM0uJkWs0vzgLJkttts2eDv3SHJqIH6qHpkLtEvgMXE5WcaAAAAoOO1BebjtQ
|
||||
XmAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFDPNDydASi4XUUx
|
||||
IeQ/HWd6M0LMXuBhvwwdct0EUzS4mRazS/OAsmS222zZ4O/dIcmogfqoemQu0S+AxcTlZx
|
||||
oAAAAhAKnIXk6H0Hs3HblklaZ6UmEjjdE/0t7EdYixpMmtpJ4eAAAAB3Rlc3RrZXk=
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -1 +0,0 @@
|
||||
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFDPNDydASi4XUUxIeQ/HWd6M0LMXuBhvwwdct0EUzS4mRazS/OAsmS222zZ4O/dIcmogfqoemQu0S+AxcTlZxo= testkey
|
||||
@@ -1,8 +0,0 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABAUTNb0if
|
||||
fqsoqtfv70CfukAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIHGs3Uw3eyqnFjBI
|
||||
2eb7Qto4KVc34ZdnBac59Bab54BLAAAAkPA6aovfxQbP6FoOfaRH6u22CxqiUM0bbMpuFf
|
||||
WETn9FLaBE6LjoHH0ZI5rzNjJaQUNfx0cRcqsIrexw8YINrdVjySmEqrl5hw8gpgy0gGP5
|
||||
1Y6vKWdHdrxJCA9YMFOfDs0UhPfpLKZCwm2Sg+Bd8arlI8Gy7y4Jj/60v2bZOLhD2IZQnK
|
||||
NdJ8xATiIINuTy4g==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -1 +0,0 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHGs3Uw3eyqnFjBI2eb7Qto4KVc34ZdnBac59Bab54BL testkey
|
||||
@@ -1,7 +0,0 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACAyQo22TXXNqvF+L8jUSSNeu8UqrsDjvf9pwIwDC9ML6gAAAJDSHpL60h6S
|
||||
+gAAAAtzc2gtZWQyNTUxOQAAACAyQo22TXXNqvF+L8jUSSNeu8UqrsDjvf9pwIwDC9ML6g
|
||||
AAAECLdlFLIJbEiFo/f0ROdXMNZAPHGPNhvbbftaPsUZEjaDJCjbZNdc2q8X4vyNRJI167
|
||||
xSquwOO9/2nAjAML0wvqAAAAB3Rlc3RrZXkBAgMEBQY=
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -1 +0,0 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDJCjbZNdc2q8X4vyNRJI167xSquwOO9/2nAjAML0wvq testkey
|
||||
@@ -1,4 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MFECAQEwBQYDK2VwBCIEIDY6/OAdDr3PbDss9NsLXK4CxiKUvz5/R9uvjtIzj4Sz
|
||||
gSEAxsxm1xpZ/4lKIRYm0JrJ5gRZUh7H24/YT/0qGVGzPa0=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -1 +0,0 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMbMZtcaWf+JSiEWJtCayeYEWVIex9uP2E/9KhlRsz2t
|
||||
@@ -1,8 +0,0 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
|
||||
c2gtZWQyNTUxOQAAACDp0/9zFBCyZs5BFqXCJN5i1DTanzPGHpUeo2LP8FmQ9wAA
|
||||
AKCyIXPqsiFz6gAAAAtzc2gtZWQyNTUxOQAAACDp0/9zFBCyZs5BFqXCJN5i1DTa
|
||||
nzPGHpUeo2LP8FmQ9wAAAEDQioomhjmD+sh2nsxfQLJ5YYGASNUAlUZHe9Jx0p47
|
||||
H+nT/3MUELJmzkEWpcIk3mLUNNqfM8YelR6jYs/wWZD3AAAAEmVkZHNhLWtleS0y
|
||||
MDI0MTExOAECAwQFBgcICQoL
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -1,39 +0,0 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
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
|
||||
2MmKBw==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -1 +0,0 @@
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC/v18xGP3qzRV9iWqyiuwHZ4GpC4K2NO2/i2Yv5A3/bnal7CmiMh/S78lphgxcWtFkwrwlb321FmdHBv6KOW+EzSiPvmsdkkbpfBXB3Qf2SlhZOZZ7lYeu8KAxL3exvvn8O1GGlUjXGUrFgmC60tHWDBc1Ncmo8a2dwDLmA/sbLa8su2dvYEFmRg1vaytLDpkn8GS7zAxrUl/g0W2RwkPsByduUziQuX90v9WAy7MqOlwBRq6t5o8wdDBVODe0VIXC7N1OS42YUsKF+N0XOnLiJrIIKkXpahMDpKZHeHQAdUQzsJVhKoLJR8DNDTYyhnJoQG7Q6m2gDTca9oAWvsBiNoEwCvwrt7cDNCz/GslH9HXQgfWcVXn8+fuZgvjO3CxUI16Ev33m0jWoOKJcgK/ZLRnk8SEvsJ8NO32MeR/qUb7IN/yUcDmPMI/3ecQsakF2cwNzHkyiGVo//yVTpf+vk8b89L+GXbYU5rtswtc2ZEGsQnUkaoNqS8mHqhWQBUk= testkey
|
||||
@@ -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-----
|
||||
@@ -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
|
||||
@@ -1,42 +0,0 @@
|
||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||
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
|
||||
-----END ENCRYPTED PRIVATE KEY-----
|
||||
@@ -1 +0,0 @@
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCcHkc0xfH4w9aW41S9M/BfancSY4QPc2O4G1cRjFfK8QrLEGDA7NiHtoEML0afcurRXD3NVxuKaAns0w6EoS4CjzXUqVHTLA4SUyuapr8k0Eu2xOpbCwC3jDovhckoKloq7BvE6rC2i5wjSMadtIJKt/dqWI3HLjUMz1BxQJAU/qAbicj1SFZSjA/MubVBzcq93XOvByMtlIFu7wami3FTc37rVkGeUFHtK8ZbvG3n1aaTF79bBgSPuoq5BfcMdGr4WfQyGQzgse4v4hQ8yKYrtE0jo0kf06hEORimwOIU/W5IH1r+/xFs7qGKcPnFSZRIFv5LfMPTo8b+OsBRflosyfUumDEX97GZE7DSQl0EJzNvWeKwl7dQ8RUJTkbph2CjrxY77DFim+165Uj/WRr4uq2qMNhA2xNSD19+TA6AHdpGw4WZd37q2/n+EddlaJEH8MzpgtHNG9MiYh5ScZ+AG0QugflozJcQNc7n8N9Lpu1sRoejV5RhurHg/TYwVK8= testkey
|
||||
@@ -1,40 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
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
|
||||
QjDEB37RqGZxqyIx8V2ZYwU=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -1 +0,0 @@
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCn4+QiJojZ9mgc9KYJIvDWGaz4qFhf0CButg6L8zEoHKwuiN+mqcEciCCOa9BNiJmm8NTTehZvrrglGG59zIbqYtDAHjVn+vtb49xPzIv+M651Yqj08lIbR9tEIHKCq7aH8GlDm8NgG9EzJGjlL7okQym4TH1MHl+s4mUyr/qb2unlZBDixAQsphU8iCLftukWCIkmQg4CSj1Gh3WbBlZ+EX5eW0EXuAw4XsSbBTWV9CHRowVIpYqPvEYSpHsoCjEcd988p19hpiGknA0J4z7JfUlNgyT/1chb8GCTDT+2DCBRApbsIg6TOBVS+PR6emAQ3eZzUW0+3/oRM4ip0ujltQy8uU6gvYIAqx5wXGMThVpZcUgahKiSsVo/s4b84iMe4DG3W8jz4qi6yyNv0VedEzPUZ1lXd1GJFoy9uKNuSTe+1ksicAcluZN6LuNsPHcPxFCzOcmoNnVXEKAXInt+ys//5CDVasroZSAHZnDjUD4oNsLI3VIOnGxgXrkwSH0= testkey
|
||||
@@ -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-----
|
||||
@@ -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-----
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
4
apps/desktop/src/package-lock.json
generated
4
apps/desktop/src/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -212,6 +212,7 @@ export class DefaultSdkService implements SdkService {
|
||||
},
|
||||
},
|
||||
privateKey,
|
||||
signingKey: undefined,
|
||||
});
|
||||
|
||||
// We initialize the org crypto even if the org_keys are
|
||||
|
||||
24
libs/common/src/vault/types/cipher-menu-items.ts
Normal file
24
libs/common/src/vault/types/cipher-menu-items.ts
Normal 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[];
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 />
|
||||
|
||||
20
libs/components/src/drawer/drawer.service.ts
Normal file
20
libs/components/src/drawer/drawer.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./layout.component";
|
||||
export * from "./scroll-layout.directive";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
98
libs/components/src/layout/scroll-layout.directive.ts
Normal file
98
libs/components/src/layout/scroll-layout.directive.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user