mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 21:33:27 +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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user