mirror of
https://github.com/bitwarden/browser
synced 2026-01-21 03:43:41 +00:00
PM-8353 MacOS passkey provider (#13963)
* Turn on passkeys and dev mode
* PM-19138: Add try-catch to desktop-autofill (#13964)
* PM-19424: React to IPC disconnect (#14123)
* React to IPC disconnects
* Minor cleanup
* Update apps/desktop/package.json
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
* Relaxed ordering
---------
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
* Autofill/pm 9034 implement passkey for unlocked accounts (#13826)
* Passkey stuff
Co-authored-by: Anders Åberg <github@andersaberg.com>
* Ugly hacks
* Work On Modal State Management
* Applying modalStyles
* modal
* Improved hide/show
* fixed promise
* File name
* fix prettier
* Protecting against null API's and undefined data
* Only show fake popup to devs
* cleanup mock code
* rename minmimal-app to modal-app
* Added comment
* Added comment
* removed old comment
* Avoided changing minimum size
* Add small comment
* Rename component
* adress feedback
* Fixed uppercase file
* Fixed build
* Added codeowners
* added void
* commentary
* feat: reset setting on app start
* Moved reset to be in main / process launch
* Add comment to create window
* Added a little bit of styling
* Use Messaging service to loadUrl
* Enable passkeysautofill
* Add logging
* halfbaked
* Integration working
* And now it works without extra delay
* Clean up
* add note about messaging
* lb
* removed console.logs
* Cleanup and adress review feedback
* This hides the swift UI
* add modal components
* update modal with correct ciphers and functionality
* add create screen
* pick credential, draft
* Remove logger
* a whole lot of wiring
* not working
* Improved wiring
* Cancel after 90s
* Introduced observable
* update cipher handling
* update to use matchesUri
* Launching bitwarden if its not running
* Passing position from native to electron
* Rename inModalMode to modalMode
* remove tap
* revert spaces
* added back isDev
* cleaned up a bit
* Cleanup swift file
* tweaked logging
* clean up
* Update apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
* Update apps/desktop/src/platform/main/autofill/native-autofill.main.ts
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
* Update apps/desktop/src/platform/services/desktop-settings.service.ts
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
* adress position feedback
* Update apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
* Removed extra logging
* Adjusted error logging
* Use .error to log errors
* remove dead code
* Update desktop-autofill.service.ts
* use parseCredentialId instead of guidToRawFormat
* Update apps/desktop/src/autofill/services/desktop-autofill.service.ts
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
* Change windowXy to a Record instead of [number,number]
* Update apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
* Remove unsued dep and comment
* changed timeout to be spec recommended maxium, 10 minutes, for now.
* Correctly assume UP
* Removed extra cancelRequest in deinint
* Add timeout and UV to confirmChoseCipher
UV is performed by UI, not the service
* Improved docs regarding undefined cipherId
* cleanup: UP is no longer undefined
* Run completeError if ipc messages conversion failed
* don't throw, instead return undefined
* Disabled passkey provider
* Throw error if no activeUserId was found
* removed comment
* Fixed lint
* removed unsued service
* reset entitlement formatting
* Update entitlements.mas.plist
* Fix build issues
* Fix import issues
* Update route names to use `fido2`
* Fix being unable to select a passkey
* Fix linting issues
* Followup to fix merge issues and other comments
* Update `userHandle` value
* Add error handling for missing session or other errors
* Remove unused route
* Fix linting issues
* Simplify updateCredential method
* Followup to remove comments and timeouts and handle errors
* Address lint issue by using `takeUntilDestroyed`
* PR Followup for typescript and vault concerns
* Add try block for cipher creation
* Make userId manditory for cipher service
---------
Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
Co-authored-by: Anders Åberg <github@andersaberg.com>
Co-authored-by: Anders Åberg <anders@andersaberg.com>
Co-authored-by: Colton Hurst <colton@coltonhurst.com>
Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>
Co-authored-by: Evan Bassler <evanbassler@Mac.attlocal.net>
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
* PM-11455: Trigger sync when user enables OS setting (#14127)
* Implemented a SendNativeStatus command
This allows reporting status or asking the electron app to do something.
* fmt
* Update apps/desktop/src/autofill/services/desktop-autofill.service.ts
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
* clean up
* Don't add empty callbacks
* Removed comment
---------
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
* Added support for handling a locked vault
Handle unlocktimeout
* PM-19511: Add support for ExcludedCredentials (#14128)
* works
* Add mapping
* remove the build script
* cleanup
* simplify updatedCipher (#14179)
* Fix base64url decode on MacOS passkeys (#14227)
* Add support for padding in base64url decode
* whitespace
* whitespace
* Autofill/pm 17444 use reprompt (#14004)
* Passkey stuff
Co-authored-by: Anders Åberg <github@andersaberg.com>
* Ugly hacks
* Work On Modal State Management
* Applying modalStyles
* modal
* Improved hide/show
* fixed promise
* File name
* fix prettier
* Protecting against null API's and undefined data
* Only show fake popup to devs
* cleanup mock code
* rename minmimal-app to modal-app
* Added comment
* Added comment
* removed old comment
* Avoided changing minimum size
* Add small comment
* Rename component
* adress feedback
* Fixed uppercase file
* Fixed build
* Added codeowners
* added void
* commentary
* feat: reset setting on app start
* Moved reset to be in main / process launch
* Add comment to create window
* Added a little bit of styling
* Use Messaging service to loadUrl
* Enable passkeysautofill
* Add logging
* halfbaked
* Integration working
* And now it works without extra delay
* Clean up
* add note about messaging
* lb
* removed console.logs
* Cleanup and adress review feedback
* This hides the swift UI
* add modal components
* update modal with correct ciphers and functionality
* add create screen
* pick credential, draft
* Remove logger
* a whole lot of wiring
* not working
* Improved wiring
* Cancel after 90s
* Introduced observable
* update cipher handling
* update to use matchesUri
* Launching bitwarden if its not running
* Passing position from native to electron
* Rename inModalMode to modalMode
* remove tap
* revert spaces
* added back isDev
* cleaned up a bit
* Cleanup swift file
* tweaked logging
* clean up
* Update apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
* Update apps/desktop/src/platform/main/autofill/native-autofill.main.ts
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
* Update apps/desktop/src/platform/services/desktop-settings.service.ts
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
* adress position feedback
* Update apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
* Removed extra logging
* Adjusted error logging
* Use .error to log errors
* remove dead code
* Update desktop-autofill.service.ts
* use parseCredentialId instead of guidToRawFormat
* Update apps/desktop/src/autofill/services/desktop-autofill.service.ts
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
* Change windowXy to a Record instead of [number,number]
* Update apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
* Remove unsued dep and comment
* changed timeout to be spec recommended maxium, 10 minutes, for now.
* Correctly assume UP
* Removed extra cancelRequest in deinint
* Add timeout and UV to confirmChoseCipher
UV is performed by UI, not the service
* Improved docs regarding undefined cipherId
* cleanup: UP is no longer undefined
* Run completeError if ipc messages conversion failed
* don't throw, instead return undefined
* Disabled passkey provider
* Throw error if no activeUserId was found
* removed comment
* Fixed lint
* removed unsued service
* reset entitlement formatting
* Update entitlements.mas.plist
* Fix build issues
* Fix import issues
* Update route names to use `fido2`
* Fix being unable to select a passkey
* Fix linting issues
* Added support for handling a locked vault
* Followup to fix merge issues and other comments
* Update `userHandle` value
* Add error handling for missing session or other errors
* Remove unused route
* Fix linting issues
* Simplify updateCredential method
* Add master password reprompt on passkey create
* Followup to remove comments and timeouts and handle errors
* Address lint issue by using `takeUntilDestroyed`
* Add MP prompt to cipher selection
* Change how timeout is handled
* Include `of` from rxjs
* Hide blue header for passkey popouts (#14095)
* Hide blue header for passkey popouts
* Fix issue with test
* Fix ngOnDestroy complaint
* Import OnDestroy correctly
* Only require master password if item requires it
---------
Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
Co-authored-by: Anders Åberg <github@andersaberg.com>
Co-authored-by: Anders Åberg <anders@andersaberg.com>
Co-authored-by: Colton Hurst <colton@coltonhurst.com>
Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>
Co-authored-by: Evan Bassler <evanbassler@Mac.attlocal.net>
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
* Change modal size to 600x600
* Improve MacOS Syncing
This changes the behaviour to react to logoff, but not to account locks. It also adds better error handling on the native side.
* Improved modalPosition by allowing multiple calls to applyModalStyles
* moved imports to please lint
* Make passkey header stick for select and create (#14357)
* Added local build command
* Exclude credentials using kvc to avoid comilation error in cicd (#14568)
* Fix syntax error
* Don't use kvc
* Enables the autofill extension in mac and mas builds (#14373)
* Enables autofill extension building
* Try use macos-14
* add --break-system-packages for macos14
* revert using build-native
* try add rustup target add x86_64-apple-darwin
* add more rustup target add x86_64-apple-darwin
* try to force sdk version
* Show SDK versions
* USE KVC for excludedCredentials
* added xcodebuild deugging
* Revert "try to force sdk version"
This reverts commit d94f2550ad.
* Use macos-15
* undo merge
* remove macos-15 from cli
* remove macos-15 from browser
---------
Co-authored-by: Anders Åberg <anders@andersaberg.com>
* Improve Autofill IPC reliability (#14358)
* Delay IPC server start
* Better ipc handling
* Rename ready() to listenerReady()
---------
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
* feat: add test and check for too long buffers (#14775)
* Autofill/PM-19511: Overwrite and reprompt (#14288)
* Show items for url that don't have passkey
* Show existing login items in the UI
* Filter available cipher results (#14399)
* Filter available cipher results
* Fix linting issues
* Update logic for eligible ciphers
* Remove unused method to check matching username
* PM-20608 update styling for excludedCredentials (#14444)
* PM-20608 update styling for excludedCredentials
* Have flow correctly move to creation for excluded cipher
* Remove duplicate confirmNeCredential call
* Revert fido2-authenticator changes and move the excluded check
* Create a separate component for excluded cipher view
* Display traffic light MacOS buttons when the vault is locked (#14673)
* Remove unneccessary filter for excludedCiphers
* Remove dead code from the excluded ciphers work
* Remove excludedCipher checks from fido2 create and vault
* Remove excludedCipher remnants from vault and simplify create cipher logic
* Move cipherHasNoOtherPasskeys to shared fido2-utils
* Remove all containsExcludedCipher references
* Use `bufferToString` to convert `userHandle`
---------
Co-authored-by: Jeffrey Holland <jholland@livefront.com>
Co-authored-by: Jeffrey Holland <124393578+jholland-livefront@users.noreply.github.com>
* Move modal files to `autofill` and rename dir to `credentials` (#14757)
* Show existing login items in the UI
* Filter available cipher results (#14399)
* Filter available cipher results
* Fix linting issues
* Update logic for eligible ciphers
* Remove unused method to check matching username
* PM-20608 update styling for excludedCredentials (#14444)
* PM-20608 update styling for excludedCredentials
* Have flow correctly move to creation for excluded cipher
* Remove duplicate confirmNeCredential call
* Revert fido2-authenticator changes and move the excluded check
* Create a separate component for excluded cipher view
* Display traffic light MacOS buttons when the vault is locked (#14673)
* Remove unneccessary filter for excludedCiphers
* Remove dead code from the excluded ciphers work
* Remove excludedCipher checks from fido2 create and vault
* Move modal files to `autofill` and rename dir to `credentials`
* Update merge issues
* Add tests for `cipherHasNoOtherPasskeys` (#14829)
* Adjust spacing to place new login button below other items (#14877)
* Adjust spacing to place new login button below other items
* Add correct design when no credentials available (#14879)
* Autofill/pm 21903 use translations everywhere for passkeys (#14908)
* Adjust spacing to place new login button below other items
* Add correct design when no credentials available
* Add correct design when no credentials available (#14879)
* Remove hardcoded strings and use translations in passkey flow
* Remove duplicate `select` translation
* Autofill/pm 21864 center unlock vault modal (#14867)
* Center the Locked Vault modal when using passkeys
* Revert swift changes and handle offscreen modals
* Remove comments
* Add rustup for cicd to work (#15055)
* Hide credentials that are in the bin (#15034)
* Add tests for passkey components (#15185)
* Add tests for passkey components
* Reuse cipher in chooseCipher tests and simplify mock creation
* Autofill/pm 22821 center vault modal (#15243)
* Center the vault modal for passkeys
* Add comments and fix electron-builder.json
* Set values to Int32 in the ternaries
* Refactor Fido2 Components (#15105)
* Refactor Fido2 Components
* Address error message and missing session
* Address remaining missing session
* Reset modals so subsequent creates work (#15145)
* Fix broken test
* Rename relevantCiphers to displayedCiphers
* Clean up heading settings, errors, and other concerns
* Address missing comments and throw error in try block
* fix type issue for SimpleDialogType
* fix type issue for SimpleDialogType
* Revert new type
* try using as null to satisfy type issue
* Remove use of firstValueFrom in create component
* PM-22476: Show config UI while enabling Bitwarden (#15149)
* Show config ui while enabling Bitwarden
* locals
* Added Localizable strings
* Changed the linebreakmode
* Removed swedish locals
* Add provisioning profile values to electron build (#15412)
* Address BitwardenShield icon issue
* Fix fido2-vault component
* Display the vault modal when selecting Bitwarden... (#15257)
* Passkeys filtering breaks on SSH keys (#15448)
* Display the blue header on the locked vault passkey flow (#15655)
* PM-23848: Use the MacOS UI-friendly API instead (#15650)
* Implement prepareInterfaceToProvideCredential
* Implement prepareInterfaceToProvideCredential
* Implement prepareInterfaceToProvideCredential
* Implement prepareInterfaceToProvideCredential
* Implement prepareInterfaceToProvideCredential
* Implement prepareInterfaceToProvideCredential
* Implement prepareInterfaceToProvideCredential
* Fix action text and close vault modal (#15634)
* Fix action text and close vault modal
* Fix broken tests
* Update SVG to support dark mode (#15805)
* When a locked vault is unlocked displays correctly (#15612)
* When a locked vault is unlocked displays correctly
* Keep old behavior while checking for recently unlocked vault
* Revert the electron-builder
* Simplify by using a simple redirect when vault unlocked
* Remove single use of `userSelectedCipher`
* Add a guard clause to unlock
* Revert to original spacing
* Add reactive guard to unlock vault
* Fix for passkey picker closing prematurely
* Remove unneeded root navigation in ensureUnlockedVault
* Fix vault not unlocking
* Update broken tests for lock component
* Add missing brace to preload.ts
* Run lint
* Added explainer
* Moved the explainer
* Tidying up readme
* Add feature flag to short-circuit the passkey provider (#16003)
* Add feature flag to short-circuit the passkey provider
* Check FF in renderer instead
* Lint fixes
* PM-22175: Improve launch of app + window positioning (#15658)
* Implement prepareInterfaceToProvideCredential
* Implement prepareInterfaceToProvideCredential
* Implement prepareInterfaceToProvideCredential
* Implement prepareInterfaceToProvideCredential
* Implement prepareInterfaceToProvideCredential
* Implement prepareInterfaceToProvideCredential
* Implement prepareInterfaceToProvideCredential
* Fix launch of app + window pos
* Wait for animation to complete and use proper position
* Wait for animation to complete and use proper position
* Added commentary
* Remove console.log
* Remove call to removed function
---------
Co-authored-by: Jeffrey Holland <jholland@livefront.com>
Co-authored-by: Jeffrey Holland <124393578+jholland-livefront@users.noreply.github.com>
* Update fido2-vault and fido2-service implementations
* Use tailwind-alike classes for new styles
* Add label to biticons in passkey modals
* Fix broken vault test
* Revert to original `isDev` function
* Add comment to lock component describing `disable-redirect` param
* Use tailwind classes instead of custom sticky header class
* Use standard `tw-z-10` for z-index
* Change log service levels
* Mock svg icons for CI
* Add back provisioning profiles
* Remove `--break-system-packages` and simplify commands
* Revert `cipherId` param for `confirmNewCredential`
* Remove placeholder UI
* Small improvements to the readme
* Remove optional userId and deprecated method
* Autofill should own the macos_provider (#16271)
* Autofill should own the macos_provider
* Autofill should own the macos_provider
* Remove unnecessary logs, no magic numbers, revert `cipherId?`
* Fixes for broken build
* Update test issues
* [BEEEP] Use tracing in macOS provider
* Update comments and add null check for ciphers
* Update status comments and readme
* Remove electron modal mode link
* Clarify modal mode use
* Add comment about usernames
* Add comment that we don't support extensions yet
* Added comment about base64 format
* Use NO_CALLBACK_INDICATOR
* cb -> callback
* Update apps/desktop/desktop_native/napi/src/lib.rs
Co-authored-by: neuronull <9162534+neuronull@users.noreply.github.com>
* Clean up Fido2Create subscriptions and update comments
* added comment to clarify silent exception
* Add comments
* clean up unwrap()
* set log level filter to INFO
* Address modal popup issue
* plutil on Info.plist
* Adhere to style guides
* Fix broken lock ui component tests
* Fix broken lock ui component tests
* Added codeowners entry
* logservice.warning -> debug
* Uint8Array -> ArrayBuffer
* Remove autofill entitlement
* Fix linting issues
* Fix arm build issue
* Adjust build command
* Add missing entitlement
* revert missing entitlement change
* Add proper autofill entitlements
* Remove autofill extension from mas builds
* Run rust formatter
---------
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
Co-authored-by: Jeffrey Holland <124393578+jholland-livefront@users.noreply.github.com>
Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
Co-authored-by: Colton Hurst <colton@coltonhurst.com>
Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>
Co-authored-by: Evan Bassler <evanbassler@Mac.attlocal.net>
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
Co-authored-by: Nathan Ansel <nathan@livefront.com>
Co-authored-by: Jeffrey Holland <jholland@livefront.com>
Co-authored-by: Robyn MacCallum <robyntmaccallum@gmail.com>
Co-authored-by: neuronull <9162534+neuronull@users.noreply.github.com>
This commit is contained in:
@@ -45,11 +45,14 @@ import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/co
|
||||
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
|
||||
|
||||
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
|
||||
import { reactiveUnlockVaultGuard } from "../autofill/guards/reactive-vault-guard";
|
||||
import { Fido2CreateComponent } from "../autofill/modal/credentials/fido2-create.component";
|
||||
import { Fido2ExcludedCiphersComponent } from "../autofill/modal/credentials/fido2-excluded-ciphers.component";
|
||||
import { Fido2VaultComponent } from "../autofill/modal/credentials/fido2-vault.component";
|
||||
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
|
||||
import { VaultV2Component } from "../vault/app/vault/vault-v2.component";
|
||||
import { VaultComponent } from "../vault/app/vault-v3/vault.component";
|
||||
|
||||
import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component";
|
||||
import { DesktopLayoutComponent } from "./layout/desktop-layout.component";
|
||||
import { SendComponent } from "./tools/send/send.component";
|
||||
import { SendV2Component } from "./tools/send-v2/send-v2.component";
|
||||
@@ -120,12 +123,16 @@ const routes: Routes = [
|
||||
canActivate: [authGuard],
|
||||
},
|
||||
{
|
||||
path: "passkeys",
|
||||
component: Fido2PlaceholderComponent,
|
||||
path: "fido2-assertion",
|
||||
component: Fido2VaultComponent,
|
||||
},
|
||||
{
|
||||
path: "passkeys",
|
||||
component: Fido2PlaceholderComponent,
|
||||
path: "fido2-creation",
|
||||
component: Fido2CreateComponent,
|
||||
},
|
||||
{
|
||||
path: "fido2-excluded",
|
||||
component: Fido2ExcludedCiphersComponent,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
@@ -271,7 +278,7 @@ const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: "lock",
|
||||
canActivate: [lockGuard()],
|
||||
canActivate: [lockGuard(), reactiveUnlockVaultGuard],
|
||||
data: {
|
||||
pageIcon: LockIcon,
|
||||
pageTitle: {
|
||||
|
||||
@@ -104,7 +104,7 @@ const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours
|
||||
<ng-template #exportVault></ng-template>
|
||||
<ng-template #appGenerator></ng-template>
|
||||
<ng-template #loginApproval></ng-template>
|
||||
<app-header></app-header>
|
||||
<app-header *ngIf="showHeader$ | async"></app-header>
|
||||
|
||||
<div id="container">
|
||||
<div class="loading" *ngIf="loading">
|
||||
@@ -141,6 +141,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
@ViewChild("loginApproval", { read: ViewContainerRef, static: true })
|
||||
loginApprovalModalRef: ViewContainerRef;
|
||||
|
||||
showHeader$ = this.accountService.showHeader$;
|
||||
loading = false;
|
||||
|
||||
private lastActivity: Date = null;
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
DesktopFido2UserInterfaceService,
|
||||
DesktopFido2UserInterfaceSession,
|
||||
} from "../../autofill/services/desktop-fido2-user-interface.service";
|
||||
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div
|
||||
style="background:white; display:flex; justify-content: center; align-items: center; flex-direction: column"
|
||||
>
|
||||
<h1 style="color: black">Select your passkey</h1>
|
||||
|
||||
<div *ngFor="let item of cipherIds$ | async">
|
||||
<button
|
||||
style="color:black; padding: 10px 20px; border: 1px solid blue; margin: 10px"
|
||||
bitButton
|
||||
type="button"
|
||||
buttonType="secondary"
|
||||
(click)="chooseCipher(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<button
|
||||
style="color:black; padding: 10px 20px; border: 1px solid black; margin: 10px"
|
||||
bitButton
|
||||
type="button"
|
||||
buttonType="secondary"
|
||||
(click)="confirmPasskey()"
|
||||
>
|
||||
Confirm passkey
|
||||
</button>
|
||||
<button
|
||||
style="color:black; padding: 10px 20px; border: 1px solid black; margin: 10px"
|
||||
bitButton
|
||||
type="button"
|
||||
buttonType="secondary"
|
||||
(click)="closeModal()"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class Fido2PlaceholderComponent implements OnInit, OnDestroy {
|
||||
session?: DesktopFido2UserInterfaceSession = null;
|
||||
private cipherIdsSubject = new BehaviorSubject<string[]>([]);
|
||||
cipherIds$: Observable<string[]>;
|
||||
|
||||
constructor(
|
||||
private readonly desktopSettingsService: DesktopSettingsService,
|
||||
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
|
||||
private readonly router: Router,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.session = this.fido2UserInterfaceService.getCurrentSession();
|
||||
this.cipherIds$ = this.session?.availableCipherIds$;
|
||||
}
|
||||
|
||||
async chooseCipher(cipherId: string) {
|
||||
// For now: Set UV to true
|
||||
this.session?.confirmChosenCipher(cipherId, true);
|
||||
|
||||
await this.router.navigate(["/"]);
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.cipherIdsSubject.complete(); // Clean up the BehaviorSubject
|
||||
}
|
||||
|
||||
async confirmPasskey() {
|
||||
try {
|
||||
// Retrieve the current UI session to control the flow
|
||||
if (!this.session) {
|
||||
// todo: handle error
|
||||
throw new Error("No session found");
|
||||
}
|
||||
|
||||
// If we want to we could submit information to the session in order to create the credential
|
||||
// const cipher = await session.createCredential({
|
||||
// userHandle: "userHandle2",
|
||||
// userName: "username2",
|
||||
// credentialName: "zxsd2",
|
||||
// rpId: "webauthn.io",
|
||||
// userVerification: true,
|
||||
// });
|
||||
|
||||
this.session.notifyConfirmNewCredential(true);
|
||||
|
||||
// Not sure this clean up should happen here or in session.
|
||||
// The session currently toggles modal on and send us here
|
||||
// But if this route is somehow opened outside of session we want to make sure we clean up?
|
||||
await this.router.navigate(["/"]);
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
} catch {
|
||||
// TODO: Handle error appropriately
|
||||
}
|
||||
}
|
||||
|
||||
async closeModal() {
|
||||
await this.router.navigate(["/"]);
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
|
||||
this.session.notifyConfirmNewCredential(false);
|
||||
// little bit hacky:
|
||||
this.session.confirmChosenCipher(null);
|
||||
}
|
||||
}
|
||||
@@ -345,6 +345,7 @@ const safeProviders: SafeProvider[] = [
|
||||
ConfigService,
|
||||
Fido2AuthenticatorServiceAbstraction,
|
||||
AccountService,
|
||||
AuthService,
|
||||
PlatformUtilsService,
|
||||
],
|
||||
}),
|
||||
|
||||
42
apps/desktop/src/autofill/guards/reactive-vault-guard.ts
Normal file
42
apps/desktop/src/autofill/guards/reactive-vault-guard.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { CanActivateFn, Router } from "@angular/router";
|
||||
import { combineLatest, map, switchMap, distinctUntilChanged } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
|
||||
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
|
||||
|
||||
/**
|
||||
* Reactive route guard that redirects to the unlocked vault.
|
||||
* Redirects to vault when unlocked in main window.
|
||||
*/
|
||||
export const reactiveUnlockVaultGuard: CanActivateFn = () => {
|
||||
const router = inject(Router);
|
||||
const authService = inject(AuthService);
|
||||
const accountService = inject(AccountService);
|
||||
const desktopSettingsService = inject(DesktopSettingsService);
|
||||
|
||||
return combineLatest([accountService.activeAccount$, desktopSettingsService.modalMode$]).pipe(
|
||||
switchMap(([account, modalMode]) => {
|
||||
if (!account) {
|
||||
return [true];
|
||||
}
|
||||
|
||||
// Monitor when the vault has been unlocked.
|
||||
return authService.authStatusFor$(account.id).pipe(
|
||||
distinctUntilChanged(),
|
||||
map((authStatus) => {
|
||||
// If vault is unlocked and we're not in modal mode, redirect to vault
|
||||
if (authStatus === AuthenticationStatus.Unlocked && !modalMode?.isModalModeActive) {
|
||||
return router.createUrlTree(["/vault"]);
|
||||
}
|
||||
|
||||
// Otherwise keep user on the lock screen
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
<div class="tw-flex tw-flex-col tw-h-full tw-bg-background-alt">
|
||||
<bit-section
|
||||
disableMargin
|
||||
class="tw-sticky tw-top-0 tw-z-10 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300"
|
||||
>
|
||||
<bit-section-header class="tw-app-region-drag tw-bg-background">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-icon [icon]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-icon>
|
||||
|
||||
<h2 bitTypography="h4" class="tw-font-semibold tw-text-lg">
|
||||
{{ "savePasskeyQuestion" | i18n }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
slot="end"
|
||||
class="tw-app-region-no-drag tw-mb-4 tw-mr-2"
|
||||
(click)="closeModal()"
|
||||
[label]="'close' | i18n"
|
||||
>
|
||||
{{ "close" | i18n }}
|
||||
</button>
|
||||
</bit-section-header>
|
||||
</bit-section>
|
||||
|
||||
<bit-section class="tw-bg-background-alt tw-p-4 tw-flex tw-flex-col">
|
||||
<div *ngIf="(ciphers$ | async)?.length === 0; else hasCiphers">
|
||||
<div class="tw-flex tw-items-center tw-flex-col tw-p-12 tw-gap-4">
|
||||
<bit-icon [icon]="Icons.NoResults" class="tw-text-main"></bit-icon>
|
||||
<div class="tw-flex tw-flex-col tw-gap-2">
|
||||
{{ "noMatchingLoginsForSite" | i18n }}
|
||||
</div>
|
||||
<button bitButton type="button" buttonType="primary" (click)="confirmPasskey()">
|
||||
{{ "savePasskeyNewLogin" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #hasCiphers>
|
||||
<bit-item *ngFor="let c of ciphers$ | async" class="">
|
||||
<button type="button" bit-item-content (click)="addCredentialToCipher(c)">
|
||||
<app-vault-icon [cipher]="c" slot="start"></app-vault-icon>
|
||||
<button bitLink [title]="c.name" type="button">
|
||||
{{ c.name }}
|
||||
</button>
|
||||
<span slot="secondary">{{ c.subTitle }}</span>
|
||||
<span bitBadge slot="end">{{ "save" | i18n }}</span>
|
||||
</button>
|
||||
</bit-item>
|
||||
<bit-item class="">
|
||||
<button
|
||||
bitLink
|
||||
linkType="primary"
|
||||
type="button"
|
||||
bit-item-content
|
||||
(click)="confirmPasskey()"
|
||||
>
|
||||
<a bitLink linkType="primary" class="tw-font-medium tw-text-base">
|
||||
{{ "saveNewPasskey" | i18n }}
|
||||
</a>
|
||||
</button>
|
||||
</bit-item>
|
||||
</ng-template>
|
||||
</bit-section>
|
||||
</div>
|
||||
@@ -0,0 +1,238 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { DesktopAutofillService } from "../../../autofill/services/desktop-autofill.service";
|
||||
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
|
||||
import {
|
||||
DesktopFido2UserInterfaceService,
|
||||
DesktopFido2UserInterfaceSession,
|
||||
} from "../../services/desktop-fido2-user-interface.service";
|
||||
|
||||
import { Fido2CreateComponent } from "./fido2-create.component";
|
||||
|
||||
describe("Fido2CreateComponent", () => {
|
||||
let component: Fido2CreateComponent;
|
||||
let mockDesktopSettingsService: MockProxy<DesktopSettingsService>;
|
||||
let mockFido2UserInterfaceService: MockProxy<DesktopFido2UserInterfaceService>;
|
||||
let mockAccountService: MockProxy<AccountService>;
|
||||
let mockCipherService: MockProxy<CipherService>;
|
||||
let mockDesktopAutofillService: MockProxy<DesktopAutofillService>;
|
||||
let mockDialogService: MockProxy<DialogService>;
|
||||
let mockDomainSettingsService: MockProxy<DomainSettingsService>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockPasswordRepromptService: MockProxy<PasswordRepromptService>;
|
||||
let mockRouter: MockProxy<Router>;
|
||||
let mockSession: MockProxy<DesktopFido2UserInterfaceSession>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
|
||||
const activeAccountSubject = new BehaviorSubject<Account | null>({
|
||||
id: "test-user-id" as UserId,
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDesktopSettingsService = mock<DesktopSettingsService>();
|
||||
mockFido2UserInterfaceService = mock<DesktopFido2UserInterfaceService>();
|
||||
mockAccountService = mock<AccountService>();
|
||||
mockCipherService = mock<CipherService>();
|
||||
mockDesktopAutofillService = mock<DesktopAutofillService>();
|
||||
mockDialogService = mock<DialogService>();
|
||||
mockDomainSettingsService = mock<DomainSettingsService>();
|
||||
mockLogService = mock<LogService>();
|
||||
mockPasswordRepromptService = mock<PasswordRepromptService>();
|
||||
mockRouter = mock<Router>();
|
||||
mockSession = mock<DesktopFido2UserInterfaceSession>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
|
||||
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession);
|
||||
mockAccountService.activeAccount$ = activeAccountSubject;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
Fido2CreateComponent,
|
||||
{ provide: DesktopSettingsService, useValue: mockDesktopSettingsService },
|
||||
{ provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: CipherService, useValue: mockCipherService },
|
||||
{ provide: DesktopAutofillService, useValue: mockDesktopAutofillService },
|
||||
{ provide: DialogService, useValue: mockDialogService },
|
||||
{ provide: DomainSettingsService, useValue: mockDomainSettingsService },
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: PasswordRepromptService, useValue: mockPasswordRepromptService },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
component = TestBed.inject(Fido2CreateComponent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createMockCiphers(): CipherView[] {
|
||||
const cipher1 = new CipherView();
|
||||
cipher1.id = "cipher-1";
|
||||
cipher1.name = "Test Cipher 1";
|
||||
cipher1.type = CipherType.Login;
|
||||
cipher1.login = {
|
||||
username: "test1@example.com",
|
||||
uris: [{ uri: "https://example.com", match: null }],
|
||||
matchesUri: jest.fn().mockReturnValue(true),
|
||||
get hasFido2Credentials() {
|
||||
return false;
|
||||
},
|
||||
} as any;
|
||||
cipher1.reprompt = CipherRepromptType.None;
|
||||
cipher1.deletedDate = null;
|
||||
|
||||
return [cipher1];
|
||||
}
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
beforeEach(() => {
|
||||
mockSession.getRpId.mockResolvedValue("example.com");
|
||||
Object.defineProperty(mockDesktopAutofillService, "lastRegistrationRequest", {
|
||||
get: jest.fn().mockReturnValue({
|
||||
userHandle: new Uint8Array([1, 2, 3]),
|
||||
}),
|
||||
configurable: true,
|
||||
});
|
||||
mockDomainSettingsService.getUrlEquivalentDomains.mockReturnValue(of(new Set<string>()));
|
||||
});
|
||||
|
||||
it("should initialize session and set show header to false", async () => {
|
||||
const mockCiphers = createMockCiphers();
|
||||
mockCipherService.getAllDecrypted.mockResolvedValue(mockCiphers);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled();
|
||||
expect(component.session).toBe(mockSession);
|
||||
});
|
||||
|
||||
it("should show error dialog when no active session found", async () => {
|
||||
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(null);
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "unableToSavePasskey" },
|
||||
content: { key: "closeThisBitwardenWindow" },
|
||||
type: "danger",
|
||||
acceptButtonText: { key: "closeThisWindow" },
|
||||
acceptAction: expect.any(Function),
|
||||
cancelButtonText: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("addCredentialToCipher", () => {
|
||||
beforeEach(() => {
|
||||
component.session = mockSession;
|
||||
});
|
||||
|
||||
it("should add passkey to cipher", async () => {
|
||||
const cipher = createMockCiphers()[0];
|
||||
|
||||
await component.addCredentialToCipher(cipher);
|
||||
|
||||
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true, cipher);
|
||||
});
|
||||
|
||||
it("should not add passkey when password reprompt is cancelled", async () => {
|
||||
const cipher = createMockCiphers()[0];
|
||||
cipher.reprompt = CipherRepromptType.Password;
|
||||
mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(false);
|
||||
|
||||
await component.addCredentialToCipher(cipher);
|
||||
|
||||
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false, cipher);
|
||||
});
|
||||
|
||||
it("should call openSimpleDialog when cipher already has a fido2 credential", async () => {
|
||||
const cipher = createMockCiphers()[0];
|
||||
Object.defineProperty(cipher.login, "hasFido2Credentials", {
|
||||
get: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
|
||||
await component.addCredentialToCipher(cipher);
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "overwritePasskey" },
|
||||
content: { key: "alreadyContainsPasskey" },
|
||||
type: "warning",
|
||||
});
|
||||
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true, cipher);
|
||||
});
|
||||
|
||||
it("should not add passkey when user cancels overwrite dialog", async () => {
|
||||
const cipher = createMockCiphers()[0];
|
||||
Object.defineProperty(cipher.login, "hasFido2Credentials", {
|
||||
get: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
await component.addCredentialToCipher(cipher);
|
||||
|
||||
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false, cipher);
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmPasskey", () => {
|
||||
beforeEach(() => {
|
||||
component.session = mockSession;
|
||||
});
|
||||
|
||||
it("should confirm passkey creation successfully", async () => {
|
||||
await component.confirmPasskey();
|
||||
|
||||
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should call openSimpleDialog when session is null", async () => {
|
||||
component.session = null;
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
await component.confirmPasskey();
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: { key: "unableToSavePasskey" },
|
||||
content: { key: "closeThisBitwardenWindow" },
|
||||
type: "danger",
|
||||
acceptButtonText: { key: "closeThisWindow" },
|
||||
acceptAction: expect.any(Function),
|
||||
cancelButtonText: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeModal", () => {
|
||||
it("should close modal and notify session", async () => {
|
||||
component.session = mockSession;
|
||||
|
||||
await component.closeModal();
|
||||
|
||||
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false);
|
||||
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from "@angular/core";
|
||||
import { RouterModule, Router } from "@angular/router";
|
||||
import { combineLatest, map, Observable, Subject, switchMap } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { BitwardenShield, NoResults } from "@bitwarden/assets/svg";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
DialogService,
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
IconModule,
|
||||
ItemModule,
|
||||
SectionComponent,
|
||||
TableModule,
|
||||
SectionHeaderComponent,
|
||||
BitIconButtonComponent,
|
||||
SimpleDialogOptions,
|
||||
} from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { DesktopAutofillService } from "../../../autofill/services/desktop-autofill.service";
|
||||
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
|
||||
import {
|
||||
DesktopFido2UserInterfaceService,
|
||||
DesktopFido2UserInterfaceSession,
|
||||
} from "../../services/desktop-fido2-user-interface.service";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
SectionHeaderComponent,
|
||||
BitIconButtonComponent,
|
||||
TableModule,
|
||||
JslibModule,
|
||||
IconModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
SectionComponent,
|
||||
ItemModule,
|
||||
BadgeModule,
|
||||
],
|
||||
templateUrl: "fido2-create.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Fido2CreateComponent implements OnInit, OnDestroy {
|
||||
session?: DesktopFido2UserInterfaceSession = null;
|
||||
ciphers$: Observable<CipherView[]>;
|
||||
private destroy$ = new Subject<void>();
|
||||
readonly Icons = { BitwardenShield, NoResults };
|
||||
|
||||
private get DIALOG_MESSAGES() {
|
||||
return {
|
||||
unexpectedErrorShort: {
|
||||
title: { key: "unexpectedErrorShort" },
|
||||
content: { key: "closeThisBitwardenWindow" },
|
||||
type: "danger",
|
||||
acceptButtonText: { key: "closeThisWindow" },
|
||||
cancelButtonText: null as null,
|
||||
acceptAction: async () => this.dialogService.closeAll(),
|
||||
},
|
||||
unableToSavePasskey: {
|
||||
title: { key: "unableToSavePasskey" },
|
||||
content: { key: "closeThisBitwardenWindow" },
|
||||
type: "danger",
|
||||
acceptButtonText: { key: "closeThisWindow" },
|
||||
cancelButtonText: null as null,
|
||||
acceptAction: async () => this.dialogService.closeAll(),
|
||||
},
|
||||
overwritePasskey: {
|
||||
title: { key: "overwritePasskey" },
|
||||
content: { key: "alreadyContainsPasskey" },
|
||||
type: "warning",
|
||||
},
|
||||
} as const satisfies Record<string, SimpleDialogOptions>;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly desktopSettingsService: DesktopSettingsService,
|
||||
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly cipherService: CipherService,
|
||||
private readonly desktopAutofillService: DesktopAutofillService,
|
||||
private readonly dialogService: DialogService,
|
||||
private readonly domainSettingsService: DomainSettingsService,
|
||||
private readonly passwordRepromptService: PasswordRepromptService,
|
||||
private readonly router: Router,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.session = this.fido2UserInterfaceService.getCurrentSession();
|
||||
|
||||
if (this.session) {
|
||||
const rpid = await this.session.getRpId();
|
||||
this.initializeCiphersObservable(rpid);
|
||||
} else {
|
||||
await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey);
|
||||
}
|
||||
}
|
||||
|
||||
async ngOnDestroy(): Promise<void> {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
await this.closeModal();
|
||||
}
|
||||
|
||||
async addCredentialToCipher(cipher: CipherView): Promise<void> {
|
||||
const isConfirmed = await this.validateCipherAccess(cipher);
|
||||
|
||||
try {
|
||||
if (!this.session) {
|
||||
throw new Error("Missing session");
|
||||
}
|
||||
|
||||
this.session.notifyConfirmCreateCredential(isConfirmed, cipher);
|
||||
} catch {
|
||||
await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.closeModal();
|
||||
}
|
||||
|
||||
async confirmPasskey(): Promise<void> {
|
||||
try {
|
||||
if (!this.session) {
|
||||
throw new Error("Missing session");
|
||||
}
|
||||
|
||||
this.session.notifyConfirmCreateCredential(true);
|
||||
} catch {
|
||||
await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey);
|
||||
}
|
||||
|
||||
await this.closeModal();
|
||||
}
|
||||
|
||||
async closeModal(): Promise<void> {
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
await this.accountService.setShowHeader(true);
|
||||
|
||||
if (this.session) {
|
||||
this.session.notifyConfirmCreateCredential(false);
|
||||
this.session.confirmChosenCipher(null);
|
||||
}
|
||||
|
||||
await this.router.navigate(["/"]);
|
||||
}
|
||||
|
||||
private initializeCiphersObservable(rpid: string): void {
|
||||
const lastRegistrationRequest = this.desktopAutofillService.lastRegistrationRequest;
|
||||
|
||||
if (!lastRegistrationRequest || !rpid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userHandle = Fido2Utils.bufferToString(
|
||||
new Uint8Array(lastRegistrationRequest.userHandle),
|
||||
);
|
||||
|
||||
this.ciphers$ = combineLatest([
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
this.domainSettingsService.getUrlEquivalentDomains(rpid),
|
||||
]).pipe(
|
||||
switchMap(async ([activeUserId, equivalentDomains]) => {
|
||||
if (!activeUserId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const allCiphers = await this.cipherService.getAllDecrypted(activeUserId);
|
||||
return allCiphers.filter(
|
||||
(cipher) =>
|
||||
cipher != null &&
|
||||
cipher.type == CipherType.Login &&
|
||||
cipher.login?.matchesUri(rpid, equivalentDomains) &&
|
||||
Fido2Utils.cipherHasNoOtherPasskeys(cipher, userHandle) &&
|
||||
!cipher.deletedDate,
|
||||
);
|
||||
} catch {
|
||||
await this.showErrorDialog(this.DIALOG_MESSAGES.unexpectedErrorShort);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async validateCipherAccess(cipher: CipherView): Promise<boolean> {
|
||||
if (cipher.login.hasFido2Credentials) {
|
||||
const overwriteConfirmed = await this.dialogService.openSimpleDialog(
|
||||
this.DIALOG_MESSAGES.overwritePasskey,
|
||||
);
|
||||
|
||||
if (!overwriteConfirmed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (cipher.reprompt) {
|
||||
return this.passwordRepromptService.showPasswordPrompt();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async showErrorDialog(config: SimpleDialogOptions): Promise<void> {
|
||||
await this.dialogService.openSimpleDialog(config);
|
||||
await this.closeModal();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<div class="tw-flex tw-flex-col tw-h-full">
|
||||
<bit-section
|
||||
disableMargin
|
||||
class="tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300"
|
||||
>
|
||||
<bit-section-header class="tw-app-region-drag tw-bg-background">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-icon [icon]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-icon>
|
||||
|
||||
<h2 bitTypography="h4" class="tw-font-semibold tw-text-lg">
|
||||
{{ "savePasskeyQuestion" | i18n }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
slot="end"
|
||||
class="tw-app-region-no-drag tw-mb-4 tw-mr-2"
|
||||
(click)="closeModal()"
|
||||
[label]="'close' | i18n"
|
||||
>
|
||||
{{ "close" | i18n }}
|
||||
</button>
|
||||
</bit-section-header>
|
||||
</bit-section>
|
||||
|
||||
<div class="tw-h-full tw-items-start">
|
||||
<bit-section
|
||||
class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-start tw-items-center tw-gap-2 tw-h-full tw-px-5"
|
||||
>
|
||||
<div class="tw-flex tw-items-center tw-flex-col tw-p-12 tw-gap-4">
|
||||
<bit-icon [icon]="Icons.NoResults" class="tw-text-main"></bit-icon>
|
||||
<div class="tw-flex tw-flex-col tw-gap-2">
|
||||
<b>{{ "passkeyAlreadyExists" | i18n }}</b>
|
||||
{{ "applicationDoesNotSupportDuplicates" | i18n }}
|
||||
</div>
|
||||
<button bitButton type="button" buttonType="primary" (click)="closeModal()">
|
||||
{{ "close" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</bit-section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NO_ERRORS_SCHEMA } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
|
||||
import {
|
||||
DesktopFido2UserInterfaceService,
|
||||
DesktopFido2UserInterfaceSession,
|
||||
} from "../../services/desktop-fido2-user-interface.service";
|
||||
|
||||
import { Fido2ExcludedCiphersComponent } from "./fido2-excluded-ciphers.component";
|
||||
|
||||
describe("Fido2ExcludedCiphersComponent", () => {
|
||||
let component: Fido2ExcludedCiphersComponent;
|
||||
let fixture: ComponentFixture<Fido2ExcludedCiphersComponent>;
|
||||
let mockDesktopSettingsService: MockProxy<DesktopSettingsService>;
|
||||
let mockFido2UserInterfaceService: MockProxy<DesktopFido2UserInterfaceService>;
|
||||
let mockAccountService: MockProxy<AccountService>;
|
||||
let mockRouter: MockProxy<Router>;
|
||||
let mockSession: MockProxy<DesktopFido2UserInterfaceSession>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDesktopSettingsService = mock<DesktopSettingsService>();
|
||||
mockFido2UserInterfaceService = mock<DesktopFido2UserInterfaceService>();
|
||||
mockAccountService = mock<AccountService>();
|
||||
mockRouter = mock<Router>();
|
||||
mockSession = mock<DesktopFido2UserInterfaceSession>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
|
||||
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Fido2ExcludedCiphersComponent],
|
||||
providers: [
|
||||
{ provide: DesktopSettingsService, useValue: mockDesktopSettingsService },
|
||||
{ provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Fido2ExcludedCiphersComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
it("should initialize session", async () => {
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled();
|
||||
expect(component.session).toBe(mockSession);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeModal", () => {
|
||||
it("should close modal and notify session when session exists", async () => {
|
||||
component.session = mockSession;
|
||||
|
||||
await component.closeModal();
|
||||
|
||||
expect(mockDesktopSettingsService.setModalMode).toHaveBeenCalledWith(false);
|
||||
expect(mockAccountService.setShowHeader).toHaveBeenCalledWith(true);
|
||||
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from "@angular/core";
|
||||
import { RouterModule, Router } from "@angular/router";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { BitwardenShield, NoResults } from "@bitwarden/assets/svg";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import {
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
IconModule,
|
||||
ItemModule,
|
||||
SectionComponent,
|
||||
TableModule,
|
||||
SectionHeaderComponent,
|
||||
BitIconButtonComponent,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
|
||||
import {
|
||||
DesktopFido2UserInterfaceService,
|
||||
DesktopFido2UserInterfaceSession,
|
||||
} from "../../services/desktop-fido2-user-interface.service";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
SectionHeaderComponent,
|
||||
BitIconButtonComponent,
|
||||
TableModule,
|
||||
JslibModule,
|
||||
IconModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
SectionComponent,
|
||||
ItemModule,
|
||||
BadgeModule,
|
||||
],
|
||||
templateUrl: "fido2-excluded-ciphers.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Fido2ExcludedCiphersComponent implements OnInit, OnDestroy {
|
||||
session?: DesktopFido2UserInterfaceSession = null;
|
||||
readonly Icons = { BitwardenShield, NoResults };
|
||||
|
||||
constructor(
|
||||
private readonly desktopSettingsService: DesktopSettingsService,
|
||||
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly router: Router,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.session = this.fido2UserInterfaceService.getCurrentSession();
|
||||
}
|
||||
|
||||
async ngOnDestroy(): Promise<void> {
|
||||
await this.closeModal();
|
||||
}
|
||||
|
||||
async closeModal(): Promise<void> {
|
||||
// Clean up modal state
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
await this.accountService.setShowHeader(true);
|
||||
|
||||
// Clean up session state
|
||||
if (this.session) {
|
||||
this.session.notifyConfirmCreateCredential(false);
|
||||
this.session.confirmChosenCipher(null);
|
||||
}
|
||||
|
||||
// Navigate away
|
||||
await this.router.navigate(["/"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<div class="tw-flex tw-flex-col tw-h-full">
|
||||
<bit-section
|
||||
disableMargin
|
||||
class="tw-sticky tw-top-0 tw-z-10 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300"
|
||||
>
|
||||
<bit-section-header class="tw-app-region-drag tw-bg-background">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<bit-icon [icon]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-icon>
|
||||
|
||||
<h2 bitTypography="h4" class="tw-font-semibold tw-text-lg">{{ "passkeyLogin" | i18n }}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
slot="end"
|
||||
class="tw-app-region-no-drag tw-mb-4 tw-mr-2"
|
||||
(click)="closeModal()"
|
||||
[label]="'close' | i18n"
|
||||
>
|
||||
{{ "close" | i18n }}
|
||||
</button>
|
||||
</bit-section-header>
|
||||
</bit-section>
|
||||
|
||||
<bit-section class="tw-bg-background-alt tw-p-4 tw-flex tw-flex-col tw-grow">
|
||||
<bit-item *ngFor="let c of ciphers$ | async" class="">
|
||||
<button type="button" bit-item-content (click)="chooseCipher(c)">
|
||||
<app-vault-icon [cipher]="c" slot="start"></app-vault-icon>
|
||||
<button bitLink [title]="c.name" type="button">
|
||||
{{ c.name }}
|
||||
</button>
|
||||
<span slot="secondary">{{ c.subTitle }}</span>
|
||||
<span bitBadge slot="end">{{ "select" | i18n }}</span>
|
||||
</button>
|
||||
</bit-item>
|
||||
</bit-section>
|
||||
</div>
|
||||
@@ -0,0 +1,196 @@
|
||||
import { NO_ERRORS_SCHEMA } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
|
||||
import {
|
||||
DesktopFido2UserInterfaceService,
|
||||
DesktopFido2UserInterfaceSession,
|
||||
} from "../../services/desktop-fido2-user-interface.service";
|
||||
|
||||
import { Fido2VaultComponent } from "./fido2-vault.component";
|
||||
|
||||
describe("Fido2VaultComponent", () => {
|
||||
let component: Fido2VaultComponent;
|
||||
let fixture: ComponentFixture<Fido2VaultComponent>;
|
||||
let mockDesktopSettingsService: MockProxy<DesktopSettingsService>;
|
||||
let mockFido2UserInterfaceService: MockProxy<DesktopFido2UserInterfaceService>;
|
||||
let mockCipherService: MockProxy<CipherService>;
|
||||
let mockAccountService: MockProxy<AccountService>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockPasswordRepromptService: MockProxy<PasswordRepromptService>;
|
||||
let mockRouter: MockProxy<Router>;
|
||||
let mockSession: MockProxy<DesktopFido2UserInterfaceSession>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
|
||||
const mockActiveAccount = { id: "test-user-id", email: "test@example.com" };
|
||||
const mockCipherIds = ["cipher-1", "cipher-2", "cipher-3"];
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDesktopSettingsService = mock<DesktopSettingsService>();
|
||||
mockFido2UserInterfaceService = mock<DesktopFido2UserInterfaceService>();
|
||||
mockCipherService = mock<CipherService>();
|
||||
mockAccountService = mock<AccountService>();
|
||||
mockLogService = mock<LogService>();
|
||||
mockPasswordRepromptService = mock<PasswordRepromptService>();
|
||||
mockRouter = mock<Router>();
|
||||
mockSession = mock<DesktopFido2UserInterfaceSession>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
|
||||
mockAccountService.activeAccount$ = of(mockActiveAccount as Account);
|
||||
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession);
|
||||
mockSession.availableCipherIds$ = of(mockCipherIds);
|
||||
mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of([]));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Fido2VaultComponent],
|
||||
providers: [
|
||||
{ provide: DesktopSettingsService, useValue: mockDesktopSettingsService },
|
||||
{ provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService },
|
||||
{ provide: CipherService, useValue: mockCipherService },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: PasswordRepromptService, useValue: mockPasswordRepromptService },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Fido2VaultComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
const mockCiphers: any[] = [
|
||||
{
|
||||
id: "cipher-1",
|
||||
name: "Test Cipher 1",
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
username: "test1@example.com",
|
||||
},
|
||||
reprompt: CipherRepromptType.None,
|
||||
deletedDate: null,
|
||||
},
|
||||
{
|
||||
id: "cipher-2",
|
||||
name: "Test Cipher 2",
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
username: "test2@example.com",
|
||||
},
|
||||
reprompt: CipherRepromptType.None,
|
||||
deletedDate: null,
|
||||
},
|
||||
{
|
||||
id: "cipher-3",
|
||||
name: "Test Cipher 3",
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
username: "test3@example.com",
|
||||
},
|
||||
reprompt: CipherRepromptType.Password,
|
||||
deletedDate: null,
|
||||
},
|
||||
];
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
it("should initialize session and load ciphers successfully", async () => {
|
||||
mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of(mockCiphers));
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled();
|
||||
expect(component.session).toBe(mockSession);
|
||||
expect(component.cipherIds$).toBe(mockSession.availableCipherIds$);
|
||||
expect(mockCipherService.cipherListViews$).toHaveBeenCalledWith(mockActiveAccount.id);
|
||||
});
|
||||
|
||||
it("should handle when no active session found", async () => {
|
||||
mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(null);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.session).toBeNull();
|
||||
});
|
||||
|
||||
it("should filter out deleted ciphers", async () => {
|
||||
const ciphersWithDeleted = [
|
||||
...mockCiphers.slice(0, 1),
|
||||
{ ...mockCiphers[1], deletedDate: new Date() },
|
||||
...mockCiphers.slice(2),
|
||||
];
|
||||
mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of(ciphersWithDeleted));
|
||||
|
||||
await component.ngOnInit();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
let ciphersResult: CipherView[] = [];
|
||||
component.ciphers$.subscribe((ciphers) => {
|
||||
ciphersResult = ciphers;
|
||||
});
|
||||
|
||||
expect(ciphersResult).toHaveLength(2);
|
||||
expect(ciphersResult.every((cipher) => !cipher.deletedDate)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chooseCipher", () => {
|
||||
const cipher = mockCiphers[0];
|
||||
|
||||
beforeEach(() => {
|
||||
component.session = mockSession;
|
||||
});
|
||||
|
||||
it("should choose cipher when access is validated", async () => {
|
||||
cipher.reprompt = CipherRepromptType.None;
|
||||
|
||||
await component.chooseCipher(cipher);
|
||||
|
||||
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, true);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]);
|
||||
});
|
||||
|
||||
it("should prompt for password when cipher requires reprompt", async () => {
|
||||
cipher.reprompt = CipherRepromptType.Password;
|
||||
mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(true);
|
||||
|
||||
await component.chooseCipher(cipher);
|
||||
|
||||
expect(mockPasswordRepromptService.showPasswordPrompt).toHaveBeenCalled();
|
||||
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, true);
|
||||
});
|
||||
|
||||
it("should not choose cipher when password reprompt is cancelled", async () => {
|
||||
cipher.reprompt = CipherRepromptType.Password;
|
||||
mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(false);
|
||||
|
||||
await component.chooseCipher(cipher);
|
||||
|
||||
expect(mockPasswordRepromptService.showPasswordPrompt).toHaveBeenCalled();
|
||||
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeModal", () => {
|
||||
it("should close modal and notify session", async () => {
|
||||
component.session = mockSession;
|
||||
|
||||
await component.closeModal();
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]);
|
||||
expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false);
|
||||
expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from "@angular/core";
|
||||
import { RouterModule, Router } from "@angular/router";
|
||||
import {
|
||||
firstValueFrom,
|
||||
map,
|
||||
combineLatest,
|
||||
of,
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
Subject,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { BitwardenShield } from "@bitwarden/assets/svg";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
IconModule,
|
||||
ItemModule,
|
||||
SectionComponent,
|
||||
TableModule,
|
||||
BitIconButtonComponent,
|
||||
SectionHeaderComponent,
|
||||
} from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service";
|
||||
import {
|
||||
DesktopFido2UserInterfaceService,
|
||||
DesktopFido2UserInterfaceSession,
|
||||
} from "../../services/desktop-fido2-user-interface.service";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
SectionHeaderComponent,
|
||||
BitIconButtonComponent,
|
||||
TableModule,
|
||||
JslibModule,
|
||||
IconModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
SectionComponent,
|
||||
ItemModule,
|
||||
BadgeModule,
|
||||
],
|
||||
templateUrl: "fido2-vault.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Fido2VaultComponent implements OnInit, OnDestroy {
|
||||
session?: DesktopFido2UserInterfaceSession = null;
|
||||
private destroy$ = new Subject<void>();
|
||||
private ciphersSubject = new BehaviorSubject<CipherView[]>([]);
|
||||
ciphers$: Observable<CipherView[]> = this.ciphersSubject.asObservable();
|
||||
cipherIds$: Observable<string[]> | undefined;
|
||||
readonly Icons = { BitwardenShield };
|
||||
|
||||
constructor(
|
||||
private readonly desktopSettingsService: DesktopSettingsService,
|
||||
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
|
||||
private readonly cipherService: CipherService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly dialogService: DialogService,
|
||||
private readonly logService: LogService,
|
||||
private readonly passwordRepromptService: PasswordRepromptService,
|
||||
private readonly router: Router,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.session = this.fido2UserInterfaceService.getCurrentSession();
|
||||
this.cipherIds$ = this.session?.availableCipherIds$;
|
||||
await this.loadCiphers();
|
||||
}
|
||||
|
||||
async ngOnDestroy(): Promise<void> {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
async chooseCipher(cipher: CipherView): Promise<void> {
|
||||
if (!this.session) {
|
||||
await this.dialogService.openSimpleDialog({
|
||||
title: { key: "unexpectedErrorShort" },
|
||||
content: { key: "closeThisBitwardenWindow" },
|
||||
type: "danger",
|
||||
acceptButtonText: { key: "closeThisWindow" },
|
||||
cancelButtonText: null,
|
||||
});
|
||||
await this.closeModal();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const isConfirmed = await this.validateCipherAccess(cipher);
|
||||
this.session.confirmChosenCipher(cipher.id, isConfirmed);
|
||||
|
||||
await this.closeModal();
|
||||
}
|
||||
|
||||
async closeModal(): Promise<void> {
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
await this.accountService.setShowHeader(true);
|
||||
|
||||
if (this.session) {
|
||||
this.session.notifyConfirmCreateCredential(false);
|
||||
this.session.confirmChosenCipher(null);
|
||||
}
|
||||
|
||||
await this.router.navigate(["/"]);
|
||||
}
|
||||
|
||||
private async loadCiphers(): Promise<void> {
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
|
||||
if (!activeUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine cipher list with optional cipher IDs filter
|
||||
combineLatest([this.cipherService.cipherListViews$(activeUserId), this.cipherIds$ || of(null)])
|
||||
.pipe(
|
||||
map(([ciphers, cipherIds]) => {
|
||||
// Filter out deleted ciphers
|
||||
const activeCiphers = ciphers.filter((cipher) => !cipher.deletedDate);
|
||||
|
||||
// If specific IDs provided, filter by them
|
||||
if (cipherIds?.length > 0) {
|
||||
return activeCiphers.filter((cipher) => cipherIds.includes(cipher.id as string));
|
||||
}
|
||||
|
||||
return activeCiphers;
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe({
|
||||
next: (ciphers) => this.ciphersSubject.next(ciphers as CipherView[]),
|
||||
error: (error: unknown) => this.logService.error("Failed to load ciphers", error),
|
||||
});
|
||||
}
|
||||
|
||||
private async validateCipherAccess(cipher: CipherView): Promise<boolean> {
|
||||
if (cipher.reprompt !== CipherRepromptType.None) {
|
||||
return this.passwordRepromptService.showPasswordPrompt();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export default {
|
||||
runCommand: <C extends Command>(params: RunCommandParams<C>): Promise<RunCommandResult<C>> =>
|
||||
ipcRenderer.invoke("autofill.runCommand", params),
|
||||
|
||||
listenerReady: () => ipcRenderer.send("autofill.listenerReady"),
|
||||
|
||||
listenPasskeyRegistration: (
|
||||
fn: (
|
||||
clientId: number,
|
||||
@@ -130,6 +132,25 @@ export default {
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
listenNativeStatus: (
|
||||
fn: (clientId: number, sequenceNumber: number, status: { key: string; value: string }) => void,
|
||||
) => {
|
||||
ipcRenderer.on(
|
||||
"autofill.nativeStatus",
|
||||
(
|
||||
event,
|
||||
data: {
|
||||
clientId: number;
|
||||
sequenceNumber: number;
|
||||
status: { key: string; value: string };
|
||||
},
|
||||
) => {
|
||||
const { clientId, sequenceNumber, status } = data;
|
||||
fn(clientId, sequenceNumber, status);
|
||||
},
|
||||
);
|
||||
},
|
||||
configureAutotype: (enabled: boolean, keyboardShortcut: string[]) => {
|
||||
ipcRenderer.send("autofill.configureAutotype", { enabled, keyboardShortcut });
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable, OnDestroy } from "@angular/core";
|
||||
import {
|
||||
Subject,
|
||||
combineLatest,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
@@ -8,10 +10,11 @@ import {
|
||||
mergeMap,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
EMPTY,
|
||||
} from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
@@ -48,6 +51,7 @@ import type { NativeWindowObject } from "./desktop-fido2-user-interface.service"
|
||||
@Injectable()
|
||||
export class DesktopAutofillService implements OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
private registrationRequest: autofill.PasskeyRegistrationRequest;
|
||||
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
@@ -55,6 +59,7 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
private configService: ConfigService,
|
||||
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<NativeWindowObject>,
|
||||
private accountService: AccountService,
|
||||
private authService: AuthService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
|
||||
@@ -68,28 +73,56 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
.getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync)
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
switchMap((enabled) => {
|
||||
if (!enabled) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return this.accountService.activeAccount$.pipe(
|
||||
map((account) => account?.id),
|
||||
filter((userId): userId is UserId => userId != null),
|
||||
switchMap((userId) => this.cipherService.cipherViews$(userId)),
|
||||
filter((enabled) => enabled === true), // Only proceed if feature is enabled
|
||||
switchMap(() => {
|
||||
return combineLatest([
|
||||
this.accountService.activeAccount$.pipe(
|
||||
map((account) => account?.id),
|
||||
filter((userId): userId is UserId => userId != null),
|
||||
),
|
||||
this.authService.activeAccountStatus$,
|
||||
]).pipe(
|
||||
// Only proceed when the vault is unlocked
|
||||
filter(([, status]) => status === AuthenticationStatus.Unlocked),
|
||||
// Then get cipher views
|
||||
switchMap(([userId]) => this.cipherService.cipherViews$(userId)),
|
||||
);
|
||||
}),
|
||||
// TODO: This will unset all the autofill credentials on the OS
|
||||
// when the account locks. We should instead explicilty clear the credentials
|
||||
// when the user logs out. Maybe by subscribing to the encrypted ciphers observable instead.
|
||||
debounceTime(100), // just a precaution to not spam the sync if there are multiple changes (we typically observe a null change)
|
||||
// No filter for empty arrays here - we want to sync even if there are 0 items
|
||||
filter((cipherViewMap) => cipherViewMap !== null),
|
||||
|
||||
mergeMap((cipherViewMap) => this.sync(Object.values(cipherViewMap ?? []))),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
// Listen for sign out to clear credentials
|
||||
this.authService.activeAccountStatus$
|
||||
.pipe(
|
||||
filter((status) => status === AuthenticationStatus.LoggedOut),
|
||||
mergeMap(() => this.sync([])), // sync an empty array
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.listenIpc();
|
||||
}
|
||||
|
||||
async adHocSync(): Promise<any> {
|
||||
this.logService.debug("Performing AdHoc sync");
|
||||
const account = await firstValueFrom(this.accountService.activeAccount$);
|
||||
const userId = account?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("No active user found");
|
||||
}
|
||||
|
||||
const cipherViewMap = await firstValueFrom(this.cipherService.cipherViews$(userId));
|
||||
this.logService.info("Performing AdHoc sync", Object.values(cipherViewMap ?? []));
|
||||
await this.sync(Object.values(cipherViewMap ?? []));
|
||||
}
|
||||
|
||||
/** Give metadata about all available credentials in the users vault */
|
||||
async sync(cipherViews: CipherView[]) {
|
||||
const status = await this.status();
|
||||
@@ -130,6 +163,11 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
}));
|
||||
}
|
||||
|
||||
this.logService.info("Syncing autofill credentials", {
|
||||
fido2Credentials,
|
||||
passwordCredentials,
|
||||
});
|
||||
|
||||
const syncResult = await ipc.autofill.runCommand<NativeAutofillSyncCommand>({
|
||||
namespace: "autofill",
|
||||
command: "sync",
|
||||
@@ -155,107 +193,152 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
get lastRegistrationRequest() {
|
||||
return this.registrationRequest;
|
||||
}
|
||||
|
||||
listenIpc() {
|
||||
ipc.autofill.listenPasskeyRegistration((clientId, sequenceNumber, request, callback) => {
|
||||
this.logService.warning("listenPasskeyRegistration", clientId, sequenceNumber, request);
|
||||
this.logService.warning(
|
||||
"listenPasskeyRegistration2",
|
||||
this.convertRegistrationRequest(request),
|
||||
);
|
||||
ipc.autofill.listenPasskeyRegistration(async (clientId, sequenceNumber, request, callback) => {
|
||||
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
|
||||
this.logService.debug(
|
||||
"listenPasskeyRegistration: MacOsNativeCredentialSync feature flag is disabled",
|
||||
);
|
||||
callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.registrationRequest = request;
|
||||
|
||||
this.logService.debug("listenPasskeyRegistration", clientId, sequenceNumber, request);
|
||||
this.logService.debug("listenPasskeyRegistration2", this.convertRegistrationRequest(request));
|
||||
|
||||
const controller = new AbortController();
|
||||
void this.fido2AuthenticatorService
|
||||
.makeCredential(
|
||||
|
||||
try {
|
||||
const response = await this.fido2AuthenticatorService.makeCredential(
|
||||
this.convertRegistrationRequest(request),
|
||||
{ windowXy: request.windowXy },
|
||||
{ windowXy: normalizePosition(request.windowXy) },
|
||||
controller,
|
||||
)
|
||||
.then((response) => {
|
||||
callback(null, this.convertRegistrationResponse(request, response));
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logService.error("listenPasskeyRegistration error", error);
|
||||
callback(error, null);
|
||||
});
|
||||
);
|
||||
|
||||
callback(null, this.convertRegistrationResponse(request, response));
|
||||
} catch (error) {
|
||||
this.logService.error("listenPasskeyRegistration error", error);
|
||||
callback(error, null);
|
||||
}
|
||||
});
|
||||
|
||||
ipc.autofill.listenPasskeyAssertionWithoutUserInterface(
|
||||
async (clientId, sequenceNumber, request, callback) => {
|
||||
this.logService.warning(
|
||||
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
|
||||
this.logService.debug(
|
||||
"listenPasskeyAssertionWithoutUserInterface: MacOsNativeCredentialSync feature flag is disabled",
|
||||
);
|
||||
callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.debug(
|
||||
"listenPasskeyAssertion without user interface",
|
||||
clientId,
|
||||
sequenceNumber,
|
||||
request,
|
||||
);
|
||||
|
||||
// For some reason the credentialId is passed as an empty array in the request, so we need to
|
||||
// get it from the cipher. For that we use the recordIdentifier, which is the cipherId.
|
||||
if (request.recordIdentifier && request.credentialId.length === 0) {
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getOptionalUserId),
|
||||
);
|
||||
if (!activeUserId) {
|
||||
this.logService.error("listenPasskeyAssertion error", "Active user not found");
|
||||
callback(new Error("Active user not found"), null);
|
||||
return;
|
||||
}
|
||||
|
||||
const cipher = await this.cipherService.get(request.recordIdentifier, activeUserId);
|
||||
if (!cipher) {
|
||||
this.logService.error("listenPasskeyAssertion error", "Cipher not found");
|
||||
callback(new Error("Cipher not found"), null);
|
||||
return;
|
||||
}
|
||||
|
||||
const decrypted = await this.cipherService.decrypt(cipher, activeUserId);
|
||||
|
||||
const fido2Credential = decrypted.login.fido2Credentials?.[0];
|
||||
if (!fido2Credential) {
|
||||
this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found");
|
||||
callback(new Error("Fido2Credential not found"), null);
|
||||
return;
|
||||
}
|
||||
|
||||
request.credentialId = Array.from(
|
||||
new Uint8Array(parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId)),
|
||||
);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
void this.fido2AuthenticatorService
|
||||
.getAssertion(
|
||||
this.convertAssertionRequest(request),
|
||||
{ windowXy: request.windowXy },
|
||||
|
||||
try {
|
||||
// For some reason the credentialId is passed as an empty array in the request, so we need to
|
||||
// get it from the cipher. For that we use the recordIdentifier, which is the cipherId.
|
||||
if (request.recordIdentifier && request.credentialId.length === 0) {
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getOptionalUserId),
|
||||
);
|
||||
if (!activeUserId) {
|
||||
this.logService.error("listenPasskeyAssertion error", "Active user not found");
|
||||
callback(new Error("Active user not found"), null);
|
||||
return;
|
||||
}
|
||||
|
||||
const cipher = await this.cipherService.get(request.recordIdentifier, activeUserId);
|
||||
if (!cipher) {
|
||||
this.logService.error("listenPasskeyAssertion error", "Cipher not found");
|
||||
callback(new Error("Cipher not found"), null);
|
||||
return;
|
||||
}
|
||||
|
||||
const decrypted = await this.cipherService.decrypt(cipher, activeUserId);
|
||||
|
||||
const fido2Credential = decrypted.login.fido2Credentials?.[0];
|
||||
if (!fido2Credential) {
|
||||
this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found");
|
||||
callback(new Error("Fido2Credential not found"), null);
|
||||
return;
|
||||
}
|
||||
|
||||
request.credentialId = Array.from(
|
||||
new Uint8Array(parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId)),
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.fido2AuthenticatorService.getAssertion(
|
||||
this.convertAssertionRequest(request, true),
|
||||
{ windowXy: normalizePosition(request.windowXy) },
|
||||
controller,
|
||||
)
|
||||
.then((response) => {
|
||||
callback(null, this.convertAssertionResponse(request, response));
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logService.error("listenPasskeyAssertion error", error);
|
||||
callback(error, null);
|
||||
});
|
||||
);
|
||||
|
||||
callback(null, this.convertAssertionResponse(request, response));
|
||||
} catch (error) {
|
||||
this.logService.error("listenPasskeyAssertion error", error);
|
||||
callback(error, null);
|
||||
return;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => {
|
||||
this.logService.warning("listenPasskeyAssertion", clientId, sequenceNumber, request);
|
||||
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
|
||||
this.logService.debug(
|
||||
"listenPasskeyAssertion: MacOsNativeCredentialSync feature flag is disabled",
|
||||
);
|
||||
callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.debug("listenPasskeyAssertion", clientId, sequenceNumber, request);
|
||||
|
||||
const controller = new AbortController();
|
||||
void this.fido2AuthenticatorService
|
||||
.getAssertion(
|
||||
try {
|
||||
const response = await this.fido2AuthenticatorService.getAssertion(
|
||||
this.convertAssertionRequest(request),
|
||||
{ windowXy: request.windowXy },
|
||||
{ windowXy: normalizePosition(request.windowXy) },
|
||||
controller,
|
||||
)
|
||||
.then((response) => {
|
||||
callback(null, this.convertAssertionResponse(request, response));
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logService.error("listenPasskeyAssertion error", error);
|
||||
callback(error, null);
|
||||
});
|
||||
);
|
||||
|
||||
callback(null, this.convertAssertionResponse(request, response));
|
||||
} catch (error) {
|
||||
this.logService.error("listenPasskeyAssertion error", error);
|
||||
callback(error, null);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for native status messages
|
||||
ipc.autofill.listenNativeStatus(async (clientId, sequenceNumber, status) => {
|
||||
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
|
||||
this.logService.debug(
|
||||
"listenNativeStatus: MacOsNativeCredentialSync feature flag is disabled",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.info("Received native status", status.key, status.value);
|
||||
if (status.key === "request-sync") {
|
||||
// perform ad-hoc sync
|
||||
await this.adHocSync();
|
||||
}
|
||||
});
|
||||
|
||||
ipc.autofill.listenerReady();
|
||||
}
|
||||
|
||||
private convertRegistrationRequest(
|
||||
@@ -277,7 +360,10 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
alg,
|
||||
type: "public-key",
|
||||
})),
|
||||
excludeCredentialDescriptorList: [],
|
||||
excludeCredentialDescriptorList: request.excludedCredentials.map((credentialId) => ({
|
||||
id: new Uint8Array(credentialId),
|
||||
type: "public-key" as const,
|
||||
})),
|
||||
requireResidentKey: true,
|
||||
requireUserVerification:
|
||||
request.userVerification === "required" || request.userVerification === "preferred",
|
||||
@@ -309,18 +395,19 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
request:
|
||||
| autofill.PasskeyAssertionRequest
|
||||
| autofill.PasskeyAssertionWithoutUserInterfaceRequest,
|
||||
assumeUserPresence: boolean = false,
|
||||
): Fido2AuthenticatorGetAssertionParams {
|
||||
let allowedCredentials;
|
||||
if ("credentialId" in request) {
|
||||
allowedCredentials = [
|
||||
{
|
||||
id: new Uint8Array(request.credentialId),
|
||||
id: new Uint8Array(request.credentialId).buffer,
|
||||
type: "public-key" as const,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
allowedCredentials = request.allowedCredentials.map((credentialId) => ({
|
||||
id: new Uint8Array(credentialId),
|
||||
id: new Uint8Array(credentialId).buffer,
|
||||
type: "public-key" as const,
|
||||
}));
|
||||
}
|
||||
@@ -333,7 +420,7 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
requireUserVerification:
|
||||
request.userVerification === "required" || request.userVerification === "preferred",
|
||||
fallbackSupported: false,
|
||||
assumeUserPresence: true, // For desktop assertions, it's safe to assume UP has been checked by OS dialogues
|
||||
assumeUserPresence,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -358,3 +445,13 @@ export class DesktopAutofillService implements OnDestroy {
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePosition(position: { x: number; y: number }): { x: number; y: number } {
|
||||
// Add 100 pixels to the x-coordinate to offset the native OS dialog positioning.
|
||||
const xPostionOffset = 100;
|
||||
|
||||
return {
|
||||
x: Math.round(position.x + xPostionOffset),
|
||||
y: Math.round(position.y),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export class DesktopFido2UserInterfaceService
|
||||
nativeWindowObject: NativeWindowObject,
|
||||
abortController?: AbortController,
|
||||
): Promise<DesktopFido2UserInterfaceSession> {
|
||||
this.logService.warning("newSession", fallbackSupported, abortController, nativeWindowObject);
|
||||
this.logService.debug("newSession", fallbackSupported, abortController, nativeWindowObject);
|
||||
const session = new DesktopFido2UserInterfaceSession(
|
||||
this.authService,
|
||||
this.cipherService,
|
||||
@@ -94,9 +94,11 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
) {}
|
||||
|
||||
private confirmCredentialSubject = new Subject<boolean>();
|
||||
private createdCipher: Cipher;
|
||||
|
||||
private availableCipherIdsSubject = new BehaviorSubject<string[]>(null);
|
||||
private updatedCipher: CipherView;
|
||||
|
||||
private rpId = new BehaviorSubject<string>(null);
|
||||
private availableCipherIdsSubject = new BehaviorSubject<string[]>([""]);
|
||||
/**
|
||||
* Observable that emits available cipher IDs once they're confirmed by the UI
|
||||
*/
|
||||
@@ -114,7 +116,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
assumeUserPresence,
|
||||
masterPasswordRepromptRequired,
|
||||
}: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
|
||||
this.logService.warning("pickCredential desktop function", {
|
||||
this.logService.debug("pickCredential desktop function", {
|
||||
cipherIds,
|
||||
userVerification,
|
||||
assumeUserPresence,
|
||||
@@ -123,6 +125,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
|
||||
try {
|
||||
// Check if we can return the credential without user interaction
|
||||
await this.accountService.setShowHeader(false);
|
||||
if (assumeUserPresence && cipherIds.length === 1 && !masterPasswordRepromptRequired) {
|
||||
this.logService.debug(
|
||||
"shortcut - Assuming user presence and returning cipherId",
|
||||
@@ -136,22 +139,27 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
// make the cipherIds available to the UI.
|
||||
this.availableCipherIdsSubject.next(cipherIds);
|
||||
|
||||
await this.showUi("/passkeys", this.windowObject.windowXy);
|
||||
await this.showUi("/fido2-assertion", this.windowObject.windowXy, false);
|
||||
|
||||
const chosenCipherResponse = await this.waitForUiChosenCipher();
|
||||
|
||||
this.logService.debug("Received chosen cipher", chosenCipherResponse);
|
||||
|
||||
return {
|
||||
cipherId: chosenCipherResponse.cipherId,
|
||||
userVerified: chosenCipherResponse.userVerified,
|
||||
cipherId: chosenCipherResponse?.cipherId,
|
||||
userVerified: chosenCipherResponse?.userVerified,
|
||||
};
|
||||
} finally {
|
||||
// Make sure to clean up so the app is never stuck in modal mode?
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
await this.accountService.setShowHeader(true);
|
||||
}
|
||||
}
|
||||
|
||||
async getRpId(): Promise<string> {
|
||||
return firstValueFrom(this.rpId.pipe(filter((id) => id != null)));
|
||||
}
|
||||
|
||||
confirmChosenCipher(cipherId: string, userVerified: boolean = false): void {
|
||||
this.chosenCipherSubject.next({ cipherId, userVerified });
|
||||
this.chosenCipherSubject.complete();
|
||||
@@ -159,7 +167,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
|
||||
private async waitForUiChosenCipher(
|
||||
timeoutMs: number = 60000,
|
||||
): Promise<{ cipherId: string; userVerified: boolean } | undefined> {
|
||||
): Promise<{ cipherId?: string; userVerified: boolean } | undefined> {
|
||||
try {
|
||||
return await lastValueFrom(this.chosenCipherSubject.pipe(timeout(timeoutMs)));
|
||||
} catch {
|
||||
@@ -174,7 +182,10 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
/**
|
||||
* Notifies the Fido2UserInterfaceSession that the UI operations has completed and it can return to the OS.
|
||||
*/
|
||||
notifyConfirmNewCredential(confirmed: boolean): void {
|
||||
notifyConfirmCreateCredential(confirmed: boolean, updatedCipher?: CipherView): void {
|
||||
if (updatedCipher) {
|
||||
this.updatedCipher = updatedCipher;
|
||||
}
|
||||
this.confirmCredentialSubject.next(confirmed);
|
||||
this.confirmCredentialSubject.complete();
|
||||
}
|
||||
@@ -195,60 +206,79 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
async confirmNewCredential({
|
||||
credentialName,
|
||||
userName,
|
||||
userHandle,
|
||||
userVerification,
|
||||
rpId,
|
||||
}: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
|
||||
this.logService.warning(
|
||||
this.logService.debug(
|
||||
"confirmNewCredential",
|
||||
credentialName,
|
||||
userName,
|
||||
userHandle,
|
||||
userVerification,
|
||||
rpId,
|
||||
);
|
||||
this.rpId.next(rpId);
|
||||
|
||||
try {
|
||||
await this.showUi("/passkeys", this.windowObject.windowXy);
|
||||
await this.showUi("/fido2-creation", this.windowObject.windowXy, false);
|
||||
|
||||
// Wait for the UI to wrap up
|
||||
const confirmation = await this.waitForUiNewCredentialConfirmation();
|
||||
if (!confirmation) {
|
||||
return { cipherId: undefined, userVerified: false };
|
||||
}
|
||||
// Create the credential
|
||||
await this.createCredential({
|
||||
credentialName,
|
||||
userName,
|
||||
rpId,
|
||||
userHandle: "",
|
||||
userVerification,
|
||||
});
|
||||
|
||||
// wait for 10ms to help RXJS catch up(?)
|
||||
// We sometimes get a race condition from this.createCredential not updating cipherService in time
|
||||
//console.log("waiting 10ms..");
|
||||
//await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
//console.log("Just waited 10ms");
|
||||
|
||||
// Return the new cipher (this.createdCipher)
|
||||
return { cipherId: this.createdCipher.id, userVerified: userVerification };
|
||||
if (this.updatedCipher) {
|
||||
await this.updateCredential(this.updatedCipher);
|
||||
return { cipherId: this.updatedCipher.id, userVerified: userVerification };
|
||||
} else {
|
||||
// Create the cipher
|
||||
const createdCipher = await this.createCipher({
|
||||
credentialName,
|
||||
userName,
|
||||
rpId,
|
||||
userHandle,
|
||||
userVerification,
|
||||
});
|
||||
return { cipherId: createdCipher.id, userVerified: userVerification };
|
||||
}
|
||||
} finally {
|
||||
// Make sure to clean up so the app is never stuck in modal mode?
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
await this.accountService.setShowHeader(true);
|
||||
}
|
||||
}
|
||||
|
||||
private async showUi(route: string, position?: { x: number; y: number }): Promise<void> {
|
||||
private async hideUi(): Promise<void> {
|
||||
await this.desktopSettingsService.setModalMode(false);
|
||||
await this.router.navigate(["/"]);
|
||||
}
|
||||
|
||||
private async showUi(
|
||||
route: string,
|
||||
position?: { x: number; y: number },
|
||||
showTrafficButtons: boolean = false,
|
||||
disableRedirect?: boolean,
|
||||
): Promise<void> {
|
||||
// Load the UI:
|
||||
await this.desktopSettingsService.setModalMode(true, position);
|
||||
await this.router.navigate(["/passkeys"]);
|
||||
await this.desktopSettingsService.setModalMode(true, showTrafficButtons, position);
|
||||
await this.accountService.setShowHeader(showTrafficButtons);
|
||||
await this.router.navigate([
|
||||
route,
|
||||
{
|
||||
"disable-redirect": disableRedirect || null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be called by the UI to create a new credential with user input etc.
|
||||
* Can be called by the UI to create a new cipher with user input etc.
|
||||
* @param param0
|
||||
*/
|
||||
async createCredential({ credentialName, userName, rpId }: NewCredentialParams): Promise<Cipher> {
|
||||
async createCipher({ credentialName, userName, rpId }: NewCredentialParams): Promise<Cipher> {
|
||||
// Store the passkey on a new cipher to avoid replacing something important
|
||||
|
||||
const cipher = new CipherView();
|
||||
cipher.name = credentialName;
|
||||
|
||||
@@ -267,32 +297,81 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
|
||||
if (!activeUserId) {
|
||||
throw new Error("No active user ID found!");
|
||||
}
|
||||
|
||||
const encCipher = await this.cipherService.encrypt(cipher, activeUserId);
|
||||
const createdCipher = await this.cipherService.createWithServer(encCipher);
|
||||
|
||||
this.createdCipher = createdCipher;
|
||||
try {
|
||||
const createdCipher = await this.cipherService.createWithServer(encCipher);
|
||||
|
||||
return createdCipher;
|
||||
return createdCipher;
|
||||
} catch {
|
||||
throw new Error("Unable to create cipher");
|
||||
}
|
||||
}
|
||||
|
||||
async updateCredential(cipher: CipherView): Promise<void> {
|
||||
this.logService.info("updateCredential");
|
||||
await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
map(async (a) => {
|
||||
if (a) {
|
||||
const encCipher = await this.cipherService.encrypt(cipher, a.id);
|
||||
await this.cipherService.updateWithServer(encCipher);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async informExcludedCredential(existingCipherIds: string[]): Promise<void> {
|
||||
this.logService.warning("informExcludedCredential", existingCipherIds);
|
||||
this.logService.debug("informExcludedCredential", existingCipherIds);
|
||||
|
||||
// make the cipherIds available to the UI.
|
||||
this.availableCipherIdsSubject.next(existingCipherIds);
|
||||
|
||||
await this.accountService.setShowHeader(false);
|
||||
await this.showUi("/fido2-excluded", this.windowObject.windowXy, false);
|
||||
}
|
||||
|
||||
async ensureUnlockedVault(): Promise<void> {
|
||||
this.logService.warning("ensureUnlockedVault");
|
||||
this.logService.debug("ensureUnlockedVault");
|
||||
|
||||
const status = await firstValueFrom(this.authService.activeAccountStatus$);
|
||||
if (status !== AuthenticationStatus.Unlocked) {
|
||||
throw new Error("Vault is not unlocked");
|
||||
await this.showUi("/lock", this.windowObject.windowXy, true, true);
|
||||
|
||||
let status2: AuthenticationStatus;
|
||||
try {
|
||||
status2 = await lastValueFrom(
|
||||
this.authService.activeAccountStatus$.pipe(
|
||||
filter((s) => s === AuthenticationStatus.Unlocked),
|
||||
take(1),
|
||||
timeout(1000 * 60 * 5), // 5 minutes
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logService.warning("Error while waiting for vault to unlock", error);
|
||||
}
|
||||
|
||||
if (status2 === AuthenticationStatus.Unlocked) {
|
||||
await this.router.navigate(["/"]);
|
||||
}
|
||||
|
||||
if (status2 !== AuthenticationStatus.Unlocked) {
|
||||
await this.hideUi();
|
||||
throw new Error("Vault is not unlocked");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async informCredentialNotFound(): Promise<void> {
|
||||
this.logService.warning("informCredentialNotFound");
|
||||
this.logService.debug("informCredentialNotFound");
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.logService.warning("close");
|
||||
this.logService.debug("close");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -908,6 +908,12 @@
|
||||
"unexpectedError": {
|
||||
"message": "An unexpected error has occurred."
|
||||
},
|
||||
"unexpectedErrorShort": {
|
||||
"message": "Unexpected error"
|
||||
},
|
||||
"closeThisBitwardenWindow": {
|
||||
"message": "Close this Bitwarden window and try again."
|
||||
},
|
||||
"itemInformation": {
|
||||
"message": "Item information"
|
||||
},
|
||||
@@ -3356,7 +3362,7 @@
|
||||
"orgTrustWarning1": {
|
||||
"message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint."
|
||||
},
|
||||
"trustUser":{
|
||||
"trustUser": {
|
||||
"message": "Trust user"
|
||||
},
|
||||
"inputRequired": {
|
||||
@@ -3886,6 +3892,75 @@
|
||||
"fileSavedToDevice": {
|
||||
"message": "File saved to device. Manage from your device downloads."
|
||||
},
|
||||
"importantNotice": {
|
||||
"message": "Important notice"
|
||||
},
|
||||
"setupTwoStepLogin": {
|
||||
"message": "Set up two-step login"
|
||||
},
|
||||
"newDeviceVerificationNoticeContentPage1": {
|
||||
"message": "Bitwarden will send a code to your account email to verify logins from new devices starting in February 2025."
|
||||
},
|
||||
"newDeviceVerificationNoticeContentPage2": {
|
||||
"message": "You can set up two-step login as an alternative way to protect your account or change your email to one you can access."
|
||||
},
|
||||
"remindMeLater": {
|
||||
"message": "Remind me later"
|
||||
},
|
||||
"newDeviceVerificationNoticePageOneFormContent": {
|
||||
"message": "Do you have reliable access to your email, $EMAIL$?",
|
||||
"placeholders": {
|
||||
"email": {
|
||||
"content": "$1",
|
||||
"example": "your_name@email.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
"newDeviceVerificationNoticePageOneEmailAccessNo": {
|
||||
"message": "No, I do not"
|
||||
},
|
||||
"newDeviceVerificationNoticePageOneEmailAccessYes": {
|
||||
"message": "Yes, I can reliably access my email"
|
||||
},
|
||||
"turnOnTwoStepLogin": {
|
||||
"message": "Turn on two-step login"
|
||||
},
|
||||
"changeAcctEmail": {
|
||||
"message": "Change account email"
|
||||
},
|
||||
"passkeyLogin": {
|
||||
"message": "Log in with passkey?"
|
||||
},
|
||||
"savePasskeyQuestion": {
|
||||
"message": "Save passkey?"
|
||||
},
|
||||
"saveNewPasskey": {
|
||||
"message": "Save as new login"
|
||||
},
|
||||
"savePasskeyNewLogin": {
|
||||
"message": "Save passkey as new login"
|
||||
},
|
||||
"noMatchingLoginsForSite": {
|
||||
"message": "No matching logins for this site"
|
||||
},
|
||||
"overwritePasskey": {
|
||||
"message": "Overwrite passkey?"
|
||||
},
|
||||
"unableToSavePasskey": {
|
||||
"message": "Unable to save passkey"
|
||||
},
|
||||
"alreadyContainsPasskey": {
|
||||
"message": "This item already contains a passkey. Are you sure you want to overwrite the current passkey?"
|
||||
},
|
||||
"passkeyAlreadyExists": {
|
||||
"message": "A passkey already exists for this application."
|
||||
},
|
||||
"applicationDoesNotSupportDuplicates": {
|
||||
"message": "This application does not support duplicates."
|
||||
},
|
||||
"closeThisWindow": {
|
||||
"message": "Close this window"
|
||||
},
|
||||
"allowScreenshots": {
|
||||
"message": "Allow screen capture"
|
||||
},
|
||||
|
||||
@@ -53,9 +53,14 @@ export class TrayMain {
|
||||
},
|
||||
{
|
||||
visible: isDev(),
|
||||
label: "Fake Popup",
|
||||
label: "Fake Popup Select",
|
||||
click: () => this.fakePopup(),
|
||||
},
|
||||
{
|
||||
visible: isDev(),
|
||||
label: "Fake Popup Create",
|
||||
click: () => this.fakePopupCreate(),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: this.i18nService.t("exit"),
|
||||
@@ -218,4 +223,8 @@ export class TrayMain {
|
||||
private async fakePopup() {
|
||||
await this.messagingService.send("loadurl", { url: "/passkeys", modal: true });
|
||||
}
|
||||
|
||||
private async fakePopupCreate() {
|
||||
await this.messagingService.send("loadurl", { url: "/create-passkey", modal: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,10 +100,10 @@ export class WindowMain {
|
||||
applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]);
|
||||
// Because modal is used in front of another app, UX wise it makes sense to hide the main window when leaving modal mode.
|
||||
this.win.hide();
|
||||
} else if (!lastValue.isModalModeActive && newValue.isModalModeActive) {
|
||||
} else if (newValue.isModalModeActive) {
|
||||
// Apply the popup modal styles
|
||||
this.logService.info("Applying popup modal styles", newValue.modalPosition);
|
||||
applyPopupModalStyles(this.win, newValue.modalPosition);
|
||||
applyPopupModalStyles(this.win, newValue.showTrafficButtons, newValue.modalPosition);
|
||||
this.win.show();
|
||||
}
|
||||
}),
|
||||
@@ -273,7 +273,7 @@ export class WindowMain {
|
||||
this.win = new BrowserWindow({
|
||||
width: this.windowStates[mainWindowSizeKey].width,
|
||||
height: this.windowStates[mainWindowSizeKey].height,
|
||||
minWidth: 680,
|
||||
minWidth: 600,
|
||||
minHeight: 500,
|
||||
x: this.windowStates[mainWindowSizeKey].x,
|
||||
y: this.windowStates[mainWindowSizeKey].y,
|
||||
|
||||
@@ -7,6 +7,11 @@ import { WindowMain } from "../../../main/window.main";
|
||||
|
||||
import { CommandDefinition } from "./command";
|
||||
|
||||
type BufferedMessage = {
|
||||
channel: string;
|
||||
data: any;
|
||||
};
|
||||
|
||||
export type RunCommandParams<C extends CommandDefinition> = {
|
||||
namespace: C["namespace"];
|
||||
command: C["name"];
|
||||
@@ -17,12 +22,43 @@ export type RunCommandResult<C extends CommandDefinition> = C["output"];
|
||||
|
||||
export class NativeAutofillMain {
|
||||
private ipcServer: autofill.IpcServer | null;
|
||||
private messageBuffer: BufferedMessage[] = [];
|
||||
private listenerReady = false;
|
||||
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private windowMain: WindowMain,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Safely sends a message to the renderer, buffering it if the server isn't ready yet
|
||||
*/
|
||||
private safeSend(channel: string, data: any) {
|
||||
if (this.listenerReady && this.windowMain.win?.webContents) {
|
||||
this.windowMain.win.webContents.send(channel, data);
|
||||
} else {
|
||||
this.messageBuffer.push({ channel, data });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes all buffered messages to the renderer
|
||||
*/
|
||||
private flushMessageBuffer() {
|
||||
if (!this.windowMain.win?.webContents) {
|
||||
this.logService.error("Cannot flush message buffer - window not available");
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.info(`Flushing ${this.messageBuffer.length} buffered messages`);
|
||||
|
||||
for (const { channel, data } of this.messageBuffer) {
|
||||
this.windowMain.win.webContents.send(channel, data);
|
||||
}
|
||||
|
||||
this.messageBuffer = [];
|
||||
}
|
||||
|
||||
async init() {
|
||||
ipcMain.handle(
|
||||
"autofill.runCommand",
|
||||
@@ -43,7 +79,7 @@ export class NativeAutofillMain {
|
||||
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
|
||||
return;
|
||||
}
|
||||
this.windowMain.win.webContents.send("autofill.passkeyRegistration", {
|
||||
this.safeSend("autofill.passkeyRegistration", {
|
||||
clientId,
|
||||
sequenceNumber,
|
||||
request,
|
||||
@@ -56,7 +92,7 @@ export class NativeAutofillMain {
|
||||
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
|
||||
return;
|
||||
}
|
||||
this.windowMain.win.webContents.send("autofill.passkeyAssertion", {
|
||||
this.safeSend("autofill.passkeyAssertion", {
|
||||
clientId,
|
||||
sequenceNumber,
|
||||
request,
|
||||
@@ -69,28 +105,49 @@ export class NativeAutofillMain {
|
||||
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
|
||||
return;
|
||||
}
|
||||
this.windowMain.win.webContents.send("autofill.passkeyAssertionWithoutUserInterface", {
|
||||
this.safeSend("autofill.passkeyAssertionWithoutUserInterface", {
|
||||
clientId,
|
||||
sequenceNumber,
|
||||
request,
|
||||
});
|
||||
},
|
||||
// NativeStatusCallback
|
||||
(error, clientId, sequenceNumber, status) => {
|
||||
if (error) {
|
||||
this.logService.error("autofill.IpcServer.nativeStatus", error);
|
||||
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
|
||||
return;
|
||||
}
|
||||
this.safeSend("autofill.nativeStatus", {
|
||||
clientId,
|
||||
sequenceNumber,
|
||||
status,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.on("autofill.listenerReady", () => {
|
||||
this.listenerReady = true;
|
||||
this.logService.info(
|
||||
`Listener is ready, flushing ${this.messageBuffer.length} buffered messages`,
|
||||
);
|
||||
this.flushMessageBuffer();
|
||||
});
|
||||
|
||||
ipcMain.on("autofill.completePasskeyRegistration", (event, data) => {
|
||||
this.logService.warning("autofill.completePasskeyRegistration", data);
|
||||
this.logService.debug("autofill.completePasskeyRegistration", data);
|
||||
const { clientId, sequenceNumber, response } = data;
|
||||
this.ipcServer.completeRegistration(clientId, sequenceNumber, response);
|
||||
});
|
||||
|
||||
ipcMain.on("autofill.completePasskeyAssertion", (event, data) => {
|
||||
this.logService.warning("autofill.completePasskeyAssertion", data);
|
||||
this.logService.debug("autofill.completePasskeyAssertion", data);
|
||||
const { clientId, sequenceNumber, response } = data;
|
||||
this.ipcServer.completeAssertion(clientId, sequenceNumber, response);
|
||||
});
|
||||
|
||||
ipcMain.on("autofill.completeError", (event, data) => {
|
||||
this.logService.warning("autofill.completeError", data);
|
||||
this.logService.debug("autofill.completeError", data);
|
||||
const { clientId, sequenceNumber, error } = data;
|
||||
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
|
||||
});
|
||||
|
||||
@@ -14,5 +14,6 @@ export class WindowState {
|
||||
|
||||
export class ModalModeState {
|
||||
isModalModeActive: boolean;
|
||||
showTrafficButtons?: boolean;
|
||||
modalPosition?: { x: number; y: number }; // Modal position is often passed from the native UI
|
||||
}
|
||||
|
||||
@@ -3,15 +3,19 @@ import { BrowserWindow } from "electron";
|
||||
import { WindowState } from "./models/domain/window-state";
|
||||
|
||||
// change as needed, however limited by mainwindow minimum size
|
||||
const popupWidth = 680;
|
||||
const popupHeight = 500;
|
||||
const popupWidth = 600;
|
||||
const popupHeight = 600;
|
||||
|
||||
type Position = { x: number; y: number };
|
||||
|
||||
export function applyPopupModalStyles(window: BrowserWindow, position?: Position) {
|
||||
export function applyPopupModalStyles(
|
||||
window: BrowserWindow,
|
||||
showTrafficButtons: boolean = true,
|
||||
position?: Position,
|
||||
) {
|
||||
window.unmaximize();
|
||||
window.setSize(popupWidth, popupHeight);
|
||||
window.setWindowButtonVisibility?.(false);
|
||||
window.setWindowButtonVisibility?.(showTrafficButtons);
|
||||
window.setMenuBarVisibility?.(false);
|
||||
window.setResizable(false);
|
||||
window.setAlwaysOnTop(true);
|
||||
@@ -40,7 +44,7 @@ function positionWindow(window: BrowserWindow, position?: Position) {
|
||||
}
|
||||
|
||||
export function applyMainWindowStyles(window: BrowserWindow, existingWindowState: WindowState) {
|
||||
window.setMinimumSize(680, 500);
|
||||
window.setMinimumSize(popupWidth, popupHeight);
|
||||
|
||||
// need to guard against null/undefined values
|
||||
|
||||
|
||||
@@ -335,9 +335,14 @@ export class DesktopSettingsService {
|
||||
* Sets the modal mode of the application. Setting this changes the windows-size and other properties.
|
||||
* @param value `true` if the application is in modal mode, `false` if it is not.
|
||||
*/
|
||||
async setModalMode(value: boolean, modalPosition?: { x: number; y: number }) {
|
||||
async setModalMode(
|
||||
value: boolean,
|
||||
showTrafficButtons?: boolean,
|
||||
modalPosition?: { x: number; y: number },
|
||||
) {
|
||||
await this.modalModeState.update(() => ({
|
||||
isModalModeActive: value,
|
||||
showTrafficButtons,
|
||||
modalPosition,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user