Repository: tetherto/pearpass-app-desktop Branch: main Commit: d3974b085799 Files: 917 Total size: 4.1 MB Directory structure: gitextract_j12gn_f4/ ├── .claude/ │ └── skills/ │ └── use-ui-kit/ │ └── SKILL.md ├── .cursor/ │ └── rules/ │ └── use-ui-kit.mdc ├── .github/ │ ├── actions/ │ │ └── authorize-pr/ │ │ └── action.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── ci-pr.yml │ ├── ci-push.yml │ └── ci-reusable.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .npmrc ├── .nvmrc ├── AGENTS.md ├── CLAUDE.md ├── CONTRIBUTING.md ├── LICENSE.md ├── NOTICE.md ├── README.md ├── SECURITY.md ├── app.electron.tsx ├── appling/ │ ├── README.md │ ├── app.cjs │ ├── app.dev.cjs │ ├── app.staging.cjs │ ├── entitlements.plist │ ├── lib/ │ │ ├── install.cjs │ │ ├── preflight.cjs │ │ ├── progress.cjs │ │ ├── utils.cjs │ │ ├── view.html.cjs │ │ └── worker.cjs │ └── package.json ├── assets/ │ ├── animations/ │ │ ├── category.riv │ │ ├── form_credit_card.riv │ │ ├── pearpass_password.riv │ │ └── sync_without_the_cloud_animation.riv │ ├── fonts/ │ │ └── humbleNostalgia/ │ │ └── HumbleNostalgia.otf │ ├── rive/ │ │ └── rive_webgl2.wasm │ └── video/ │ ├── onboarding_lock_loop.webm │ └── onboarding_lock_start.webm ├── babel.config.cjs ├── babel.strict-dom.cjs ├── build-assets/ │ └── win/ │ └── AppxManifest.xml ├── docs/ │ └── Electron-Packaging-And-Runtime.md ├── e2e/ │ ├── .gitignore │ ├── components/ │ │ ├── CreateOrEditPage.js │ │ ├── DetailsPage.js │ │ ├── LoginPage.js │ │ ├── MainPage.js │ │ ├── SettingsPage.js │ │ ├── SideMenuPage.js │ │ ├── Utilities.js │ │ └── index.js │ ├── fixtures/ │ │ ├── app.runner.js │ │ └── test-data.js │ ├── package.json │ ├── playwright.config.js │ ├── scripts/ │ │ └── explore.js │ └── specs/ │ ├── 01-Login/ │ │ ├── creatingLoginItem.test.js │ │ ├── creatingLoginItemPassword.test.js │ │ └── editingDeletingLoginItem.test.js │ ├── 02-CreditCard/ │ │ ├── creatingCreditCardItem.test.js │ │ └── editingDeleteCreditCardItem.test.js │ ├── 03-WiFi/ │ │ ├── creatingWiFiItem.test.js │ │ └── editingDeletingWiFiItem.test.js │ ├── 04-Identity/ │ │ ├── creatingIdentityItem.test.js │ │ └── editingDeletingIdentityItem.test.js │ ├── 05-PassPhrase/ │ │ ├── creatingPassPhraseItem.test.js │ │ └── editingDeletingPassPhraseItem.test.js │ ├── 06-Note/ │ │ ├── creatingNoteItem.test.js │ │ └── editingDeleteNoteItem.test.js │ ├── 07-CustomField/ │ │ ├── creatingCustomFieldItem.test.js │ │ └── editingDeleteCustomFieldItem.test.js │ ├── 08-MultipleSelection/ │ │ └── multipleSelection.test.js │ ├── 09-SortingAndFiltering/ │ │ └── sorting.test.js │ └── 10-Settings/ │ └── settings.test.js ├── electron/ │ ├── clipboardCleanup.cjs │ ├── clipboardCleanup.test.js │ ├── clipboardCleanup.windows.ps1 │ ├── clipboardCleanupHelper.cjs │ ├── clipboardCleanupHelper.test.js │ ├── flatpak-paths.cjs │ ├── flatpak-paths.test.js │ ├── linuxWaylandClipboard.cjs │ ├── linuxWaylandClipboardFallback.cjs │ ├── linuxX11Clipboard.cjs │ ├── linuxX11ClipboardFallback.cjs │ ├── main.cjs │ ├── preload.cjs │ ├── preload.test.js │ └── runtime-config.cjs ├── electron-builder.linux.json ├── electron-builder.mac.json ├── eslint.config.js ├── flatpak/ │ ├── appimage/ │ │ └── .gitkeep │ ├── com.pears.pass.desktop │ ├── com.pears.pass.metainfo.xml │ └── com.pears.pass.yaml ├── forge.config.cjs ├── index.html ├── index.js ├── jest.config.js ├── lingui.config.js ├── package.json ├── resources/ │ └── bin/ │ ├── wl-copy-arm64 │ ├── wl-copy-x86_64 │ ├── wl-paste-arm64 │ ├── wl-paste-x86_64 │ ├── xsel-arm64 │ └── xsel-x86_64 ├── scripts/ │ ├── afterPack.cjs │ ├── apply-flavor.mjs │ ├── build-flatpak.sh │ ├── build-snap.sh │ ├── build.worklet.mjs │ ├── bundle-bridge.mjs │ ├── bundle-renderer.mjs │ ├── create-linux-tarball.sh │ ├── notarize.cjs │ └── patch-electron-dock-name.mjs ├── snap/ │ └── snapcraft.yaml ├── src/ │ ├── app/ │ │ ├── App/ │ │ │ ├── appConfig.js │ │ │ ├── hooks/ │ │ │ │ ├── useInactivity.js │ │ │ │ ├── useInactivity.test.js │ │ │ │ ├── useOnExtension.test.js │ │ │ │ ├── useOnExtensionExit.js │ │ │ │ ├── useOnExtensionLockOut.js │ │ │ │ ├── useOnExtensionLockOut.test.js │ │ │ │ ├── useRedirect.js │ │ │ │ └── useRedirect.test.js │ │ │ ├── index.js │ │ │ └── styles.js │ │ └── Routes/ │ │ └── index.js │ ├── components/ │ │ ├── AlertBox/ │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── AppHeaderV2/ │ │ │ ├── AppHeaderV2.styles.ts │ │ │ ├── AppHeaderV2.test.js │ │ │ ├── AppHeaderV2.tsx │ │ │ └── index.ts │ │ ├── BackgroundWithGradient.tsx │ │ ├── BadgeTextItem/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── BannerBox/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── ButtonPlusCreateNew/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── CardSingleSetting/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── CopyButton/ │ │ │ └── index.tsx │ │ ├── CreateCustomField/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── CreateNewCategoryPopupContent/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── DropdownSwapVault/ │ │ │ ├── index.test.js │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── EditFolderPopupContent/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── EmptyCollectionView/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── FileDropArea/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── FileUploadContent/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── FolderDropdown/ │ │ │ ├── FolderDropdownV2.tsx │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── FormGroup/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── FormModalHeaderWrapper/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── FormWrapper/ │ │ │ ├── index.js │ │ │ └── index.test.js │ │ ├── ImportDataOption/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── InitialPageWrapper/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── InputFieldNote/ │ │ │ ├── index.js │ │ │ └── index.test.js │ │ ├── InputPearpassPassword/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── InputSearch/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── ListItem/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── LoadingOverlay/ │ │ │ ├── index.js │ │ │ └── index.test.js │ │ ├── MenuDropdown/ │ │ │ ├── MenuDropdownItem/ │ │ │ │ ├── index.js │ │ │ │ └── index.test.js │ │ │ ├── MenuDropdownLabel/ │ │ │ │ ├── index.js │ │ │ │ └── index.test.js │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── NoticeContainer/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── OnboardingShell/ │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── OtpCodeField/ │ │ │ ├── index.test.js │ │ │ ├── index.ts │ │ │ └── utils.ts │ │ ├── OtpCodeFieldV2/ │ │ │ ├── index.test.tsx │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── Overlay/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── PasswordFieldStrengthIndicator/ │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── PopupMenu/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ ├── styles.js │ │ │ └── utils/ │ │ │ ├── getHorizontal.js │ │ │ ├── getHorizontal.test.js │ │ │ ├── getVertical.js │ │ │ └── getVertical.test.js │ │ ├── RadioSelect/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── Record/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── RecordActionsPopupContent/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── RecordAvatar/ │ │ │ ├── index.test.js │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── RecordItemIcon/ │ │ │ ├── RecordItemIcon.styles.ts │ │ │ ├── RecordItemIcon.tsx │ │ │ └── index.ts │ │ ├── RecordSortActionsPopupContent/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── RecordTypeMenu/ │ │ │ ├── index.js │ │ │ └── index.test.js │ │ ├── Select/ │ │ │ ├── SelectItem/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── SelectLabel/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── SidebarCategory/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── SidebarFolder/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── SidebarSearch/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── SwitchWithLabel/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ ├── TimerBar/ │ │ │ ├── index.test.js │ │ │ ├── index.ts │ │ │ └── styles.ts │ │ ├── TimerCircle/ │ │ │ ├── index.test.js │ │ │ ├── index.ts │ │ │ └── styles.ts │ │ ├── TitleBar/ │ │ │ └── index.js │ │ ├── Toasts/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.js │ │ └── WebsiteButton/ │ │ └── index.tsx │ ├── constants/ │ │ ├── appConstants.js │ │ ├── feedback.js │ │ ├── formFields.js │ │ ├── layout.ts │ │ ├── localStorage.js │ │ ├── meta.js │ │ ├── navigation.js │ │ ├── pairing.js │ │ ├── password.ts │ │ ├── pearpassLinks.js │ │ ├── recordActions.js │ │ ├── recordColorByType.js │ │ ├── recordIconByType.js │ │ ├── securityErrors.js │ │ ├── services.js │ │ ├── sortOptions.ts │ │ ├── timeConstants.js │ │ └── transitions.js │ ├── containers/ │ │ ├── AppHeaderContainer/ │ │ │ ├── AppHeaderContainer.test.js │ │ │ ├── AppHeaderContainer.tsx │ │ │ └── index.ts │ │ ├── AttachmentField/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── AuthenticationCard/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── BaseInitialPage/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── CustomFields/ │ │ │ └── index.tsx │ │ ├── EmptyCollectionViewV2/ │ │ │ ├── EmptyCollectionViewV2.styles.ts │ │ │ ├── EmptyCollectionViewV2.tsx │ │ │ └── index.ts │ │ ├── EmptyResultsViewV2/ │ │ │ ├── EmptyResultsViewV2.styles.ts │ │ │ ├── EmptyResultsViewV2.tsx │ │ │ └── index.ts │ │ ├── ImagesField/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── LayoutWithSidebar/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── MainViewHeader/ │ │ │ ├── MainViewHeader.styles.ts │ │ │ ├── MainViewHeader.test.tsx │ │ │ └── MainViewHeader.tsx │ │ ├── Modal/ │ │ │ ├── AddDeviceModalContent/ │ │ │ │ ├── ScanQRExpireTimer.tsx │ │ │ │ ├── index.ts │ │ │ │ └── styles.ts │ │ │ ├── AddDeviceModalContentV2/ │ │ │ │ ├── AddDeviceModalContentV2.styles.ts │ │ │ │ └── AddDeviceModalContentV2.tsx │ │ │ ├── AuthenticationModalContentV2/ │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── BlindPeersModalContent/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── BrowserExtensionDialogV2/ │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── ConfirmationModalContent/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── CreateFileEncryptionPassword/ │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── CreateFolderModalContent/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── CreateFolderModalContentV2/ │ │ │ │ ├── CreateFolderModalContentV2.styles.ts │ │ │ │ └── CreateFolderModalContentV2.tsx │ │ │ ├── CreateOrEditCategoryWrapper/ │ │ │ │ ├── CreateOrEditAuthenticatorModalContent/ │ │ │ │ │ ├── CreateOrEditAuthenticatorModalContent.styles.ts │ │ │ │ │ └── CreateOrEditAuthenticatorModalContent.tsx │ │ │ │ ├── CreateOrEditCreditCardModalContent/ │ │ │ │ │ └── index.js │ │ │ │ ├── CreateOrEditCreditCardModalContentV2/ │ │ │ │ │ ├── CreateOrEditCreditCardModalContentV2.styles.ts │ │ │ │ │ └── CreateOrEditCreditCardModalContentV2.tsx │ │ │ │ ├── CreateOrEditCustomModalContent/ │ │ │ │ │ └── index.js │ │ │ │ ├── CreateOrEditCustomModalContentV2/ │ │ │ │ │ ├── CreateOrEditCustomModalContentV2.styles.ts │ │ │ │ │ └── CreateOrEditCustomModalContentV2.tsx │ │ │ │ ├── CreateOrEditIdentityModalContent/ │ │ │ │ │ └── index.js │ │ │ │ ├── CreateOrEditIdentityModalContentV2/ │ │ │ │ │ ├── CreateOrEditIdentityModalContentV2.styles.ts │ │ │ │ │ └── CreateOrEditIdentityModalContentV2.tsx │ │ │ │ ├── CreateOrEditLoginModalContent/ │ │ │ │ │ └── index.js │ │ │ │ ├── CreateOrEditLoginModalContentV2/ │ │ │ │ │ ├── CreateOrEditLoginModalContentV2.styles.ts │ │ │ │ │ └── CreateOrEditLoginModalContentV2.tsx │ │ │ │ ├── CreateOrEditNoteModalContent/ │ │ │ │ │ └── index.js │ │ │ │ ├── CreateOrEditNoteModalContentV2/ │ │ │ │ │ ├── CreateOrEditNoteModalContentV2.styles.ts │ │ │ │ │ └── CreateOrEditNoteModalContentV2.tsx │ │ │ │ ├── CreateOrEditPassPhraseModalContent/ │ │ │ │ │ └── index.js │ │ │ │ ├── CreateOrEditPassPhraseModalContentV2/ │ │ │ │ │ ├── CreateOrEditPassPhraseModalContentV2.styles.ts │ │ │ │ │ └── CreateOrEditPassPhraseModalContentV2.tsx │ │ │ │ ├── CreateOrEditWifiModalContent/ │ │ │ │ │ └── index.js │ │ │ │ ├── CreateOrEditWifiModalContentV2/ │ │ │ │ │ ├── CreateOrEditWifiModalContentV2.styles.ts │ │ │ │ │ └── CreateOrEditWifiModalContentV2.tsx │ │ │ │ └── index.js │ │ │ ├── CreateOrEditVaultModalContentV2/ │ │ │ │ ├── CreateOrEditVaultModalContentV2.styles.ts │ │ │ │ └── CreateOrEditVaultModalContentV2.tsx │ │ │ ├── CreateVaultModalContent/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── DecryptFilePassword/ │ │ │ │ └── index.tsx │ │ │ ├── DeleteFolderModalContentV2/ │ │ │ │ ├── DeleteFolderModalContentV2.styles.ts │ │ │ │ └── DeleteFolderModalContentV2.tsx │ │ │ ├── DeleteRecordsModalContentV2/ │ │ │ │ ├── DeleteRecordsModalContentV2.styles.ts │ │ │ │ ├── DeleteRecordsModalContentV2.tsx │ │ │ │ └── index.ts │ │ │ ├── DeleteVaultModalContent/ │ │ │ │ ├── DeviceList.tsx │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── DeleteVaultModalContent.test.tsx │ │ │ │ │ └── DeviceList.test.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── styles.ts │ │ │ │ └── types.ts │ │ │ ├── DisplayPictureModalContent/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── DisplayPictureModalContentV2/ │ │ │ │ ├── DisplayPictureModalContentV2.styles.ts │ │ │ │ └── DisplayPictureModalContentV2.tsx │ │ │ ├── ExtensionPairingModalContent/ │ │ │ │ ├── ExtensionPairingModalContentV2.styles.ts │ │ │ │ ├── ExtensionPairingModalContentV2.tsx │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── GeneratePasswordModalContentV2/ │ │ │ │ ├── GeneratePasswordModalContentV2.styles.ts │ │ │ │ └── GeneratePasswordModalContentV2.tsx │ │ │ ├── GeneratePasswordSideDrawerContent/ │ │ │ │ ├── PassphraseChecker/ │ │ │ │ │ └── index.js │ │ │ │ ├── PassphraseGenerator/ │ │ │ │ │ └── index..js │ │ │ │ ├── PasswordChecker/ │ │ │ │ │ └── index.js │ │ │ │ ├── PasswordGenerator/ │ │ │ │ │ └── index.js │ │ │ │ ├── RuleSelector/ │ │ │ │ │ └── index.js │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── ImportItemOrVaultModalContentV2/ │ │ │ │ ├── ImportItemOrVaultModalContentV2.styles.ts │ │ │ │ └── index.tsx │ │ │ ├── ImportVaultPreviewModalContent/ │ │ │ │ ├── ImportVaultPreviewModalContent.styles.ts │ │ │ │ └── index.tsx │ │ │ ├── ModalContent/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── ModalHeader/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── ModifyMasterVaultModalContent/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── ModifyVaultModalContent/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── MoveFolderModalContent/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── MoveFolderModalContentV2/ │ │ │ │ ├── MoveFolderModalContentV2.styles.ts │ │ │ │ └── MoveFolderModalContentV2.tsx │ │ │ ├── PairedDevicesModalContent/ │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── SideDrawer/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── UnsavedChangesModalContent/ │ │ │ │ ├── UnsavedChangesModalContent.tsx │ │ │ │ └── index.ts │ │ │ ├── UpdateRequiredModalContent/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── UpdateRequiredModalContentV2/ │ │ │ │ ├── UpdateRequiredModalContentV2.styles.ts │ │ │ │ └── UpdateRequiredModalContentV2.tsx │ │ │ ├── UploadFilesModalContentV2/ │ │ │ │ ├── UploadFilesModalContentV2.styles.ts │ │ │ │ ├── UploadFilesModalContentV2.tsx │ │ │ │ └── index.ts │ │ │ ├── UploadImageModalContent/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── VaultPasswordFormModalContent/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── MultiSelectActionsBar/ │ │ │ ├── MultiSelectActionsBar.styles.ts │ │ │ ├── MultiSelectActionsBar.test.tsx │ │ │ ├── MultiSelectActionsBar.tsx │ │ │ └── index.ts │ │ ├── PassPhrase/ │ │ │ ├── PassPhraseSettings.js │ │ │ ├── PassPhraseV2.styles.ts │ │ │ ├── PassPhraseV2.tsx │ │ │ ├── __tests__/ │ │ │ │ ├── PassPhrase.test.js │ │ │ │ └── PassPhraseSettings.test.js │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── RecordDetails/ │ │ │ ├── CreditCardDetailsForm/ │ │ │ │ ├── CreditCardDetailsFormV2.styles.ts │ │ │ │ ├── CreditCardDetailsFormV2.tsx │ │ │ │ ├── index.js │ │ │ │ └── utils.ts │ │ │ ├── CustomDetailsForm/ │ │ │ │ ├── CustomDetailsFormV2.styles.ts │ │ │ │ ├── CustomDetailsFormV2.tsx │ │ │ │ └── index.js │ │ │ ├── IdentityDetailsForm/ │ │ │ │ ├── IdentityDetailsFormV2.styles.ts │ │ │ │ ├── IdentityDetailsFormV2.tsx │ │ │ │ ├── index.js │ │ │ │ └── utils.ts │ │ │ ├── LoginRecordDetailsForm/ │ │ │ │ ├── LoginRecordDetailsFormV2.styles.ts │ │ │ │ ├── LoginRecordDetailsFormV2.tsx │ │ │ │ ├── index.js │ │ │ │ └── utils.ts │ │ │ ├── NoteDetailsForm/ │ │ │ │ ├── NoteDetailsFormV2.styles.ts │ │ │ │ ├── NoteDetailsFormV2.tsx │ │ │ │ ├── index.js │ │ │ │ └── utils.ts │ │ │ ├── PassPhraseDetailsForm/ │ │ │ │ ├── PassPhraseDetailsFormV2.styles.ts │ │ │ │ ├── PassPhraseDetailsFormV2.tsx │ │ │ │ ├── index.js │ │ │ │ └── utils.ts │ │ │ ├── RecordDetailsContent/ │ │ │ │ └── index.js │ │ │ ├── RecordDetailsV2.styles.ts │ │ │ ├── RecordDetailsV2.tsx │ │ │ ├── WifiDetailsForm/ │ │ │ │ ├── WifiDetailsFormV2.styles.ts │ │ │ │ ├── WifiDetailsFormV2.tsx │ │ │ │ ├── index.js │ │ │ │ └── utils.ts │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── RecordListView/ │ │ │ ├── RecordListViewV2.styles.ts │ │ │ ├── RecordListViewV2.test.tsx │ │ │ ├── RecordListViewV2.tsx │ │ │ ├── RecordRowContextMenuV2.styles.ts │ │ │ ├── RecordRowContextMenuV2.tsx │ │ │ ├── index.tsx │ │ │ ├── styles.js │ │ │ └── utils.js │ │ ├── Sidebar/ │ │ │ ├── SidebarCategories/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── SidebarV2.styles.ts │ │ │ ├── SidebarV2.test.tsx │ │ │ ├── SidebarV2.tsx │ │ │ ├── VaultSelector/ │ │ │ │ ├── VaultSelector.styles.ts │ │ │ │ └── VaultSelector.tsx │ │ │ ├── index.js │ │ │ └── styles.js │ │ └── WifiPasswordQRCode/ │ │ ├── WifiPasswordQRCodeV2.tsx │ │ ├── index.js │ │ └── styles.js │ ├── context/ │ │ ├── AppHeaderContext.test.js │ │ ├── AppHeaderContext.tsx │ │ ├── BannerContext.js │ │ ├── LoadingContext.js │ │ ├── LoadingContext.test.js │ │ ├── ModalContext.js │ │ ├── ModalContext.test.js │ │ ├── RouterContext.d.ts │ │ ├── RouterContext.js │ │ ├── RouterContext.test.js │ │ ├── ToastContext.js │ │ ├── ToastContext.test.js │ │ └── UnsavedChangesContext.tsx │ ├── electron/ │ │ ├── index.js │ │ ├── vaultClientProxy.js │ │ └── vaultClientProxy.test.js │ ├── hooks/ │ │ ├── __tests__/ │ │ │ └── useTranslation.test.js │ │ ├── useAnimatedVisibility.js │ │ ├── useAnimatedVisibility.test.js │ │ ├── useAutoLockPreferences.test.js │ │ ├── useAutoLockPreferences.ts │ │ ├── useConnectExtension.js │ │ ├── useConnectExtension.test.js │ │ ├── useCopyToClipboard.electron.js │ │ ├── useCopyToClipboard.electron.test.js │ │ ├── useCreateOrEditRecord.d.ts │ │ ├── useCreateOrEditRecord.js │ │ ├── useCreateOrEditRecord.test.js │ │ ├── useCustomFields.js │ │ ├── useCustomFields.test.js │ │ ├── useGetMultipleFiles.js │ │ ├── useGetMultipleFiles.test.js │ │ ├── useLanguageOptions.js │ │ ├── useLanguageOptions.test.js │ │ ├── useOutsideClick.js │ │ ├── useOutsideClick.test.js │ │ ├── usePasteFromClipboard.js │ │ ├── usePasteFromClipboard.test.js │ │ ├── usePearUpdate.js │ │ ├── usePearUpdate.test.js │ │ ├── useRecordActionItems.d.ts │ │ ├── useRecordActionItems.js │ │ ├── useRecordActionItems.test.js │ │ ├── useRecordMenuItems.js │ │ ├── useRecordMenuItems.test.js │ │ ├── useRecordMenuItemsV2.test.ts │ │ ├── useRecordMenuItemsV2.ts │ │ ├── useRiveWithRetry.ts │ │ ├── useScrollOverflow.test.js │ │ ├── useScrollOverflow.ts │ │ ├── useSimulatedLoading.js │ │ ├── useSimulatedLoading.test.js │ │ ├── useTranslation.ts │ │ ├── useVaultSwitch.test.tsx │ │ ├── useVaultSwitch.tsx │ │ ├── useWindowResize.js │ │ └── useWindowResize.test.js │ ├── lib-react-components/ │ │ ├── components/ │ │ │ ├── ButtonCreate/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── ButtonFilter/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── ButtonFolder/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── ButtonLittle/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── ButtonPrimary/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── ButtonRadio/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── ButtonRoundIcon/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── ButtonSecondary/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── ButtonSingleInput/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── ButtonThin/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── CompoundField/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── HighlightString/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── InputField/ │ │ │ │ ├── index.test.js │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── NoticeText/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── PasswordField/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── PearPassInputField/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── PearPassPasswordField/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── PearPassPasswordFieldV2/ │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.tsx │ │ │ │ ├── styles.ts │ │ │ │ └── types.ts │ │ │ ├── Slider/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── Switch/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── TextArea/ │ │ │ │ ├── index.test.js │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ └── TooltipWrapper/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── icons/ │ │ │ ├── AboutIcon/ │ │ │ │ └── index.js │ │ │ ├── AppearanceIcon/ │ │ │ │ └── index.js │ │ │ ├── ArrowDownIcon/ │ │ │ │ └── index.js │ │ │ ├── ArrowLeftIcon/ │ │ │ │ └── index.js │ │ │ ├── ArrowRightIcon/ │ │ │ │ └── index.js │ │ │ ├── ArrowUpAndDown/ │ │ │ │ └── index.js │ │ │ ├── ArrowUpIcon/ │ │ │ │ └── index.js │ │ │ ├── AutoFillIcon/ │ │ │ │ └── index.js │ │ │ ├── BackIcon/ │ │ │ │ └── index.js │ │ │ ├── BrushIcon/ │ │ │ │ └── index.js │ │ │ ├── CalendarIcon/ │ │ │ │ └── index.js │ │ │ ├── CheckIcon/ │ │ │ │ └── index.js │ │ │ ├── CollapseIcon/ │ │ │ │ └── index.js │ │ │ ├── CommonFileIcon/ │ │ │ │ └── index.js │ │ │ ├── ComputerIcon/ │ │ │ │ └── index.js │ │ │ ├── CopyIcon/ │ │ │ │ └── index.js │ │ │ ├── CreditCardIcon/ │ │ │ │ └── index.js │ │ │ ├── CubeIcon/ │ │ │ │ └── index.js │ │ │ ├── DeleteIcon/ │ │ │ │ └── index.js │ │ │ ├── EmailIcon/ │ │ │ │ └── index.js │ │ │ ├── ErrorIcon/ │ │ │ │ └── index.js │ │ │ ├── ExitIcon/ │ │ │ │ └── index.js │ │ │ ├── EyeClosedIcon/ │ │ │ │ └── index.js │ │ │ ├── EyeIcon/ │ │ │ │ └── index.js │ │ │ ├── FolderIcon/ │ │ │ │ └── index.js │ │ │ ├── FullBodyIcon/ │ │ │ │ └── index.js │ │ │ ├── GenderIcon/ │ │ │ │ └── index.js │ │ │ ├── GroupIcon/ │ │ │ │ └── index.js │ │ │ ├── ImageIcon/ │ │ │ │ └── index.js │ │ │ ├── InfoIcon/ │ │ │ │ └── index.js │ │ │ ├── KebabMenuIcon/ │ │ │ │ └── index.js │ │ │ ├── KeyIcon/ │ │ │ │ └── index.js │ │ │ ├── LockCircleIcon/ │ │ │ │ └── index.js │ │ │ ├── LockIcon/ │ │ │ │ └── index.js │ │ │ ├── MoveToIcon/ │ │ │ │ └── index.js │ │ │ ├── MultiSelectionIcon/ │ │ │ │ └── index.js │ │ │ ├── NationalityIcon/ │ │ │ │ └── index.js │ │ │ ├── NewFolderIcon/ │ │ │ │ └── index.js │ │ │ ├── NineDotsIcon/ │ │ │ │ └── index.js │ │ │ ├── NoteIcon/ │ │ │ │ └── index.js │ │ │ ├── OkayIcon/ │ │ │ │ └── index.js │ │ │ ├── OutsideLinkIcon/ │ │ │ │ └── index.js │ │ │ ├── PassPhraseIcon/ │ │ │ │ └── index.js │ │ │ ├── PasswordIcon/ │ │ │ │ └── index.js │ │ │ ├── PasteIcon/ │ │ │ │ └── index.js │ │ │ ├── PhoneIcon/ │ │ │ │ └── index.js │ │ │ ├── PinIcon/ │ │ │ │ └── index.js │ │ │ ├── PlusIcon/ │ │ │ │ └── index.js │ │ │ ├── SaveIcon/ │ │ │ │ └── index.js │ │ │ ├── SearchIcon/ │ │ │ │ └── index.js │ │ │ ├── SecurityIcon/ │ │ │ │ └── index.js │ │ │ ├── SettingsIcon/ │ │ │ │ └── index.js │ │ │ ├── ShareIcon/ │ │ │ │ └── index.js │ │ │ ├── SmallArrowIcon/ │ │ │ │ └── index.js │ │ │ ├── StarIcon/ │ │ │ │ └── index.js │ │ │ ├── SyncingIcon/ │ │ │ │ └── index.js │ │ │ ├── TimeIcon/ │ │ │ │ └── index.js │ │ │ ├── UserIcon/ │ │ │ │ └── index.js │ │ │ ├── UserSecurityIcon/ │ │ │ │ └── index.js │ │ │ ├── VaultIcon/ │ │ │ │ └── index.js │ │ │ ├── WifiIcon/ │ │ │ │ └── index.js │ │ │ ├── WorldIcon/ │ │ │ │ └── index.js │ │ │ ├── XIcon/ │ │ │ │ └── index.js │ │ │ ├── YellowErrorIcon/ │ │ │ │ └── index.js │ │ │ └── icons.test.js │ │ ├── illustrations/ │ │ │ └── AuthenticatorIllustration/ │ │ │ └── index.js │ │ ├── index.js │ │ └── utils/ │ │ ├── getIconProps.js │ │ └── getIconProps.test.js │ ├── pages/ │ │ ├── AuthenticatorView/ │ │ │ ├── index.js │ │ │ ├── index.test.js │ │ │ └── styles.ts │ │ ├── InitialPage/ │ │ │ └── index.js │ │ ├── Intro/ │ │ │ ├── CategoryAnimation/ │ │ │ │ └── index.tsx │ │ │ ├── CreditCardAnimation/ │ │ │ │ └── index.tsx │ │ │ ├── GradientContainer/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── IntroV2.tsx │ │ │ ├── IntroV2Styles.ts │ │ │ ├── OnboardingLockVideo/ │ │ │ │ └── index.tsx │ │ │ ├── PasswordFillAnimation/ │ │ │ │ ├── index.tsx │ │ │ │ └── styles.js │ │ │ ├── SyncWithoutCloudAnimation/ │ │ │ │ └── index.tsx │ │ │ ├── TutorialContainer/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── WelcomeToPearpass/ │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── LoadingPage/ │ │ │ ├── LoadingPageV2.tsx │ │ │ ├── LoadingPageV2Styles.ts │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── MainView/ │ │ │ ├── MainViewV2.styles.ts │ │ │ ├── MainViewV2.tsx │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── SettingsView/ │ │ │ ├── AboutContent/ │ │ │ │ ├── index.js │ │ │ │ └── index.test.js │ │ │ ├── AppearanceContent/ │ │ │ │ └── index.js │ │ │ ├── ExportTab/ │ │ │ │ ├── index.js │ │ │ │ ├── styles.js │ │ │ │ └── utils/ │ │ │ │ ├── downloadFile.js │ │ │ │ ├── downloadFile.test.js │ │ │ │ ├── downloadZip.js │ │ │ │ ├── downloadZip.test.js │ │ │ │ ├── exportCsvPerVault.js │ │ │ │ └── exportJsonPerVault.js │ │ │ ├── ImportTab/ │ │ │ │ ├── index.js │ │ │ │ ├── styles.js │ │ │ │ └── utils/ │ │ │ │ ├── readFileContent.js │ │ │ │ └── readFileContent.test.js │ │ │ ├── SecurityContent/ │ │ │ │ └── index.js │ │ │ ├── SettingsAdvancedTab/ │ │ │ │ ├── SettingsAutoLockConfiguration/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.ts │ │ │ │ ├── SettingsBlindPeersSection/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── index.test.js │ │ │ │ │ └── styles.js │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── SettingsTab/ │ │ │ │ ├── SettingsDevicesSection/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── index.test.js │ │ │ │ │ └── styles.js │ │ │ │ ├── SettingsLanguageSection/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── index.test.js │ │ │ │ │ └── styles.js │ │ │ │ ├── SettingsPasswordsSection/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── styles.js │ │ │ │ ├── SettingsReportSection/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── index.test.js │ │ │ │ │ └── styles.js │ │ │ │ ├── index.js │ │ │ │ └── styles.js │ │ │ ├── SettingsVaultsTab/ │ │ │ │ ├── index.js │ │ │ │ ├── index.test.js │ │ │ │ └── styles.js │ │ │ ├── SyncingContent/ │ │ │ │ └── index.js │ │ │ ├── VaultContent/ │ │ │ │ └── index.js │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── SettingsViewV2/ │ │ │ ├── SettingsViewV2.styles.ts │ │ │ ├── SettingsViewV2.tsx │ │ │ └── content/ │ │ │ ├── AppPreferencesContent/ │ │ │ │ ├── index.test.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── AppVersionContent/ │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── BlindPeersContent/ │ │ │ │ ├── index.test.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── DiagnosticsContent/ │ │ │ │ ├── index.test.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── ExportItemsContent/ │ │ │ │ ├── index.test.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── ImportCodesContent/ │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── ImportItemsContent/ │ │ │ │ ├── index.test.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── LanguageContent/ │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── MasterPasswordContent/ │ │ │ │ ├── index.test.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── ReportAProblemContent/ │ │ │ │ ├── index.test.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── YourDevicesContent/ │ │ │ │ ├── index.test.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ ├── YourVaultsContent/ │ │ │ │ ├── index.test.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ │ └── index.ts │ │ └── WelcomePage/ │ │ ├── CardCreateMasterPassword/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── CardCreateMasterPasswordV2/ │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── CardLoadVault/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── CardNewVaultCredentials/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── CardUnlockPearPass/ │ │ │ └── index.tsx │ │ ├── CardUnlockPearPassV2/ │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── CardUnlockVault/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── CardUploadBackupFile/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── CardVaultSelect/ │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── LockedScreen/ │ │ │ ├── Timer.js │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── LockedScreenV2/ │ │ │ ├── LockedScreenV2.styles.ts │ │ │ ├── LockedScreenV2.test.tsx │ │ │ └── LockedScreenV2.tsx │ │ ├── index.js │ │ └── styles.js │ ├── services/ │ │ ├── createOrGetPearpassClient.js │ │ ├── createOrGetPearpassClient.test.js │ │ ├── createOrGetPipe.js │ │ ├── createOrGetPipe.test.js │ │ ├── handlers/ │ │ │ ├── EncryptionHandlers.js │ │ │ ├── EncryptionHandlers.test.js │ │ │ ├── SecureRequestHandler.js │ │ │ ├── SecureRequestHandler.test.js │ │ │ ├── SecurityHandlers.js │ │ │ ├── SecurityHandlers.test.js │ │ │ └── VaultHandlers.js │ │ ├── ipc/ │ │ │ ├── MethodRegistry.js │ │ │ ├── MethodRegistry.test.js │ │ │ ├── SocketManager.js │ │ │ └── SocketManager.test.js │ │ ├── nativeMessagingIPCServer.js │ │ ├── nativeMessagingIPCServer.test.js │ │ ├── nativeMessagingPreferences.js │ │ ├── nativeMessagingPreferences.test.js │ │ └── security/ │ │ ├── appIdentity.js │ │ ├── appIdentity.test.js │ │ ├── protocolConstants.js │ │ ├── sessionManager.js │ │ ├── sessionManager.test.js │ │ └── sessionStore.js │ ├── shared/ │ │ ├── commandDefinitions.js │ │ └── types.ts │ ├── strict.css │ ├── svgs/ │ │ ├── ItemCardIllustration/ │ │ │ ├── ItemCardIllustration.tsx │ │ │ └── index.ts │ │ ├── LogoLock/ │ │ │ ├── index.js │ │ │ └── index.test.js │ │ ├── OnboardingLock/ │ │ │ └── index.js │ │ ├── PearLogo/ │ │ │ └── index.js │ │ ├── PearpassLogo/ │ │ │ └── index.js │ │ ├── ProtonPass/ │ │ │ └── index.js │ │ ├── SpotlightLeft/ │ │ │ ├── index.js │ │ │ └── index.test.js │ │ ├── SpotlightMiddle/ │ │ │ ├── index.js │ │ │ └── index.test.js │ │ └── SpotlightRight/ │ │ ├── index.js │ │ └── index.test.js │ ├── types/ │ │ ├── css.d.ts │ │ ├── electron.d.ts │ │ ├── jest-globals.d.ts │ │ ├── modules.d.ts │ │ └── styled.d.ts │ └── utils/ │ ├── addHttps.js │ ├── addHttps.test.js │ ├── applyGlobalStyles.js │ ├── applyGlobalStyles.test.js │ ├── autoLock.js │ ├── autoLock.test.js │ ├── breakpoints.js │ ├── breakpoints.test.js │ ├── createErrorWithCode.js │ ├── createErrorWithCode.test.js │ ├── designVersion.js │ ├── devicePreferences.cjs │ ├── devicePreferences.test.js │ ├── envGetter.js │ ├── envGetter.test.js │ ├── extractDomainName.js │ ├── extractDomainName.test.js │ ├── formatPasskeyDate.js │ ├── getDeviceName.js │ ├── getDeviceName.test.js │ ├── getFilteredAttachmentsById.js │ ├── getFilteredAttachmentsById.test.js │ ├── getPasswordStrengthInfo.test.ts │ ├── getPasswordStrengthInfo.ts │ ├── getRecordSubtitle.test.ts │ ├── getRecordSubtitle.ts │ ├── groupRecordsByTimePeriod.test.ts │ ├── groupRecordsByTimePeriod.ts │ ├── handleFileSelect.js │ ├── handleFileSelect.test.js │ ├── isFavorite.js │ ├── isFavorite.test.js │ ├── isOnline.js │ ├── isPasswordChangeReminderDisabled.js │ ├── isPasswordChangeReminderDisabled.test.js │ ├── logHelper.cjs │ ├── logHelper.test.js │ ├── logger.js │ ├── logger.test.js │ ├── nativeMessagingSetup.js │ ├── nativeMessagingSetup.test.js │ ├── sortByName.js │ ├── sortByName.test.js │ ├── toSentenceCase.js │ ├── toSentenceCase.test.js │ ├── vaultCreated.js │ ├── vaultCreated.test.js │ ├── withAlpha.test.js │ └── withAlpha.ts ├── styles.js └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/skills/use-ui-kit/SKILL.md ================================================ --- name: use-ui-kit description: Use whenever creating or editing UI in this repo — React components, modals, dialogs, forms, buttons, inputs, typography, styling, icons, or any .tsx/.jsx work. The repo uses `@tetherto/pearpass-lib-ui-kit` as the single source for UI primitives; do not roll custom ones. Load this before suggesting any UI change, especially for v2 files (V2 suffix) or when touching src/components, src/containers/Modal, or src/pages. --- # UI conventions for pearpass-app-desktop-tether This is the Electron desktop app for PearPass. It's written in React + TypeScript. UI is built on the shared component library `@tetherto/pearpass-lib-ui-kit`. This document is for **anyone contributing UI** to the repo — new hires, current engineers, and AI coding assistants (Claude Code, Cursor, Codex, etc.). It captures the component catalog, styling conventions, file-naming rules, and patterns we use when building UI in this app. Read it once before your first UI change; keep it open when you're in doubt. ## Design-system state — `DESKTOP_DESIGN_VERSION === 2` Which design renders at runtime is controlled by the `DESKTOP_DESIGN_VERSION` flag from `@tetherto/pearpass-lib-constants` (resolved in [electron/runtime-config.cjs](../../../electron/runtime-config.cjs) and exposed via `isV2()` in [src/utils/designVersion.js](../../../src/utils/designVersion.js)). **Currently `DESKTOP_DESIGN_VERSION === 2`**, so kit components are the right choice for all new UI work. Legacy v1 components still live under [src/lib-react-components/](../../../src/lib-react-components/) and are rendered whenever `isV2()` returns `false` — do not delete them as part of v2 work. ## File naming: when to use the `V2` suffix The `V2` suffix is a **coexistence marker**, not a design marker. Use it only when a v1 sibling already exists: - **A v1 file already exists** for this component/screen → create a new file with the `V2` suffix next to it (e.g. v1 `CreateVaultModalContent.jsx` → new `CreateVaultModalContentV2.tsx`). Both live in the tree during migration; the branching happens at the call site via `isV2()`. - **No v1 equivalent exists** (net-new feature, net-new component) → create the file with its natural name, **no `V2` suffix**. The codebase is already v2 by default, so the suffix would just be noise. Before creating a file, glob the directory for the base name without the suffix. If nothing comes up, skip the suffix. ## Golden rules 1. **Check the catalog below before creating any component.** If it exists in the kit, use it — never wrap or reimplement. 2. **All new UI goes through the kit.** Any new `.tsx`/`.jsx` file — suffixed or not — must import from `@tetherto/pearpass-lib-ui-kit`, not from [src/lib-react-components/](../../../src/lib-react-components/). 3. **Never add variants under [src/lib-react-components/components/](../../../src/lib-react-components/components/)** (`ButtonThin`, `ButtonPrimary`, `ButtonRoundIcon`, `PearPassInputField`, etc.). That tree is legacy; the kit's `Button` takes variants. 4. **Style with tokens.** Use `useTheme()` + `rawTokens`. No hardcoded hex colors or px spacing. 5. **Icons come from the kit.** `@tetherto/pearpass-lib-ui-kit/icons` has 530 icons. Do not add new SVGs under `src/`. 6. **If the kit lacks something you need, stop and ask the user.** Don't silently roll a custom component. ## Component catalog (31 components) Import pattern: `import { ComponentName } from '@tetherto/pearpass-lib-ui-kit'` ### Actions - `Button` — all CTAs. Takes variants; use instead of `ButtonThin`, `ButtonPrimary`, `ButtonSecondary`, `ButtonRoundIcon`, `ButtonLittle`, `ButtonFilter`, `ButtonFolder`, `ButtonRadio`, `ButtonSingleInput`, `ButtonCreate`. - `Pressable` — low-level pressable wrapper for custom interactive elements. - `Link` — text links. ### Forms - `Form` — form wrapper with validation. - `InputField` — text input. Use instead of `PearPassInputField`. - `PasswordField` — password input with strength indicator. Use instead of `PearPassPasswordField` / `PearPassPasswordFieldV2`. - `SearchField` — search input. - `SelectField` — dropdown select. - `Dropdown` — low-level dropdown primitive. - `TextArea` — multiline text input. - `Checkbox` - `Radio` - `ToggleSwitch` - `Slider` - `DateField` - `AttachmentField` - `UploadField` - `MultiSlotInput` — split inputs for OTP / recovery codes. Use instead of custom `OtpCodeField`. - `FieldError` — inline field validation error. ### Typography - `Title` — headings. - `Text` — body text. ### Layout / surfaces - `Dialog` — modals. Use instead of custom `ModalContent` wrappers. - `NativeBottomSheet` — bottom sheets. - `PageHeader` — top-of-page header. - `ItemScreenHeader` — item-detail header. - `Breadcrumb` - `ListItem` - `NavbarListItem` - `ContextMenu` ### Feedback - `AlertMessage` — inline alerts. Reference: [src/pages/WelcomePage/CardCreateMasterPasswordV2/index.tsx](../../../src/pages/WelcomePage/CardCreateMasterPasswordV2/index.tsx). - `Snackbar` — toast-style notifications. - `PasswordIndicator` — standalone password strength meter. ### Type exports - `ThemeColors`, `Theme`, `ThemeType`, `RawTokens` - `PasswordIndicatorVariant` — `'vulnerable' | 'decent' | 'strong'` Import types with `import type { ... } from '@tetherto/pearpass-lib-ui-kit'`. ## Component props (15 most-used) Required props have no `?`. **Always include a test ID on interactive components** — see the "Test IDs" section below for which prop to use per component. - **Button** — `variant: 'primary' | 'secondary' | 'tertiary' | 'destructive'`, `size: 'small' | 'medium'`, `onClick`, `children`, `type?: 'button' | 'submit'`, `disabled?`, `isLoading?`, `iconBefore?`, `iconAfter?`, `data-testid?`. Icon-only buttons need `aria-label`. - **Dialog** — `title` (ReactNode), `onClose?`, `open?`, `footer?`, `children?`, `closeOnOutsideClick?`, `hideCloseButton?`, `trapFocus?`, `initialFocusRef?`, `testID?`, `closeButtonTestID?`. Put action buttons in `footer`. - **InputField** — `label`, `value`, `onChange?: (e) => void`, `placeholder?`, `error?: string`, `inputType?: 'text' | 'password'`, `disabled?`, `readOnly?`, `copyable?`, `onCopy?`, `leftSlot?`, `rightSlot?`, `testID?`. - **PasswordField** — `label`, `value`, `onChange?`, `placeholder?`, `error?`, `passwordIndicator?: 'vulnerable' | 'decent' | 'strong' | 'match'`, `infoBox?: string`, `copyable?`, `testID?`. - **SearchField** — `value`, `onChangeText` (yes, this one is still current), `placeholderText?`, `size?: 'small' | 'medium'`, `testID?`. - **Form** — `children`, `onSubmit?`, `noValidate?`, `testID?`. Wrap fields here; pair with `useForm` from `@tetherto/pear-apps-lib-ui-react-hooks`. - **Text** — `children`, `as?: 'p' | 'span'`, `variant?: 'label' | 'labelEmphasized' | 'body' | 'bodyEmphasized' | 'caption'`, `color?`, `numberOfLines?`, `data-testid?`. - **Title** — `children`, `as?: 'h1' | 'h2' | ... | 'h6'`, `data-testid?`. - **AlertMessage** — `variant: 'info' | 'warning' | 'error'`, `size: 'small' | 'medium' | 'big'`, `title`, `description`, `actionText?`, `onAction?`, `testID?`, `actionTestId?`. - **ToggleSwitch** — `checked?`, `onChange?: (b: boolean) => void`, `label?`, `description?`, `disabled?`, `data-testid?`. - **Checkbox** — same shape as `ToggleSwitch` (uses `data-testid`). - **Radio** — `options: Array<{value, label?, description?, disabled?}>`, `value?`, `onChange?: (v: string) => void`, `testID?`. - **SelectField** — `label`, `value?`, `placeholder?`, `onClick?` (opens dropdown), `error?`, `disabled?`, `leftSlot?`, `rightSlot?`, `testID?`. - **TextArea** — `value`, `onChange?`, `label?`, `placeholder?`, `error?`, `disabled?`, `testID?`. - **Link** — `children`, `href?`, `isExternal?`, `onClick?`, `data-testid?` (and standard `` attributes). For components not listed, open `node_modules/@tetherto/pearpass-lib-ui-kit/dist/components//types.d.ts`. ### Test IDs — `testID` vs `data-testid` Always include a test ID on anything a user interacts with (buttons, fields, toggles, dialogs). Which prop depends on the component: - **`testID`** — components that declare it explicitly: `Dialog` (+ `closeButtonTestID`), `Form`, `InputField`, `PasswordField`, `SearchField`, `SelectField`, `TextArea`, `Radio`, `AlertMessage` (+ `actionTestId`). - **`data-testid`** — components that extend native HTML and don't redeclare it: `Button`, `ToggleSwitch`, `Checkbox`, `Link`, `Pressable`, `Text`, `Title`. Rule of thumb: try `testID` first; if TypeScript rejects it, use `data-testid`. When editing an existing file, follow the naming pattern already there (e.g. `createvault-name-v2`, `createvault-discard-v2`). ### Prop naming — modern vs. deprecated (important) The kit recently renamed several field props. **Use the modern names:** | Use | Not (deprecated) | | --- | --- | | `onChange` (receives `ChangeEvent`) | `onChangeText` (receives string) | | `placeholder` | `placeholderText` | | `error` (string) | `errorMessage` + `variant` | ⚠️ **Existing v2 files in this repo (e.g. `CreateVaultModalContentV2`, `CardCreateMasterPasswordV2`) still use the deprecated props.** Don't copy their prop names blindly — use the modern ones in new code. The deprecated props still work for now but will be removed. **Exception:** `SearchField` still uses `onChangeText` + `placeholderText` — those aren't deprecated there. `testID` is current everywhere. ## Theming The codebase does **not** use styled-components. The convention is a `createStyles(colors)` factory that returns plain inline-style objects, consumed via `style={styles.foo}`. **In the component** (reference: `src/pages/WelcomePage/CardCreateMasterPasswordV2/index.tsx`): ```tsx import { useTheme } from '@tetherto/pearpass-lib-ui-kit' import { createStyles } from './styles' const Component = () => { const { theme } = useTheme() const styles = createStyles(theme.colors) return
} ``` **In the companion `styles.ts`** (reference: `src/pages/WelcomePage/CardCreateMasterPasswordV2/styles.ts`): ```ts import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = (colors: ThemeColors) => ({ card: { background: colors.colorSurfacePrimary, border: `1px solid ${colors.colorBorderPrimary}`, borderRadius: `${rawTokens.radius8}px`, padding: `${rawTokens.spacing24}px`, gap: `${rawTokens.spacing12}px`, }, }) ``` ### `rawTokens` — flat, numeric-suffixed keys (not nested) - Spacing: `spacing2`, `spacing4`, `spacing6`, `spacing8`, `spacing10`, `spacing12`, `spacing16`, `spacing20`, `spacing24`, `spacing32`, `spacing40`, `spacing48` (all `number`, multiply with `${n}px`) - Radius: `radius8`, `radius16`, `radius20`, `radius26` - Font size: `fontSize12`, `fontSize14`, `fontSize16`, `fontSize24`, `fontSize28` - Font family: `fontPrimary` (`"Inter"`), `fontDisplay` (`"Humble Nostalgia"`) - Weight: `weightRegular` (`"400"`), `weightMedium` (`"500"`) ### `theme.colors` — common keys seen in this repo `colorSurfacePrimary`, `colorSurfaceHover`, `colorBorderPrimary`, `colorBorderSecondary`, `colorTextPrimary`, `colorTextSecondary`, `colorTextTertiary`, `colorLinkText`. If you need one you haven't seen, inspect the `ThemeColors` type from `@tetherto/pearpass-lib-ui-kit`. ### When hardcoded values are OK Tokens cover the design-system primitives. Feature-specific layout values (a card's `maxWidth: '500px'`, a one-off `padding: '55px 0'`) are fine as literals — these aren't design tokens. **Rule of thumb:** if the value corresponds to a semantic design decision (spacing step, brand color, radius), it must come from a token. ## Icons ```tsx import { Add, Download, Folder, OpenInNew } from '@tetherto/pearpass-lib-ui-kit/icons' ``` 530 icons, mostly Material Design, with style variants as suffixes: `Filled`, `Outlined`, `Round`, `Sharp`, `Tone` (e.g. `LockFilled`, `InfoOutlined`, `KeyboardArrowRightRound`). If a name has no suffix, it exists as a single variant. **Commonly used in this repo** (check these first before browsing): - **Actions:** `Add`, `Download`, `ContentCopy`, `Share`, `Send`, `Swap`, `UploadFileFilled` - **Folder / organization:** `Folder`, `FolderOpen`, `FolderCopy`, `CreateNewFolder`, `Layers` - **Navigation / arrows:** `KeyboardArrowRightFilled`, `KeyboardArrowRightRound`, `KeyboardArrowLeftFilled`, `ExpandMore` - **Status / feedback:** `InfoOutlined`, `ReportProblemRound`, `ErrorFilled`, `Check`, `DoneAll`, `CheckBox` - **Security:** `LockFilled`, `Key`, `SecurityFilled`, `Fingerprint`, `TwoFactorAuthenticationFilled` - **External / misc:** `ImportOutlined`, `OpenInNew` **Discovering others:** `ls node_modules/@tetherto/pearpass-lib-ui-kit/dist/icons/components/ | grep -i ` — names are PascalCase, grep is case-insensitive friendly. ## Anti-patterns to avoid When editing a v2 file or creating new UI, do **not**: - Add a new file under `src/lib-react-components/components/` for a Button/Input/Modal variant. - Import `PearPassInputField`, `PearPassPasswordField`, or any `Button*` variant from `src/lib-react-components/` into any new file — swap to the kit equivalents. - Add a `V2` suffix to a net-new file that has no v1 sibling. Suffix is only for migration coexistence. - Use native `
` ================================================ FILE: appling/lib/worker.cjs ================================================ const appling = require('appling-native') const { App } = require('fx-native') const bootstrap = require('pear-updater-bootstrap') const { Progress } = require('./progress') const { encode, decode, format } = require('./utils') const app = App.shared() let config let platform let installing = false // Guard against concurrent installation function setup(data) { config = data } /** * Validates that config has all required fields. * @returns {string|null} Error message if invalid, null if valid */ function validateConfig() { if (!config) { return 'Configuration not received - setup() was not called' } if (!config.dir) { return "Configuration missing required 'dir' field" } if (!config.platform) { return "Configuration missing required 'platform' field" } if (!config.link) { return "Configuration missing required 'link' field" } return null } async function install() { // Prevent concurrent installation attempts if (installing) { return } // Validate configuration before proceeding const configError = validateConfig() if (configError) { console.error('[worker] Configuration error:', configError) app.broadcast(encode({ type: 'error', error: configError })) return } installing = true const progress = new Progress(app, [0.3, 0.7]) let platformFound = false let bootstrapInterval = null try { try { platform = await appling.resolve(config.dir) platformFound = true } catch { // Platform not found - bootstrap it await bootstrap(config.platform, config.dir, { lock: false, onupdater: (u) => { bootstrapInterval = setInterval(() => { progress.update(format(u)) if (u.downloadProgress === 1) { clearInterval(bootstrapInterval) } }, 250) } }) platform = await appling.resolve(config.dir) } if (platformFound) { progress.stage(0, 1) } progress.update({ progress: 0, speed: '', peers: 0, bytes: 0 }, 1) await platform.preflight(config.link, (u) => { progress.update(format(u), 1) }) progress.complete() app.broadcast(encode({ type: 'complete' })) } catch (e) { console.error('[worker] Installation error:', e.message) app.broadcast(encode({ type: 'error', error: e.message })) } finally { // Always reset installing flag to allow retry installing = false if (bootstrapInterval) { clearInterval(bootstrapInterval) } } } app.on('message', async (message) => { const msg = decode(message) // Handle decode failure (malformed JSON) if (!msg) { return } switch (msg.type) { case 'config': setup(msg.data) break case 'install': await install() break } }) app.broadcast(encode({ type: 'ready' })) ================================================ FILE: appling/package.json ================================================ { "private": true, "name": "PearPass", "version": "1.0.0", "description": "Pear Appling for PearPass", "engines": { "node": ">=22.0.0" }, "exports": { "./package": "./package.json" }, "files": [ "app.cjs", "lib" ], "scripts": { "test": "npm run lint", "lint": "prettier . --check" }, "repository": { "type": "git", "url": "git+https://github.com/tetherto/pearpass-app-desktop.git" }, "author": "Tether Data S.A. de C.V.", "license": "Apache-2.0", "bugs": { "url": "https://github.com/tetherto/pearpass-app-desktop/issues" }, "homepage": "https://github.com/tetherto/pearpass-app-desktop#readme", "dependencies": { "appling-native": "^1.4.0", "bare-thread": "^1.1.3", "fx-native": "^1.1.3", "pear-updater-bootstrap": "^2.3.0", "prettier-bytes": "^1.0.4" }, "devDependencies": { "prettier": "^3.6.2", "prettier-config-holepunch": "^2.0.0" } } ================================================ FILE: babel.config.cjs ================================================ module.exports = { presets: [ [ '@babel/preset-env', { targets: { node: 'current' }, modules: 'commonjs' } ], '@babel/preset-react', '@babel/preset-typescript' ], env: { test: { // Compile `css.create(...)` / `html.*` calls from react-strict-dom and // @tetherto/pearpass-lib-ui-kit the same way the production bundler does // (see `scripts/bundle-renderer.mjs`). Without this, evaluating those // modules in Jest throws "Styles must be compiled by '@stylexjs/babel-plugin'". presets: [ [ 'react-strict-dom/babel-preset', { debug: false, dev: false, rootDir: process.cwd(), platform: 'web' } ] ] } } } ================================================ FILE: babel.strict-dom.cjs ================================================ const dev = process.env.NODE_ENV !== 'production' module.exports = { babelrc: false, configFile: false, parserOpts: { plugins: ['typescript', 'jsx'] }, presets: [ [ 'react-strict-dom/babel-preset', { debug: dev, dev, rootDir: process.cwd(), platform: 'web' } ] ] } ================================================ FILE: build-assets/win/AppxManifest.xml ================================================ PearPass PearPass PearPass password manager assets\PearPass.png disabled disabled ================================================ FILE: docs/Electron-Packaging-And-Runtime.md ================================================ # Electron packaging and runtime This document describes how the PearPass desktop app is built, packaged, and how the main process, worklet (vault), and renderer communicate. --- ## 1. Architecture overview ``` ┌─────────────────────────────────────────────────────────────────┐ │ Renderer (React) │ │ - Uses window.electronAPI (from preload) for vault & runtime │ └────────────────────────────┬────────────────────────────────────┘ │ IPC (vault:invoke, runtime:*, get-app-path) ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Main process (electron/main.cjs) │ │ - Creates window, preload, BrowserWindow │ │ - Starts worklet via pear-runtime or bare-sidecar │ │ - Registers IPC handlers; forwards vault calls to vaultClient │ └────────────────────────────┬────────────────────────────────────┘ │ stdio / IPC pipe ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Worklet (Bare sidecar) │ │ - Runs vault logic (pearpass-lib-vault-core worklet) │ │ - Dev: app.js (ESM). Packaged: app.cjs (CJS bundle) │ └─────────────────────────────────────────────────────────────────┘ ``` - **Renderer** never talks to the worklet directly. It calls `window.electronAPI.vaultInvoke(method, args)` (and runtime helpers). The **main process** receives those and forwards them to **PearpassVaultClient**, which speaks to the worklet over a pipe. - The **worklet** runs in a separate process (Bare runtime). It uses `bare-*` modules and native addons; the main process only spawns it and connects IPC. --- ## 2. Main process (electron/main.cjs) - **Entry:** `main` in package.json points to `electron/main.cjs`. - **On ready:** Sets log path, registers IPC, then starts the runtime via `startRuntime()` and finally creates the main window. - **Runtime start:** - If `runtime-config.cjs.upgrade` is set: uses **pear-runtime** (P2P OTA), configures storage based on `pear-runtime-legacy-storage`, and calls `PearRuntime.run(workletPath)` to launch the vault worklet as a sidecar. - If no upgrade link is set: uses **bare-sidecar** only (`startWorkletOnly()`), spawns the worklet with `new Sidecar(workletPath)` and runs without P2P updates. - **Storage layout:** Tries to reuse existing Pear platform storage via `pear-runtime-legacy-storage`. If none is found, it falls back to `app.getPath('userData')/app-storage/by-dkey/`. - **Flatpak compatibility:** `electron/flatpak-paths.cjs` wraps both `app.getPath('userData')` and any legacy Pear storage path with `getSandboxSafePath()`. Inside Flatpak, host-mapped XDG paths under `~/.var/app/...` are remapped into `~/.config/...` compatibility paths so the vault worklet accepts them. - **Packaged app:** With `asar: false` all code and `node_modules` live under `Contents/Resources/app/` on macOS, so the worklet and renderer resolve modules from the real filesystem (no `app.asar` indirection). - **IPC:** Handles `get-app-path`, `runtime:getConfig`, `runtime:applyUpdate`, `runtime:restart`, `runtime:checkUpdated`, and `vault:invoke`. Vault methods are forwarded to `vaultClient`; Buffers are serialized as `{ __base64 }`. It also listens for `pearRuntime.updater` events and forwards `runtime:updating` / `runtime:updated` to the renderer to drive the OTA UI. --- ## 3. Worklet: dev vs packaged The vault worklet lives in `@tetherto/pearpass-lib-vault-core` (Git dependency) under `src/worklet/`. It is loaded in two different ways so it works in both dev and packaged app. ### 3.1 Dev - **Path:** `getWorkletPath()` returns `app.getAppPath()/node_modules/@tetherto/pearpass-lib-vault-core/src/worklet/app.js`. - **Format:** ESM (`app.js`). The Bare loader in dev can run ESM and resolve Node built-ins to its own shims. - **No bundle:** Dependencies are required from the real `node_modules` tree. ### 3.2 Packaged - **Path:** `getWorkletPath()` returns `process.resourcesPath/app/node_modules/@tetherto/pearpass-lib-vault-core/src/worklet/app.cjs`. - **Format:** CommonJS bundle (`app.cjs`). The Bare runtime used in the packaged app loads the entry as CJS; giving it ESM `app.js` would throw “Cannot use import statement outside a module”. - **Bundle:** Produced by `scripts/build.worklet.mjs` (see below). Only the worklet **source** (relative imports) is bundled; all `node_modules` are external so Bare resolves them at runtime and native addons work. --- ## 4. Worklet build (scripts/build.worklet.mjs) - **Runs as part of `npm run build`** (before `tsc` and the renderer bundle). - **Input:** `node_modules/@tetherto/pearpass-lib-vault-core/src/worklet/app.js` (ESM). - **Output:** `node_modules/@tetherto/pearpass-lib-vault-core/src/worklet/app.cjs` (single CJS file). - **Behaviour (current esbuild config):** - `entryPoints`: the ESM worklet entry; `bundle: true`, `platform: 'node'`, `format: 'cjs'`, `target: 'node18'`. - **Externalize Node built-ins and native-heavy modules:** `fs`, `path`, `os`, `net`, `crypto`, `child_process`, `fs/promises`, `require-addon`, `fs-native-extensions`, `sodium-native` are marked as `external` so they resolve at runtime from `node_modules`. - **Result:** A CJS bundle that contains only the worklet code; at runtime Bare loads its dependencies from `node_modules` in the packaged app. --- ## 5. Packaging (no asar; mac = electron-builder, win = electron-forge) - **asar:** Disabled (`"asar": false` in `build`). All app code and `node_modules` are real files on disk (no `app.asar`), so the worklet and renderer always resolve modules from the filesystem. - **Why no asar:** Electron patches the Node `fs` module so any access to `*.asar` is routed through its ASAR reader. During OTA on macOS, `pear-runtime-updater` mirrors a partially written `app.asar` into the `next` directory; Electron’s patched `fs` then tries to treat that in‑progress file as a valid ASAR and throws `Error: Invalid package ...app.asar`. Turning asar off avoids this class of error and lets the updater see only plain files. ### 5.1 macOS (electron-builder) - **Tooling:** `electron-builder@23.6.0`. - **Build commands:** `npm run dist:mac` (local) and `npm run dist:mac:ci` (CI). - **Pipeline:** - `npm run build` → worklet bundle + `tsc` + renderer bundle (`dist/renderer.bundle.js`). - `npx electron-builder --mac` → `dist/mac-arm64/PearPass.app` + DMG. - CI uses `scripts/notarize.cjs` as an `afterSign` hook (`@electron/notarize` + `notarytool`) to sign and notarize the app. - After that, `PearPass.app` is copied into `out/darwin-arm64/` and `pear:build:darwin` produces the Pear drive layout (`by-arch/darwin-arm64/app/PearPass.app/...`). ### 5.2 Windows (electron-forge, MSIX) - **Tooling:** **Electron Forge** for Windows packaging (electron-builder does not support MSIX). - **Build:** Forge produces an MSIX package for the Windows desktop app; CI then stages that MSIX into the Pear drive for the `win32-x64` arch so `PearRuntime` on Windows can install it via `MSIXManager`. - **Pear layout:** The staged drive contains `by-arch/win32-x64/app/.msix`, where `` matches the `name` passed to `PearRuntime` in `electron/main.cjs`. --- ## 6. Preload (electron/preload.cjs) - **Attached to the renderer** via `webPreferences.preload` (with `nodeIntegration: true`, `contextIsolation: false`). - **Responsibilities:** 1. **App path for fs-native-extensions:** Sends `get-app-path` sync, then sets `global.__dirname` and `global.__filename` to the `fs-native-extensions` path so code that uses it (e.g. via pear-ipc) in the renderer does not break. 2. **Renderer API:** Exposes `window.electronAPI` with: - Runtime: `getConfig`, `applyUpdate`, `restart`, `checkUpdated`, `onRuntimeUpdating`, `onRuntimeUpdated` - Vault: `vaultInvoke(method, args)`, `vaultOnUpdate(cb)` - The renderer must use this preload; without it there is no `window.electronAPI` and no correct `__dirname`/`__filename` for fs-native-extensions. --- ## 7. Renderer → main → worklet flow 1. Renderer calls e.g. `window.electronAPI.vaultInvoke('someMethod', [arg1, arg2])`. 2. Preload forwards to `ipcRenderer.invoke('vault:invoke', { method, args })`. 3. Main process `ipcMain.handle('vault:invoke', …)` receives it, gets `vaultClient[method]`, deserializes args (e.g. `__base64` → Buffer), calls the method on `vaultClient`. 4. `PearpassVaultClient` sends the call over the pipe to the worklet; worklet runs the vault logic and replies. 5. Main process serializes the result (e.g. Buffer → `__base64`) and returns to the renderer. --- ## 8. Key files reference | Role | File | | ------------------------------ | -------------------------------------------------------------------------------- | | Main process | `electron/main.cjs` | | Preload | `electron/preload.cjs` | | Flatpak path helper | `electron/flatpak-paths.cjs` | | Worklet entry (ESM) | `node_modules/@tetherto/pearpass-lib-vault-core/src/worklet/app.js` | | Worklet bundle (CJS, packaged) | `node_modules/@tetherto/pearpass-lib-vault-core/src/worklet/app.cjs` (generated) | | Worklet build script | `scripts/build.worklet.mjs` | | Renderer bundle | `scripts/bundle-renderer.mjs` → `dist/renderer.bundle.js` | | Build pipeline | `package.json` scripts: `build`, `dist:*`, `pear:build:*` | --- ## 9. Troubleshooting - **“Cannot use import statement outside a module” in packaged app** Packaged app must run the CJS worklet (`app.cjs`). Ensure `npm run build` runs the worklet build and `getWorkletPath()` returns `.../app.cjs` when `app.isPackaged` is true. - **“MODULE_NOT_FOUND” for a package when running from DMG /Applications** With `asar: false` this usually means the package was not included in `build.files` or was only a devDependency. Ensure it is a runtime dependency and matched by `build.files`. - **“ADDON_NOT_FOUND” for a native addon** The worklet bundle must not inline that package. Ensure the module is in the `external` list in `scripts/build.worklet.mjs` (so it is loaded from `node_modules` at runtime) and that the native binary is present in the packaged app. - **Flatpak build starts but vault/worklet storage init fails** Ensure `electron/main.cjs` still routes both `app.getPath('userData')` and `pear-runtime-legacy-storage` results through `getSandboxSafePath()` from `electron/flatpak-paths.cjs`. Flatpak commonly exposes XDG directories under `~/.var/app/...`, which the worklet rejects unless they are remapped to the approved `~/.config/...` compatibility location. - **OTA update appears stuck on Windows** Confirm that the Pear drive for `by-arch/win32-x64/app/...` contains a valid `.msix` (if `PearRuntime` is using `MSIXManager.addPackage`) and that the filename matches the `name` passed to `PearRuntime` in `electron/main.cjs`. ================================================ FILE: e2e/.gitignore ================================================ test-artifacts/ node_modules/ playwright-report/ .DS_Store .env ================================================ FILE: e2e/components/CreateOrEditPage.js ================================================ import { test, expect } from '../fixtures/app.runner.js' class CreateOrEditPage { constructor(root) { this.root = root } // --- Input fields --- getCreateOrEditInputField(field) { const overrides = { website: 'createoredit-input-website-v2-0', attachment: 'createoredit-attachment-upload-v2', } const dashIndex = field.indexOf('-') const testId = overrides[field] ?? (dashIndex !== -1 ? `createoredit-${field.slice(0, dashIndex)}-input-${field.slice(dashIndex + 1)}-v2` : `createoredit-input-${field}-v2`) return this.root.getByTestId(testId).locator('input').first() } getCreateOrEditTextareaField(field) { return this.root.getByTestId(`createoredit-textarea-${field}`) } async fillCreateOrEditInput(field, value) { const input = this.getCreateOrEditInputField(field) await input.waitFor({ state: 'visible' }) await input.fill('') await input.fill(value) } async verifyPasswordToNotHaveValue(password) { const passwordInput = this.getCreateOrEditInputField('password') await expect(passwordInput).toBeVisible() await expect(passwordInput).not.toHaveValue(password) } // --- Form buttons --- getCreateOrEditButton(name) { const dashIndex = name.indexOf('-') const testId = dashIndex !== -1 ? `createoredit-${name.slice(0, dashIndex)}-button-${name.slice(dashIndex + 1)}-v2` : `createoredit-button-${name}-v2` return this.root.getByTestId(testId) } async clickOnCreateOrEditButton(button) { const input = this.getCreateOrEditButton(button) await input.waitFor({ state: 'visible' }) await input.click() } get saveButton() { return this.root.getByTestId('createoredit-button-save') } get elementItemCloseButton() { return this.root.getByTestId(/-close-v2$/).first() } async clickElementItemCloseButton() { await expect(this.elementItemCloseButton).toBeVisible() await this.elementItemCloseButton.click() } // --- Multi-slot website / comment --- get detailsWebsite() { return this.root.getByTestId('website-multi-slot-input-slot-0') } async verifyDetailsWebsiteCount(expectedCount) { await expect(this.detailsWebsite).toHaveCount(expectedCount) } get detailsComment() { return this.root.getByTestId('comments-multi-slot-input-slot-0') } async verifyDetailsCommentCount(expectedCount) { await expect(this.detailsComment).toHaveCount(expectedCount) } // --- Password generation --- get passwordMenu() { return this.root.getByTestId('createoredit-button-generatepassword-v2') } async openPasswordMenu() { await expect(this.passwordMenu).toBeVisible() await this.passwordMenu.click() } get insertPasswordButton() { return this.root .getByTestId('generatepassword-button-primary-v2') .first() } async clickInsertPasswordButton() { await expect(this.insertPasswordButton).toBeVisible() await this.insertPasswordButton.click() } // --- Password field --- get elementItemPassword() { return this.root.getByPlaceholder('Password') } get passwordInput() { return this.root.getByTestId('createoredit-input-password') } get elementItemPasswordShowHideFirst() { return this.root .getByTestId('password-field-eye-button') .first() } get elementItemPasswordShowHideLast() { return this.root.getByTestId('password-field-eye-button').last() } async clickShowHidePasswordButtonFirst() { await expect(this.elementItemPasswordShowHideFirst).toBeVisible() await this.elementItemPasswordShowHideFirst.click() } async clickShowHidePasswordButtonLast() { await expect(this.elementItemPasswordShowHideLast).toBeVisible() await this.elementItemPasswordShowHideLast.click() } async verifyPasswordType(password_type) { const itemDetail = this.root.getByPlaceholder('Password') await expect(itemDetail).toBeVisible() await expect(itemDetail).toHaveAttribute('type', password_type) } // --- Attachment upload --- getCreateOrEditUploadAttachment() { return this.root.getByTestId(/-attachment-upload-v2$/).first() } async clickOnAttachment() { const input = this.getCreateOrEditUploadAttachment() await input.waitFor({ state: 'visible' }) await input.click() } get deleteAttachmentButton() { return this.root.getByTestId(/-button-deleteattachment-v2-0$/).first() } async clickOnDeleteAttachmentButton() { const deleteButton = this.deleteAttachmentButton await expect(deleteButton).toBeVisible() await deleteButton.click() } get loadFile() { return this.root.getByTestId('createoredit-button-loadfile') } get fileInput() { return this.root.locator('input[type="file"]').first() } async uploadFile() { await this.fileInput.setInputFiles('test-files/TestPhoto.png') } get uploadedFileLink() { return this.root .getByTestId('uploadfiles-field-v2') .getByText('TestPhoto.png', { exact: true }) } get uploadedFile() { return this.root.getByTestId('uploadfiles-button-additem-v2') } get uploadedImage() { return this.root.getByAltText('TestPhoto.png') } async clickOnUploadedFile() { await expect(this.uploadedFile).toBeVisible() await this.uploadedFile.click() } async verifyUploadedFileIsVisible() { await expect(this.uploadedFileLink).toBeVisible() await expect(this.uploadedFileLink).toHaveText('TestPhoto.png') } async verifyUploadedImageIsVisible() { await expect(this.uploadedImage).toBeVisible() } // --- Custom note fields --- get createCustomNote() { return this.root.getByTestId('createcustomfield-option-note') } get customNoteInput() { return this.root.getByTestId('createoredit-custom-input-customfield-v2-0').locator('input').first() } get customNoteInput_first() { return this.root.getByTestId(/^createoredit-custom-input-customfield-v2-/) } async fillCustomNoteInput() { const input = this.customNoteInput await input.waitFor({ state: 'visible' }) await input.fill('') await input.fill('Custom Note') } async fillCustomNoteInput_first() { const input = this.customNoteInput_first await input.waitFor({ state: 'visible' }) await input.fill('') await input.fill('Custom Note') } async deleteCustomNote() { const input = this.customNoteInput await input.waitFor({ state: 'visible' }) await input.fill('') } // --- Folder dropdown --- get dropdownFolderMenu() { return this.root.getByTestId('createoredit-select-folder-v2') } async openDropdownMenu() { await this.dropdownFolderMenu.waitFor({ state: 'attached' }) await this.dropdownFolderMenu.click() } async selectFromDropdownMenu(foldername) { const folder = this.root.getByTestId(`createoredit-folder-option-v2-${foldername}`) await expect(folder).toBeVisible() await folder.click() } // --- Identity sections --- getSection(sectionname) { return this.root.getByTestId(`createoredit-section-${sectionname}`) } get identitySection() { return this.root.getByTestId(`createoredit-section-personalinfo`) } async clickOnIdentitySection(sectionname) { const section = this.getSection(sectionname) await section.waitFor({ state: 'visible' }) await section.click() } // --- PassPhrase --- get passPhrasePasteButton() { return this.root.getByRole('button', { name: 'Paste recovery phrase' }).first() } async clickOnPasteFromClipboard() { const pasteButton = this.passPhrasePasteButton await expect(pasteButton).toBeVisible() await pasteButton.click() } // --- Item details (shared verifications) --- async verifyItemDetailsValue(labelOrPlaceholder, expectedValue) { const itemDetail = this.getElementItemDetails(labelOrPlaceholder) await expect(itemDetail).toHaveValue(expectedValue) } async verifyItemDetailsValueIsNotVisible(labelOrPlaceholder) { const itemDetail = this.getElementItemDetails(labelOrPlaceholder) await expect(itemDetail).not.toBeVisible() } } module.exports = { CreateOrEditPage } ================================================ FILE: e2e/components/DetailsPage.js ================================================ import { test, expect } from '../fixtures/app.runner.js' class DetailsPage { constructor(root) { this.root = root } // --- General / counter --- get itemDetailsCounter() { return this.root .getByTestId('details-header-v2') .locator('input[placeholder]') } async verifyDetailsNoItems() { await expect(this.itemDetailsCounter).toHaveCount(0) } // --- Title --- get getItemDetailsTitle() { return this.root.locator('[data-testid^="details-title"], [data-testid="details-header-v2"]') } async verifyTitle(expectedTitle) { await expect(this.getItemDetailsTitle).toContainText(expectedTitle) } // --- Item details / multi-slot --- getElementItemDetails(labelOrPlaceholder) { const v2LabelMap = { 'Email or username': 'credentials-multi-slot-input-slot-0', 'Password': 'credentials-multi-slot-input-slot-1', 'https://': 'website-multi-slot-input-slot-0', 'Name on card': 'card-details-multi-slot-input-slot-0', 'Number on card': 'card-details-multi-slot-input-slot-1', 'Date of expire': 'card-details-multi-slot-input-slot-2', 'Security code': 'card-details-multi-slot-input-slot-3', 'Pin code': 'card-details-multi-slot-input-slot-4', 'Comment': 'comments-multi-slot-input-slot-0', 'Wi-Fi Password': 'credentials-multi-slot-input-slot-0', 'Add comment': 'comments-multi-slot-input-slot-0', 'Other Field': 'custom-fields-multi-slot-input-slot-0', } const v2TestId = v2LabelMap[labelOrPlaceholder] if (v2TestId) { return this.root.getByTestId(v2TestId).locator('input').first() } return this.root .locator('input', { has: this.root.locator('[data-testid="details-header"]', { hasText: labelOrPlaceholder }) }) .or(this.root.locator(`input[placeholder="${labelOrPlaceholder}"]`)) } async verifyItemDetailsValue(labelOrPlaceholder, expectedValue) { const itemDetail = this.getElementItemDetails(labelOrPlaceholder) await expect(itemDetail).toHaveValue(expectedValue) } async verifyItemDetailsValueIsNotVisible(labelOrPlaceholder) { const itemDetail = this.getElementItemDetails(labelOrPlaceholder) await expect(itemDetail).not.toBeVisible() } // --- Comment / note --- get getItemDetailsCommentInput() { return this.root.getByTestId('comments-multi-slot-input-slot-0').locator('input') } async verifyCustomNoteText(expectedText) { await expect(this.getItemDetailsCommentInput).toBeVisible() await expect(this.getItemDetailsCommentInput).toHaveValue(expectedText) } getElementItemDetailsNew() { return this.root.getByTestId('note-multi-slot-input-slot-0').locator('input').first() } async verifyNoteText(note_text) { const noteTextDetail = this.getElementItemDetailsNew() await expect(noteTextDetail).toBeVisible() await expect(noteTextDetail).toHaveValue(note_text) } // --- Identity --- getIdentityDetails(name) { const v2SlotMap = { fullname: 'personal-information-multi-slot-input-slot-0', email: 'personal-information-multi-slot-input-slot-1', phone: 'personal-information-multi-slot-input-slot-2', address: 'address-multi-slot-input-slot-0', zip: 'address-multi-slot-input-slot-1', city: 'address-multi-slot-input-slot-2', region: 'address-multi-slot-input-slot-3', country: 'address-multi-slot-input-slot-4', passportfullname: 'passport-multi-slot-input-slot-0', passportnumber: 'passport-multi-slot-input-slot-1', passportissuingcountry: 'passport-multi-slot-input-slot-2', passportdateofissue: 'passport-multi-slot-input-slot-3', passportexpirydate: 'passport-multi-slot-input-slot-4', passportnationality: 'passport-multi-slot-input-slot-5', passportdob: 'passport-multi-slot-input-slot-6', passportgender: 'passport-multi-slot-input-slot-7', idcardnumber: 'identity-card-multi-slot-input-slot-0', idcarddateofissue: 'identity-card-multi-slot-input-slot-1', idcardexpirydate: 'identity-card-multi-slot-input-slot-2', idcardissuingcountry: 'identity-card-multi-slot-input-slot-3', comment: 'comments-multi-slot-input-slot-0', note: 'comments-multi-slot-input-slot-0', } const v2TestId = v2SlotMap[name] if (v2TestId) { return this.root.getByTestId(v2TestId).locator('input').first() } return this.root.getByTestId(`identitydetails-field-${name}`) } async verifyIdentityDetails(name) { const identityDetail = this.getIdentityDetails(name) await expect(identityDetail).toBeVisible() } async verifyIdentityDetailsValue(name, expectedValue) { const identityDetail = this.getIdentityDetails(name) await expect(identityDetail).toHaveValue(expectedValue) } // --- Recovery phrase --- get recoveryPhraseDetails() { return this.root.getByTestId(/passphrase-word-input-\d+/) } async verifyAllRecoveryPhraseWords(expectedWords) { const slots = this.recoveryPhraseDetails const count = await slots.count() for (let i = 0; i < count; i++) { const input = slots.nth(i).locator('input').first() await expect(input).toHaveValue(expectedWords[i]) } } // --- Attachment / file --- get elementItemFileLink() { return this.root .getByTestId('attachment-field-0') .getByText('TestPhoto.png', { exact: true }) } get uploadedImage() { return this.root.getByAltText('TestPhoto.png') } async clickOnUploadedFile() { await expect(this.elementItemFileLink).toBeVisible() await this.elementItemFileLink.click() } async verifyUploadedFileIsVisible() { await expect(this.elementItemFileLink).toBeVisible() } async verifyUploadedImageIsVisible() { await expect(this.uploadedImage).toBeVisible() } // --- Actions bar --- get detailsBarActionsButton() { return this.root.getByTestId('details-button-actions-v2') } get detailsBarEditButton() { return this.root.getByTestId('details-actions-item-edit-v2') } get detailsBarFavoriteButton() { return this.root.getByTestId('details-actions-item-favorite-v2') } get detailsBarThreeDots() { return this.root.getByTestId('button-round-icon').first() } async openItemBarThreeDotsDropdownMenu() { await expect(this.detailsBarActionsButton).toBeVisible() await this.detailsBarActionsButton.click() } async editElement() { await expect(this.detailsBarActionsButton).toBeVisible() await this.detailsBarActionsButton.click() await expect(this.detailsBarEditButton).toBeVisible() await this.detailsBarEditButton.click() } get markAsFavoriteButton() { return this.root.locator('[data-testid="details-actions-item-favorite-v2"]').getByText('Add to Favorites', { exact: true }) } get removeFromFavoritesButton() { return this.root.locator('[data-testid="details-actions-item-favorite-v2"]').getByText('Remove from Favorites', { exact: true }) } async clickMarkAsFavoriteButton() { await expect(this.detailsBarFavoriteButton).toBeVisible() await this.detailsBarFavoriteButton.click() } async clickRemoveFromFavoritesButton() { await expect(this.removeFromFavoritesButton).toBeVisible() await this.removeFromFavoritesButton.click() } async clickFavoriteButton() { await expect(this.detailsBarActionsButton).toBeVisible() await this.detailsBarActionsButton.click() await expect(this.detailsBarFavoriteButton).toBeVisible() await this.detailsBarFavoriteButton.click() } // --- Close button --- get elementItemCloseButton() { return this.root.getByTestId(/-close-v2$/).first() } async clickElementItemCloseButton() { await expect(this.elementItemCloseButton).toBeVisible() await this.elementItemCloseButton.click() } // --- Folder management --- getCreateNewFolderTitleInput() { return this.root.locator( 'input[placeholder="Enter Name"]' ) } get createFolderButton() { return this.root.getByRole('button', { name: 'Create New Folder' }); } async fillCreateNewFolderTitleInput(value) { await this.getCreateNewFolderTitleInput().fill(value) } async clickCreateFolderButton() { const saveBtn = this.root.getByTestId('createfolder-save-v2') await expect(saveBtn).toBeVisible() await saveBtn.click() } getItemDetailsFolderName(foldername) { return this.root.getByTestId(`sidebar-folder-${foldername}`) } async verifyItemDetailsFolderName(foldername) { const itemDetailsFolder = this.getItemDetailsFolderName(foldername) await expect(itemDetailsFolder).toBeVisible() } // --- Record list / favorites --- get recordListContainer() { return this.root.getByTestId('recordList-record-container') } // --- Password visibility --- async clickShowHidePasswordButton() { await expect(this.elementItemPasswordShowHide).toBeVisible() await this.elementItemPasswordShowHide.click() } async clickPasswordToggle(slotTestId) { const toggle = this.root.getByTestId(slotTestId).getByTestId('password-field-eye-button') await expect(toggle).toBeVisible() await toggle.click() } async verifyPasswordFieldType(slotTestId, expectedType) { const input = this.root.getByTestId(slotTestId).locator('input').first() await expect(input).toBeVisible() await expect(input).toHaveAttribute('type', expectedType) } } module.exports = { DetailsPage } ================================================ FILE: e2e/components/LoginPage.js ================================================ import { test, expect } from '../fixtures/app.runner.js' class LoginPage { constructor(root) { this.root = root } // --- Title / ready state --- get title() { return this.root.locator('h1', { hasText: 'Enter Your Master Password' }) } async waitForReady(timeout = 30000) { await expect(this.title).toBeVisible({ timeout }) } // --- Password --- get passwordInput() { return this.root.getByTestId('login-password-input-v2').locator('input') } async enterPassword(password) { await expect(this.passwordInput).toBeVisible() await this.passwordInput.fill(password) } // --- Continue button --- get continueButton() { return this.root.getByTestId('login-continue-button-v2') } async clickContinue() { await this.continueButton.click() } async loginToApplication(password) { await this.waitForReady() await this.enterPassword(password) await this.clickContinue() } } module.exports = { LoginPage } ================================================ FILE: e2e/components/MainPage.js ================================================ import { test, expect } from '../fixtures/app.runner.js' class MainPage { constructor(root) { this.root = root } // --- Element list / records --- get element() { return this.root.locator('[data-record-id]').first() } getElementByPosition(position) { return this.root .locator('[data-record-id]') .nth(position) .locator('span') .last() } async clickOnFirstElement() { await expect(this.element).toBeVisible() await this.element.click() } async openElementDetails() { await expect(this.element).toBeVisible() await this.element.click() } async verifyElementTitle(title) { const row = this.root.locator('[data-record-id]').filter({ hasText: title }) await expect(row).toBeVisible() } async verifyElementIsNotVisible() { await expect(this.element).not.toBeVisible() } async verifyElementByPosition(position, element_name) { await expect(this.getElementByPosition(position)).toHaveText(element_name) } async clickElementByPosition(position, element_name) { const element = this.getElementByPosition(position) await expect(element).toContainText(element_name) await element.click() } async elementCheckBox(expectedState) { const checkbox = this.element .locator('button[aria-checked]') await expect(checkbox) .toHaveAttribute('aria-checked', String(expectedState)) } // --- Favorites --- get mainViewFavoriteIcon() { return this.root.getByTestId('multi-select-favorite') } async clickOnMainViewFavoriteIcon() { await expect(this.mainViewFavoriteIcon).toBeVisible() await this.mainViewFavoriteIcon.click() } async favoriteIconDisabled() { const favorite1 = this.mainViewFavoriteIcon await expect(favorite1).toHaveAttribute('aria-label', 'Add to Favorites') } async favoriteIconEnabled() { const favorite2 = this.mainViewFavoriteIcon await expect(favorite2).toHaveAttribute('aria-label', 'Remove from Favorites') } // --- Multi-select --- get mainViewHeaderSelect() { return this.root.getByTestId('main-view-header-select') } get multipleSelectionButon() { return this.root.getByTestId('main-view-header-select') } get multipleSelectDeleteButon() { return this.root.getByTestId('multi-select-delete') } get multipleSelectMoveButon() { return this.root.getByTestId('multi-select-move') } get multipleSelectCancelButon() { return this.root.getByTestId('multi-select-cancel-button') } get multipleSelectCheckerByPosition() { return this.root .getByTestId('recordList-record-container') .nth(`${position}`) .getByTestId('undefined-selected') } async clickMainViewHeaderSelect() { await expect(this.mainViewHeaderSelect).toBeVisible() await this.mainViewHeaderSelect.click() } async clickMultipleSelectiontButton() { await expect(this.multipleSelectionButon).toBeVisible() await this.multipleSelectionButon.click() } async clickMultipleSelectDeletetButton() { await expect(this.multipleSelectDeleteButon).toBeVisible() await this.multipleSelectDeleteButon.click() } async clickMultipleSelectMoveButon() { await expect(this.multipleSelectMoveButon).toBeVisible() await this.multipleSelectMoveButon.click() } async verifyMultipleSelectDeleteButtonIsEnabled() { await expect(this.multipleSelectDeleteButon).toBeVisible() await expect(this.multipleSelectDeleteButon).toBeEnabled() } // --- Add item / plus button --- get mainPlusButon() { return this.root.getByTestId('main-plus-button') } async clickAddItem(type) { await expect(this.mainPlusButon).toBeVisible() await this.mainPlusButon.click() const menuItem = this.root.getByTestId(`add-item-${type}`) await expect(menuItem).toBeVisible() await menuItem.click() } // --- Sort --- get sortButon() { return this.root.getByTestId('main-view-header-sort-menu') } getSortOption(option) { return this.root.getByTestId(`main-view-header-sort-${option}`) } async clickSortButton() { await expect(this.sortButon).toBeVisible() await this.sortButon.click() } async selectSortOption(option) { const sortOption = this.getSortOption(option) await sortOption.click() } // --- Folder management --- get createNewFolderinputFolderName() { return this.root.getByTestId('input-field') } get createFolderModalButton() { return this.root.getByRole('button', { name: 'Create folder' }) } getCollectionButton(button_name) { return this.root.getByTestId(`emptycollection-button-create-${button_name}`) } async verifyElementFolderName(elementfoldername) { const folderBtn = this.root.getByTestId(`sidebar-folder-${elementfoldername}`) await expect(folderBtn).toBeVisible() await folderBtn.click() await expect(this.root.locator('[data-record-id]').first()).toBeVisible() } // --- Move folder --- async clickMoveFolderChip(folderName) { const chip = this.root.getByTestId(`movefolder-chip-${folderName}`) await expect(chip).toBeVisible() await chip.click() } async clickMoveFolderSubmit() { const submitBtn = this.root.getByTestId('movefolder-submit-v2') await expect(submitBtn).toBeVisible() await expect(submitBtn).toBeEnabled() await submitBtn.click() } // --- Empty collection --- get emptyCollectionView() { return this.root.getByTestId('empty-collection-v2') } async verifyEmptyCollection() { await expect(this.emptyCollectionView).toBeVisible() } // --- Details close --- get detailsCloseButton() { return this.root.getByTestId('details-button-collapse') } async clickDetailsCloseButton() { const collapseBtn = this.root.getByTestId('details-button-collapse') const modalCloseBtn = this.root .getByTestId('modalheader-button-close') .last() const closeBtn = collapseBtn.or(modalCloseBtn) await closeBtn.click({ timeout: 5000 }).catch(() => { }) } // --- Confirm / delete --- async clickYesButton() { await this.root.getByTestId('delete-records-submit-v2').click() } } module.exports = { MainPage } ================================================ FILE: e2e/components/SettingsPage.js ================================================ import { test, expect } from '../fixtures/app.runner.js' class SettingsPage { constructor(root) { this.root = root } // --- Navigation --- getSettingsDropdownSection(section_name) { return this.root.getByTestId(`section-${section_name}`) } async verifySettingsDropdownSectionIsVisible(section_name) { const section_dropdown = this.getSettingsDropdownSection(section_name) await expect(section_dropdown).toBeVisible() } getSettingsDropdownNavigation(section_navigation_name) { return this.root.getByTestId(`settings-nav-${section_navigation_name}`) } async verifySettingsDropdownNavigationIsVisible(section_navigation_name) { const section_navigation = this.getSettingsDropdownNavigation(section_navigation_name) await expect(section_navigation).toBeVisible() } get backSettingsButton() { return this.root.getByRole('button', { name: 'Go back' }); } async clickBackSettingsButton() { await this.backSettingsButton.waitFor({ state: 'visible' }); await this.backSettingsButton.click(); } // --- Dropdowns --- getPearPassFunctionDropdown(pearpass_dropdown_id) { return this.root.getByTestId(`settings-${pearpass_dropdown_id}`) } async clickPearPassFunctionDropdown(dropdown_id) { const function_dropdown = this.getPearPassFunctionDropdown(dropdown_id) await expect(function_dropdown).toBeVisible() await function_dropdown.click() } getPearPassFunctionDropdownOption(dropdown_option) { return this.root.getByTestId(`settings-auto-lock-option-${dropdown_option}`) } async verifyPearPassFunctionDropdownOptionIsVisible(dropdown_id) { const function_dropdown = this.getPearPassFunctionDropdownOption(dropdown_id) await expect(function_dropdown).toBeVisible() } } module.exports = { SettingsPage } ================================================ FILE: e2e/components/SideMenuPage.js ================================================ import { test, expect } from '../fixtures/app.runner.js' class SideMenuPage { constructor(root) { this.root = root } // --- Exit / lock --- get sidebarExitButton() { return this.root.getByTestId('sidebar-lock-app') } async clickSidebarExitButton() { await expect(this.sidebarExitButton).toBeVisible() await this.sidebarExitButton.click() } // --- Settings --- get sidebarSettingsButton() { return this.root.getByTestId('sidebar-settings-button') } async clickSidebarSettingsButton() { await expect(this.sidebarSettingsButton).toBeVisible() await this.sidebarSettingsButton.click() } // --- Categories --- getSidebarCategory(categoryname) { return this.root.getByTestId(`sidebar-category-${categoryname}`) } async selectSideBarCategory(name) { const category = this.getSidebarCategory(name) await expect(category).toBeVisible() await expect(category).toBeEnabled() await category.click() } // --- Favorites folder --- get favoritesFolder() { return this.root.getByTestId('sidebar-folder-favorites').locator('span').last() } async verifySideBarFavoritesFolder(items) { await expect(this.favoritesFolder).toBeVisible() await expect(this.favoritesFolder).toHaveAttribute('aria-label', items) } // --- Folders --- getSideMenuFolder(folderName) { return this.root.getByRole('button', { name: folderName }) .and(this.root.locator('[data-testid^="sidebar-"]')) } get sidebarAddButton() { return this.root.getByTestId('sidebar-folder-add') } get confirmButton() { return this.root.getByTestId('button-primary') } get deleteFolderButton() { return this.root.getByTestId('deletefolder-submit-v2') } async clickSidebarAddButton() { await expect(this.sidebarAddButton).toBeVisible() await this.sidebarAddButton.click() } async createFolder(name) { await this.clickSidebarAddButton() const nameInput = this.root.getByTestId('createfolder-name-v2').locator('input') await expect(nameInput).toBeVisible() await nameInput.fill(name) const saveBtn = this.root.getByTestId('createfolder-save-v2') await expect(saveBtn).toBeVisible() await saveBtn.click() await expect(saveBtn).toBeHidden() } async openSideBarFolder(foldername) { await expect(this.getSideMenuFolder(foldername)).toBeVisible() await this.getSideMenuFolder(foldername).click() } async deleteMultipleItemsFolder(foldername) { const folder = this.getSideMenuFolder(foldername) await expect(folder).toBeVisible() await folder.click({ button: 'right' }) const deleteButton = this.root.getByText('Delete Folder', { exact: true }) await expect(deleteButton).toBeVisible() await deleteButton.click() } async deleteFolder(foldername) { const folder = this.getSideMenuFolder(foldername) await expect(folder).toBeVisible() await folder.click({ button: 'right' }) const deleteButton = this.root.getByText('Delete Folder', { exact: true }) await expect(deleteButton).toBeVisible() await deleteButton.click() await expect(deleteButton).toBeHidden() } async clickDeleteFolderButton() { await expect(this.deleteFolderButton).toBeVisible() await this.deleteFolderButton.click() } async verifySidebarFolderName(foldername) { const folder = this.getSideMenuFolder(foldername) await expect(folder).toBeVisible() } } module.exports = { SideMenuPage } ================================================ FILE: e2e/components/Utilities.js ================================================ import { test, expect } from '../fixtures/app.runner.js' class Utilities { constructor(root) { this.root = root } // --- Bulk delete --- async deleteAllElements() { const emptyState = this.root.getByTestId('empty-collection-v2') while (true) { const emptyVisible = await emptyState.isVisible().catch(() => false) if (emptyVisible) break const firstRow = this.root.locator('[data-record-id]').first() const rowVisible = await firstRow .isVisible({ timeout: 3000 }) .catch(() => false) if (!rowVisible) break const recordId = await firstRow.getAttribute('data-record-id') if (!recordId) break await firstRow.click({ button: 'right' }) const deleteButton = this.root.getByTestId( `record-row-menu-delete-${recordId}` ) await expect(deleteButton).toBeVisible({ timeout: 5000 }) await deleteButton.click() const confirmButton = this.root.getByTestId('delete-records-submit-v2') await expect(confirmButton).toBeVisible({ timeout: 5000 }) await confirmButton.click() await confirmButton .waitFor({ state: 'hidden', timeout: 5000 }) .catch(() => { }) } } // --- Clipboard --- async pasteFromClipboard(locator, text) { await this.root.page().evaluate(async (t) => { await navigator.clipboard.writeText(t) }, text) await locator.click() const modifier = process.platform === 'darwin' ? 'Meta' : 'Control' await this.root.page().keyboard.press(`${modifier}+v`) } } module.exports = { Utilities } ================================================ FILE: e2e/components/index.js ================================================ export { LoginPage } from './LoginPage.js' export { CreateOrEditPage } from './CreateOrEditPage.js' export { DetailsPage } from './DetailsPage.js' export { MainPage } from './MainPage.js' export { SideMenuPage } from './SideMenuPage.js' export { Utilities } from './Utilities.js' export { SettingsPage } from './SettingsPage.js' ================================================ FILE: e2e/fixtures/app.runner.js ================================================ import { spawn } from 'node:child_process' import { createRequire } from 'node:module' import os from 'node:os' import path from 'node:path' import { test as base, expect, chromium } from '@playwright/test' const isWindows = os.platform() === 'win32' /** Real Electron binary path (avoids Windows spawn EINVAL from .cmd without shell). */ function resolveElectronBinary(appDir) { const require = createRequire(path.join(appDir, 'package.json')) return require('electron') } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } async function connectWithRetries(wsEndpoint, maxRetries) { // Windows needs more retries with shorter delays // Mac works better with exponential backoff const retries = maxRetries ?? (isWindows ? 15 : 10) for (let attempt = 0; attempt <= retries; attempt++) { try { // Sleep AFTER first attempt fails, not before if (attempt > 0) { const delay = isWindows ? 1000 : 2 ** attempt * 1000 await sleep(delay) } console.log( `[CDP] Attempting connection to ${wsEndpoint} (attempt ${attempt + 1}/${retries + 1})` ) return await chromium.connectOverCDP(wsEndpoint) } catch (err) { console.log(`[CDP] Connection failed: ${err.message}`) if (attempt === retries) throw err } } } async function waitForPage(browser, maxRetries = 60) { for (let attempt = 0; attempt <= maxRetries; attempt++) { const contexts = browser.contexts() for (const context of contexts) { const pages = context.pages() // Debug: log all page URLs if (attempt % 5 === 0) { console.log( `[Attempt ${attempt}] Available pages:`, pages.map((p) => p.url()) ) } // Electron loads index.html via file:// on all platforms const page = pages.find((p) => p.url().includes('index.html')) if (page) { console.log('[Found] App page:', page.url()) return page } } await sleep(1000) } // Last resort: return any page that's not blank const contexts = browser.contexts() for (const context of contexts) { const page = context.pages().find((p) => !p.url().startsWith('about:')) if (page) { console.log('[Fallback] Using page:', page.url()) return page } } return null } async function launchApp(appDir) { const port = Math.floor(Math.random() * (65535 - 10000 + 1)) + 10000 console.log( `[Launch] Starting app on port ${port}, platform: ${os.platform()}` ) const electronBin = resolveElectronBinary(appDir) // ELECTRON_RUN_AS_NODE=1 makes Electron behave as plain Node.js, which // prevents require('electron') from returning the built-in API (app, BrowserWindow, etc.) const env = { ...process.env } delete env.ELECTRON_RUN_AS_NODE let proc if (isWindows) { proc = spawn( electronBin, ['.', `--remote-debugging-port=${port}`, '--no-sandbox'], { cwd: appDir, stdio: 'inherit', windowsHide: false, env } ) } else { // Mac/Linux: use detached process so we can kill the process group on teardown proc = spawn( electronBin, ['.', `--remote-debugging-port=${port}`, '--no-sandbox'], { cwd: appDir, stdio: 'inherit', detached: true, env } ) proc.unref() } // Give the app time to start before trying to connect // Electron needs time for startRuntime() (P2P + worklet) before the window is created const initialDelay = isWindows ? 5000 : 5000 console.log(`[Launch] Waiting ${initialDelay}ms for app to initialize...`) await sleep(initialDelay) const browser = await connectWithRetries(`http://localhost:${port}`) // Listen for new pages on all contexts for (const context of browser.contexts()) { context.on('page', (p) => console.log('[Event] New page created:', p.url())) } const page = await waitForPage(browser) if (!page) { // Final debug output const allPages = browser .contexts() .flatMap((c) => c.pages().map((p) => p.url())) console.error('[Debug] All available page URLs:', allPages) throw new Error('Could not find app page') } // Wait for page to be fully loaded on all platforms console.log('[Launch] Waiting for page to be ready...') await page.waitForLoadState('domcontentloaded') // Windows needs additional settling time if (isWindows) { await sleep(2000) } const app = { proc, browser, page, isWindows } /** * Returns the current app page. If the page was closed (e.g. app restarted), * tries to find the new page from the browser. Use this in beforeEach to * avoid "Target page, context or browser has been closed" errors. */ app.getPage = async function getPage() { if (this.page && !this.page.isClosed()) { return this.page } const newPage = await waitForPage(this.browser, 10) if (newPage) { this.page = newPage await this.page.waitForLoadState('domcontentloaded') if (isWindows) await sleep(500) return this.page } throw new Error('App page was closed and no new page could be found') } return app } import { spawnSync } from 'node:child_process' export async function teardown({ proc, browser, isWindows }) { try { if (proc?.pid) { console.log(`[Teardown] Killing Electron process PID=${proc.pid} ...`) if (isWindows) { // Windows: koristi taskkill spawnSync('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { stdio: 'inherit' }) } else { // Mac/Linux: ubij proces grupu (-pid) process.kill(-proc.pid, 'SIGKILL') } } } catch (e) { console.warn( 'Electron process already terminated or could not be killed', e.message ) } // Mali delay da OS oslobodi port i prozore await new Promise((r) => setTimeout(r, 500)) // Close CDP browser connection try { if (browser) { console.log('[Teardown] Closing CDP browser connection...') await browser.close() } } catch (e) { console.warn('Browser already closed', e.message) } } exports.test = base.extend({ app: [ async ({}, use) => { const appDir = path.resolve(__dirname, '..', '..') const app = await launchApp(appDir) await use(app) await teardown(app) }, { scope: 'worker' } ] }) exports.expect = expect ================================================ FILE: e2e/fixtures/test-data.js ================================================ 'use strict' /** * Centralized test data for reuse across test suites */ module.exports = { // User credentials credentials: { validPassword: 'Test123!', invalidPassword: 'WrongPassword123!' }, // Vault data vault: { name: 'Test' }, // Timeouts timeouts: { navigation: 3000, action: 3000 }, // PassPhrase passphrase: { text12: 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12', text24: 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12 word13 word14 word15 word16 word17 word18 word19 word20 word21 word22 word23 word24' } } ================================================ FILE: e2e/package.json ================================================ { "name": "pearpass-e2e", "version": "1.0.0", "scripts": { "test": "npx playwright test", "test:headed": "npx playwright test --headed", "test:debug": "npx playwright test --debug", "report": "npx playwright show-report test-artifacts/report" }, "dependencies": { "@playwright/test": "^1.52.0", "dotenv": "^17.3.1" }, "devDependencies": { "clipboardy": "^5.1.0", "playwright-qase-reporter": "^2.2.2" } } ================================================ FILE: e2e/playwright.config.js ================================================ import { defineConfig } from '@playwright/test' import dotenv from 'dotenv' dotenv.config() export default defineConfig({ timeout: 5 * 60 * 1000, testDir: './specs', workers: 1, forbidOnly: !!process.env.CI, maxFailures: process.env.CI ? 5 : undefined, fullyParallel: false, retries: 0, use: { screenshot: 'only-on-failure', trace: 'on-first-retry', actionTimeout: 30000, navigationTimeout: 60000 }, reporter: [ ['list'], ['html', { outputFolder: 'test-artifacts/report', open: 'never' }], [ 'playwright-qase-reporter', { mode: 'testops', debug: false, testops: { api: { token: process.env.API_TOKEN }, project: 'PAS', uploadAttachments: true, run: { title: 'Automated Playwright Run', description: 'Nightly regression tests', complete: true }, batch: { size: 100 } }, framework: { browser: { addAsParameter: true, parameterName: 'Browser' }, markAsFlaky: true } } ] ], outputDir: 'test-artifacts/results' }) ================================================ FILE: e2e/scripts/explore.js ================================================ 'use strict' const { chromium } = require('@playwright/test') async function explore() { const browser = await chromium.connectOverCDP('http://localhost:9222') const page = browser .contexts()[0] .pages() .find((p) => p.url().includes('index.html')) console.log('Connected to:', page.url()) await page.waitForTimeout(2000) console.log('\n=== VISIBLE TEXT ===') console.log(await page.locator('body').innerText()) console.log('\n=== BUTTONS ===') const buttons = await page.locator('button').all() for (const btn of buttons) { if (await btn.isVisible()) { console.log('-', await btn.textContent()) } } console.log('\n=== INPUTS ===') const inputs = await page.locator('input').all() for (const input of inputs) { if (await input.isVisible()) { const type = await input.getAttribute('type') const placeholder = await input.getAttribute('placeholder') console.log(`- type="${type}" placeholder="${placeholder}"`) } } } explore().catch(console.error) ================================================ FILE: e2e/specs/01-Login/creatingLoginItem.test.js ================================================ import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Creating Login Item', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) sideMenuPage = new SideMenuPage(root) utilities = new Utilities(root) mainPage = new MainPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('login') await utilities.deleteAllElements() try { await sideMenuPage.deleteFolder('Test Folder') } catch (e) { // folder may not exist from a previous run } await mainPage.clickAddItem('login') await page.waitForTimeout(testData.timeouts.action) }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async () => { await utilities.deleteAllElements() try { await sideMenuPage.deleteFolder('Test Folder') } catch (e) { } await sideMenuPage.clickSidebarExitButton() }) test('Creating the "Login" item', async () => { qase.id(1928) await createOrEditPage.fillCreateOrEditInput('title', 'Login Title') await createOrEditPage.fillCreateOrEditInput('username', 'Test User') await createOrEditPage.fillCreateOrEditInput('password', 'Test Pass') await createOrEditPage.fillCreateOrEditInput('website', 'https://www.website.co') await createOrEditPage.fillCreateOrEditInput('comment', 'Test Note') await createOrEditPage.clickOnCreateOrEditButton('save') await page.waitForTimeout(testData.timeouts.action) }) test('Viewing created item. Verify item details', async () => { qase.id(1929) await mainPage.verifyElementTitle('Login Title') await mainPage.openElementDetails() await detailsPage.verifyItemDetailsValue('Email or username', 'Test User') await detailsPage.verifyItemDetailsValue('Password', 'Test Pass') await detailsPage.verifyItemDetailsValue('https://', 'https://www.website.co') await detailsPage.verifyCustomNoteText('Test Note') }) test('Password visibility icon displays/hides value', async () => { qase.id(1930) await mainPage.verifyElementTitle('Login Title') await mainPage.openElementDetails() await detailsPage.verifyPasswordFieldType('credentials-multi-slot-input-slot-1', 'password') await detailsPage.clickPasswordToggle('credentials-multi-slot-input-slot-1') await detailsPage.verifyPasswordFieldType('credentials-multi-slot-input-slot-1', 'text') }) test('Dropdown moves to selected item edit screen', async () => { qase.id(1931) await mainPage.verifyElementTitle('Login Title') await sideMenuPage.clickSidebarAddButton() await detailsPage.fillCreateNewFolderTitleInput('Test Folder') await detailsPage.clickCreateFolderButton() await detailsPage.editElement() await createOrEditPage.openDropdownMenu() await createOrEditPage.selectFromDropdownMenu('Test Folder') await createOrEditPage.clickOnCreateOrEditButton('save') await expect(detailsPage.getItemDetailsFolderName('Test Folder')).toBeVisible() await mainPage.verifyElementFolderName('Test Folder') }) test('Item moved to folder (and cleanup)', async ({ page }) => { qase.id(1932) await sideMenuPage.verifySidebarFolderName('Test Folder') await mainPage.openElementDetails() await detailsPage.editElement() await createOrEditPage.openDropdownMenu() await createOrEditPage.selectFromDropdownMenu('Test Folder') await createOrEditPage.clickOnCreateOrEditButton('save') await sideMenuPage.deleteFolder('Test Folder') }) test('Add via Favorite icon', async ({ page }) => { qase.id(1933) await sideMenuPage.selectSideBarCategory('all') await mainPage.clickMainViewHeaderSelect() await mainPage.elementCheckBox(false) await mainPage.clickOnFirstElement() await mainPage.elementCheckBox(true) await mainPage.clickOnMainViewFavoriteIcon() await sideMenuPage.verifySideBarFavoritesFolder('1 items') }) test('Remove via Favorite icon', async ({ page }) => { qase.id(1934) await mainPage.clickMainViewHeaderSelect() await mainPage.clickOnFirstElement() await mainPage.clickOnMainViewFavoriteIcon() await sideMenuPage.verifySideBarFavoritesFolder('0 items') }) test('Add via More options', async ({ page }) => { qase.id(1935) await mainPage.openElementDetails() await detailsPage.openItemBarThreeDotsDropdownMenu() await detailsPage.clickMarkAsFavoriteButton() await sideMenuPage.verifySideBarFavoritesFolder('1 items') }) test('Remove via More options', async ({ page }) => { qase.id(1936) await detailsPage.openItemBarThreeDotsDropdownMenu() await detailsPage.clickRemoveFromFavoritesButton() await sideMenuPage.verifySideBarFavoritesFolder('0 items') }) // test('Add Custom Note', async ({ page }) => { // qase.id(1937) // await mainPage.verifyElementTitle('Login Title') // await mainPage.openElementDetails() // await detailsPage.editElement() // await createOrEditPage.clickCreateCustomItem() // await createOrEditPage.clickCustomItemOptionNote() // await expect(createOrEditPage.customNoteInput).toHaveCount(1) // await createOrEditPage.fillCustomNoteInput() // await createOrEditPage.clickOnCreateOrEditButton('save') // await page.waitForTimeout(testData.timeouts.action) // await mainPage.clickDetailsCloseButton() // }) // test('Delete Note field', async ({ page }) => { // qase.id(1938) // await mainPage.verifyElementTitle('Login Title') // await mainPage.openElementDetails() // await detailsPage.editElement() // await expect(createOrEditPage.customNoteInput_first).toHaveCount(2) // await createOrEditPage.deleteCustomNote() // await expect(createOrEditPage.customNoteInput_first).toHaveCount(1) // await createOrEditPage.clickOnCreateOrEditButton('save') // await page.waitForTimeout(testData.timeouts.action) // await mainPage.clickDetailsCloseButton() // }) test('Close via Cross icon', async ({ page }) => { qase.id(1939) await mainPage.verifyElementTitle('Login Title') await mainPage.openElementDetails() await detailsPage.editElement() await detailsPage.clickElementItemCloseButton() await mainPage.verifyElementTitle('Login Title') }) test('View uploaded file in Edit mode', async ({ page }) => { qase.id(1940) await detailsPage.editElement() await createOrEditPage.clickOnAttachment() await createOrEditPage.uploadFile() await createOrEditPage.verifyUploadedFileIsVisible() await createOrEditPage.clickOnUploadedFile() await createOrEditPage.clickOnCreateOrEditButton('save') await page.waitForTimeout(testData.timeouts.action) await detailsPage.verifyUploadedFileIsVisible() await detailsPage.clickOnUploadedFile() await detailsPage.verifyUploadedImageIsVisible() await createOrEditPage.clickElementItemCloseButton() }) test('Empty fields not displayed in view mode', async ({ page }) => { qase.id(1942) await mainPage.verifyElementTitle('Login Title') await mainPage.openElementDetails() await detailsPage.editElement() await createOrEditPage.fillCreateOrEditInput('username', '') await createOrEditPage.fillCreateOrEditInput('password', '') await createOrEditPage.fillCreateOrEditInput('website', '') await createOrEditPage.fillCreateOrEditInput('comment', '') await createOrEditPage.clickOnDeleteAttachmentButton() await createOrEditPage.clickOnCreateOrEditButton('save') await mainPage.openElementDetails() await detailsPage.verifyDetailsNoItems() await test.step('CLOSE DETAILS', async () => { await mainPage.clickDetailsCloseButton() }) }) }) ================================================ FILE: e2e/specs/01-Login/creatingLoginItemPassword.test.js ================================================ import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Password', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('login') await utilities.deleteAllElements() await mainPage.clickAddItem('login') await createOrEditPage.fillCreateOrEditInput('title', 'Login Title') await createOrEditPage.fillCreateOrEditInput('username', 'Test User') await createOrEditPage.fillCreateOrEditInput('password', 'Test Pass') await createOrEditPage.fillCreateOrEditInput('website', 'https://www.website.co') await createOrEditPage.fillCreateOrEditInput('comment', 'Test Note') await createOrEditPage.clickOnCreateOrEditButton('save') await page.waitForTimeout(testData.timeouts.action) }) test.beforeEach(async () => { const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async () => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Verify that the password was changed to a generic password, with "Safe" strength as the default option.', async () => { qase.id(2000) await mainPage.openElementDetails() await detailsPage.editElement() await createOrEditPage.openPasswordMenu() await createOrEditPage.clickInsertPasswordButton() await createOrEditPage.clickShowHidePasswordButtonLast() await createOrEditPage.verifyPasswordToNotHaveValue('Test Pass') await createOrEditPage.clickOnCreateOrEditButton('save') await page.waitForTimeout(testData.timeouts.action) await mainPage.clickDetailsCloseButton() }) test('Verify that password strength updates when the "special characters" switch is toggled', async () => { qase.id(2001) const root = page.locator('body') await mainPage.openElementDetails() await detailsPage.editElement() await createOrEditPage.openPasswordMenu() await expect(root.getByRole('radio', { name: /Random Characters/i })).toBeChecked() await expect(root.getByText('8 chars')).toBeVisible() await expect(root.getByText('Special character')).toBeVisible() const toggle = root.locator('[role="switch"]') await expect(toggle).toHaveAttribute('aria-checked', 'true') await expect(root.getByText('Strong')).toBeVisible() await toggle.click() await expect(toggle).toHaveAttribute('aria-checked', 'false') await expect(root.getByText('Strong')).not.toBeVisible() await toggle.click() await expect(toggle).toHaveAttribute('aria-checked', 'true') await expect(root.getByText('Strong')).toBeVisible() await root.getByTestId('generatepassword-button-discard-v2').click() await createOrEditPage.clickOnCreateOrEditButton('discard') await mainPage.clickDetailsCloseButton() }) }) ================================================ FILE: e2e/specs/01-Login/editingDeletingLoginItem.test.js ================================================ import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Editing/Deleting Login Item', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('login') await utilities.deleteAllElements() await mainPage.clickAddItem('login') await createOrEditPage.fillCreateOrEditInput('title', 'Login Title') await createOrEditPage.fillCreateOrEditInput('username', 'Test User') await createOrEditPage.fillCreateOrEditInput('password', 'Test Pass') await createOrEditPage.fillCreateOrEditInput('website', 'https://www.website.co') await createOrEditPage.fillCreateOrEditInput('comment', 'Test Note') await createOrEditPage.clickOnCreateOrEditButton('save') await page.waitForTimeout(testData.timeouts.action) await page.waitForTimeout(testData.timeouts.action) }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async () => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Verify that edited "Login" item fields are saved correctly', async () => { qase.id(2034) await mainPage.openElementDetails() await detailsPage.editElement() await createOrEditPage.fillCreateOrEditInput('title', 'Login Title EDITED') await createOrEditPage.fillCreateOrEditInput('username', 'Test User EDITED') await createOrEditPage.fillCreateOrEditInput('password', 'Test Pass EDITED') await createOrEditPage.fillCreateOrEditInput('website', 'https://www.website1.co') await createOrEditPage.fillCreateOrEditInput('comment', 'Test Note EDITED') await createOrEditPage.clickOnCreateOrEditButton('save') await page.waitForTimeout(testData.timeouts.action) await mainPage.verifyElementTitle('Login Title EDITED') await mainPage.openElementDetails() await detailsPage.verifyItemDetailsValue('Email or username', 'Test User EDITED') await detailsPage.verifyItemDetailsValue('Password', 'Test Pass EDITED') await detailsPage.verifyItemDetailsValue('https://', 'https://www.website1.co') await detailsPage.verifyCustomNoteText('Test Note EDITED') }) test('Verify that deleted "Website" and custom "Note" fields are not saved in the edited "Login" item', async () => { qase.id(2035) await detailsPage.editElement() await createOrEditPage.fillCreateOrEditInput('website', '') await createOrEditPage.fillCreateOrEditInput('comment', '') await createOrEditPage.clickOnCreateOrEditButton('save') await detailsPage.verifyItemDetailsValue('Email or username', 'Test User EDITED') await detailsPage.verifyItemDetailsValue('Password', 'Test Pass EDITED') await createOrEditPage.verifyDetailsWebsiteCount(0) await createOrEditPage.verifyDetailsCommentCount(0) }) test('Empty fields are not displayed in view mode', async () => { qase.id(2036) await detailsPage.editElement() await createOrEditPage.fillCreateOrEditInput('username', '') await createOrEditPage.fillCreateOrEditInput('password', '') await createOrEditPage.clickOnCreateOrEditButton('save') await mainPage.openElementDetails() await detailsPage.verifyDetailsNoItems() }) test('Verify that the "Login" item is removed after deletion', async () => { qase.id(2037) await utilities.deleteAllElements() await mainPage.verifyElementIsNotVisible() }) test('Verify that the empty collection view is displayed on the Home screen after deleting the last item', async () => { qase.id(2037) await sideMenuPage.selectSideBarCategory('all') await expect(mainPage.emptyCollectionView).toBeVisible() }) }) ================================================ FILE: e2e/specs/02-CreditCard/creatingCreditCardItem.test.js ================================================ import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Creating Credit Card Item', async () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) mainPage = new MainPage(root) detailsPage = new DetailsPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('all') await utilities.deleteAllElements() await sideMenuPage.selectSideBarCategory('creditCard') await mainPage.clickAddItem('creditCard') await page.waitForTimeout(testData.timeouts.action) }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async ({ app }) => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Creating the "Credit Card" item', async ({ page }) => { qase.id(2115) await createOrEditPage.fillCreateOrEditInput('creditcard-title', 'Credit Card Title') await createOrEditPage.fillCreateOrEditInput('creditcard-name', 'John') await createOrEditPage.fillCreateOrEditInput('creditcard-number', '1231 2312') await createOrEditPage.fillCreateOrEditInput('creditcard-expiredate', '12 12') await createOrEditPage.fillCreateOrEditInput('creditcard-securitycode', '111') await createOrEditPage.fillCreateOrEditInput('creditcard-pincode', '5555') await createOrEditPage.fillCreateOrEditInput('creditcard-comment', 'Credit Card Note') await createOrEditPage.clickOnCreateOrEditButton('creditcard-save') }) test('Viewing created item. Verify item details', async ({ page }) => { qase.id(2116) await mainPage.verifyElementTitle('Credit Card Title') await mainPage.openElementDetails() await detailsPage.verifyItemDetailsValue('Name on card', 'John') await detailsPage.verifyItemDetailsValue('Number on card', '1231 2312') await detailsPage.verifyItemDetailsValue('Date of expire', '12 12') await detailsPage.verifyItemDetailsValue('Security code', '111') await detailsPage.verifyItemDetailsValue('Pin code', '5555') await detailsPage.verifyItemDetailsValue('Comment', 'Credit Card Note') }) test('Password visibility icon displays/hides value', async ({ page }) => { qase.id(2117) await mainPage.verifyElementTitle('Credit Card Title') await mainPage.openElementDetails() await detailsPage.verifyPasswordFieldType('card-details-multi-slot-input-slot-3', 'password') await detailsPage.clickPasswordToggle('card-details-multi-slot-input-slot-3') await detailsPage.verifyPasswordFieldType('card-details-multi-slot-input-slot-3', 'text') }) test('Add via Favorite icon', async ({ page }) => { qase.id(2120) await sideMenuPage.selectSideBarCategory('all') await mainPage.clickMainViewHeaderSelect() await mainPage.elementCheckBox(false) await mainPage.clickOnFirstElement() await mainPage.elementCheckBox(true) await mainPage.clickOnMainViewFavoriteIcon() await sideMenuPage.verifySideBarFavoritesFolder('1 items') }) test('Remove via Favorite icon', async ({ page }) => { qase.id(2121) await mainPage.clickMainViewHeaderSelect() await mainPage.clickOnFirstElement() await mainPage.clickOnMainViewFavoriteIcon() await sideMenuPage.verifySideBarFavoritesFolder('0 items') }) test('Add via More options', async ({ page }) => { qase.id(2122) await mainPage.openElementDetails() await detailsPage.openItemBarThreeDotsDropdownMenu() await detailsPage.clickMarkAsFavoriteButton() await sideMenuPage.verifySideBarFavoritesFolder('1 items') }) test('Remove via More options', async ({ page }) => { qase.id(2123) await detailsPage.openItemBarThreeDotsDropdownMenu() await detailsPage.clickRemoveFromFavoritesButton() await sideMenuPage.verifySideBarFavoritesFolder('0 items') }) // test('Add Custom Note', async ({ page }) => { // qase.id(2124) // await mainPage.verifyElementTitle('Credit Card Title') // await mainPage.openElementDetails() // await detailsPage.editElement() // await createOrEditPage.clickCreateCustomItem() // await createOrEditPage.clickCustomItemOptionNote() // await expect(createOrEditPage.customNoteInput).toHaveCount(1) // await createOrEditPage.fillCustomNoteInput() // await createOrEditPage.clickOnCreateOrEditButton('save') // await page.waitForTimeout(testData.timeouts.action) // await mainPage.clickDetailsCloseButton() // }) // test('Delete Note field', async ({ page }) => { // qase.id(2125) // await mainPage.verifyElementTitle('Credit Card Title') // await mainPage.openElementDetails() // await detailsPage.editElement() // await expect(createOrEditPage.customNoteInput_first).toHaveCount(2) // await createOrEditPage.deleteCustomNote() // await expect(createOrEditPage.customNoteInput_first).toHaveCount(1) // await createOrEditPage.clickOnCreateOrEditButton('save') // await page.waitForTimeout(testData.timeouts.action) // await mainPage.clickDetailsCloseButton() // }) test('Close via Cross icon', async ({ page }) => { qase.id(2126) await mainPage.verifyElementTitle('Credit Card Title') await mainPage.openElementDetails() await detailsPage.editElement() await detailsPage.clickElementItemCloseButton() await mainPage.verifyElementTitle('Credit Card Title') }) test('View uploaded file in Edit mode', async ({ page }) => { qase.id(2127) await detailsPage.editElement() await createOrEditPage.clickOnAttachment() await createOrEditPage.uploadFile() await createOrEditPage.verifyUploadedFileIsVisible() await createOrEditPage.clickOnUploadedFile() await createOrEditPage.clickOnCreateOrEditButton('creditcard-save') await page.waitForTimeout(testData.timeouts.action) await detailsPage.verifyUploadedFileIsVisible() await detailsPage.clickOnUploadedFile() await detailsPage.verifyUploadedImageIsVisible() await createOrEditPage.clickElementItemCloseButton() }) test('Empty fields not displayed in view mode', async ({ page }) => { qase.id(2129) await mainPage.verifyElementTitle('Credit Card Title') await mainPage.openElementDetails() await detailsPage.editElement() await createOrEditPage.fillCreateOrEditInput('creditcard-name', '') await createOrEditPage.fillCreateOrEditInput('creditcard-number', '') await createOrEditPage.fillCreateOrEditInput('creditcard-expiredate', '') await createOrEditPage.fillCreateOrEditInput('creditcard-securitycode', '') await createOrEditPage.fillCreateOrEditInput('creditcard-pincode', '') await createOrEditPage.fillCreateOrEditInput('creditcard-comment', '') await createOrEditPage.clickOnDeleteAttachmentButton() await createOrEditPage.clickOnCreateOrEditButton('creditcard-save') await mainPage.openElementDetails() await detailsPage.verifyDetailsNoItems() await test.step('CLOSE DETAILS', async () => { await mainPage.clickDetailsCloseButton() }) }) }) ================================================ FILE: e2e/specs/02-CreditCard/editingDeleteCreditCardItem.test.js ================================================ import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Editing/Deleting Credit Card Item', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('all') await utilities.deleteAllElements() await sideMenuPage.selectSideBarCategory('creditCard') await mainPage.clickAddItem('creditCard') await createOrEditPage.fillCreateOrEditInput('creditcard-title', 'Credit Card Title') await createOrEditPage.fillCreateOrEditInput('creditcard-name', 'John') await createOrEditPage.fillCreateOrEditInput('creditcard-number', '12312312') await createOrEditPage.fillCreateOrEditInput('creditcard-expiredate', '1212') await createOrEditPage.fillCreateOrEditInput('creditcard-securitycode', '111') await createOrEditPage.fillCreateOrEditInput('creditcard-pincode', '111') await createOrEditPage.fillCreateOrEditInput('creditcard-comment', 'Credit Card Note') await createOrEditPage.clickOnCreateOrEditButton('creditcard-save') await page.waitForTimeout(testData.timeouts.action) await mainPage.verifyElementTitle('Credit Card Title') await page.waitForTimeout(testData.timeouts.action) }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async ({ app }) => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Verify that edited "Credit Card" item fields are saved correctly', async () => { qase.id(2130) await mainPage.openElementDetails() await detailsPage.editElement() await createOrEditPage.fillCreateOrEditInput('creditcard-name', 'John EDITED') await createOrEditPage.fillCreateOrEditInput('creditcard-number', '99999999') await createOrEditPage.fillCreateOrEditInput('creditcard-expiredate', '0101') await createOrEditPage.fillCreateOrEditInput('creditcard-securitycode', '222') await createOrEditPage.fillCreateOrEditInput('creditcard-pincode', '222') await createOrEditPage.fillCreateOrEditInput('creditcard-comment', 'Credit Card Note EDITED') await createOrEditPage.clickOnCreateOrEditButton('creditcard-save') await page.waitForTimeout(testData.timeouts.action) await mainPage.verifyElementTitle('Credit Card Title') await mainPage.openElementDetails() await detailsPage.verifyItemDetailsValue('Name on card', 'John EDITED') await detailsPage.verifyItemDetailsValue('Number on card', '9999 9999') await detailsPage.verifyCustomNoteText('Credit Card Note EDITED') }) test('Empty fields are not displayed in view mode', async ({ page }) => { qase.id(2131) await detailsPage.editElement() await createOrEditPage.fillCreateOrEditInput('creditcard-name', '') await createOrEditPage.fillCreateOrEditInput('creditcard-number', '') await createOrEditPage.fillCreateOrEditInput('creditcard-expiredate', '') await createOrEditPage.fillCreateOrEditInput('creditcard-securitycode', '') await createOrEditPage.fillCreateOrEditInput('creditcard-pincode', '') await createOrEditPage.fillCreateOrEditInput('creditcard-comment', '') await createOrEditPage.clickOnCreateOrEditButton('creditcard-save') await mainPage.openElementDetails() await detailsPage.verifyDetailsNoItems() await test.step('CLOSE DETAILS', async () => { await mainPage.clickDetailsCloseButton() }) }) test('Verify that the "Credit Card" item is removed after deletion', async () => { qase.id(2133) await utilities.deleteAllElements() await mainPage.verifyElementIsNotVisible() }) test('Verify that the empty collection view is displayed on the Home screen after deleting the last item', async () => { qase.id(2134) await sideMenuPage.selectSideBarCategory('all') await expect(mainPage.emptyCollectionView).toBeVisible() }) }) ================================================ FILE: e2e/specs/03-WiFi/creatingWiFiItem.test.js ================================================ import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Creating WiFi Item', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) mainPage = new MainPage(root) detailsPage = new DetailsPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('wifiPassword') await utilities.deleteAllElements() await mainPage.clickAddItem('wifiPassword') }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async ({ }) => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Creating the "WiFi" item', async ({ page }) => { qase.id(2135) await createOrEditPage.fillCreateOrEditInput('wifi-name', 'WiFi Title') await createOrEditPage.fillCreateOrEditInput('wifi-password', 'WiFi Pass') await createOrEditPage.fillCreateOrEditInput('wifi-comment', 'WiFi Note') await createOrEditPage.clickOnCreateOrEditButton('wifi-save') }) test('Viewing created item. Verify item details', async ({ page }) => { qase.id(2136) await mainPage.openElementDetails() await detailsPage.verifyTitle('WiFi Title') await detailsPage.verifyItemDetailsValue('Wi-Fi Password', 'WiFi Pass') await detailsPage.verifyItemDetailsValue('Add comment', 'WiFi Note') }) test('Password visibility icon displays/hides value', async ({ page }) => { qase.id(2137) await mainPage.openElementDetails() await mainPage.verifyElementTitle('WiFi Title') await createOrEditPage.verifyPasswordType('password') await createOrEditPage.clickShowHidePasswordButtonFirst() await createOrEditPage.verifyPasswordType('text') }) test('Add via Favorite icon', async ({ page }) => { qase.id(2140) await sideMenuPage.selectSideBarCategory('all') await mainPage.clickMainViewHeaderSelect() await mainPage.elementCheckBox(false) await mainPage.clickOnFirstElement() await mainPage.elementCheckBox(true) await mainPage.clickOnMainViewFavoriteIcon() await sideMenuPage.verifySideBarFavoritesFolder('1 items') }) test('Remove via Favorite icon', async ({ page }) => { qase.id(2141) await mainPage.clickMainViewHeaderSelect() await mainPage.clickOnFirstElement() await mainPage.clickOnMainViewFavoriteIcon() await sideMenuPage.verifySideBarFavoritesFolder('0 items') }) test('Add via More options', async ({ page }) => { qase.id(2142) await mainPage.openElementDetails() await detailsPage.openItemBarThreeDotsDropdownMenu() await detailsPage.clickMarkAsFavoriteButton() await sideMenuPage.verifySideBarFavoritesFolder('1 items') }) test('Remove via More options', async ({ page }) => { qase.id(2143) await detailsPage.openItemBarThreeDotsDropdownMenu() await detailsPage.clickRemoveFromFavoritesButton() await sideMenuPage.verifySideBarFavoritesFolder('0 items') }) // test('Add Custom Note', async ({ page }) => { // qase.id(2144) // await mainPage.verifyElementTitle('WiFi Title') // await mainPage.openElementDetails() // await detailsPage.editElement() // await createOrEditPage.clickCreateCustomItem() // await createOrEditPage.clickCustomItemOptionNote() // await expect(createOrEditPage.customNoteInput).toHaveCount(1) // await createOrEditPage.fillCustomNoteInput() // await createOrEditPage.clickOnCreateOrEditButton('save') // await page.waitForTimeout(testData.timeouts.action) // await mainPage.clickDetailsCloseButton() // }) // test('Delete Note field', async ({ page }) => { // qase.id(2145) // await mainPage.verifyElementTitle('WiFi Title') // await mainPage.openElementDetails() // await detailsPage.editElement() // await expect(createOrEditPage.customNoteInput_first).toHaveCount(2) // await createOrEditPage.deleteCustomNote() // await expect(createOrEditPage.customNoteInput_first).toHaveCount(1) // await createOrEditPage.clickOnCreateOrEditButton('save') // await page.waitForTimeout(testData.timeouts.action) // await mainPage.clickDetailsCloseButton() // }) test('Close via Cross icon', async ({ page }) => { qase.id(2146) await mainPage.verifyElementTitle('WiFi Title') await mainPage.openElementDetails() await detailsPage.editElement() await detailsPage.clickElementItemCloseButton() await mainPage.verifyElementTitle('WiFi Title') }) test('Empty fields not displayed in view mode', async ({ page }) => { qase.id(2147) await mainPage.verifyElementTitle('WiFi Title') await mainPage.openElementDetails() await detailsPage.editElement() await createOrEditPage.fillCreateOrEditInput('wifi-comment', '') await createOrEditPage.clickOnCreateOrEditButton('wifi-save') await mainPage.openElementDetails() await detailsPage.verifyTitle('WiFi Title') await detailsPage.verifyItemDetailsValueIsNotVisible('Add comment') await mainPage.clickDetailsCloseButton() }) }) ================================================ FILE: e2e/specs/03-WiFi/editingDeletingWiFiItem.test.js ================================================ import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Editing/Deleting WiFi Item', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) mainPage = new MainPage(root) detailsPage = new DetailsPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('wifiPassword') await utilities.deleteAllElements() await mainPage.clickAddItem('wifiPassword') await createOrEditPage.fillCreateOrEditInput('wifi-name', 'WiFi Title') await createOrEditPage.fillCreateOrEditInput('wifi-password', 'WiFi Pass') await createOrEditPage.fillCreateOrEditInput('wifi-comment', 'WiFi Note') await createOrEditPage.clickOnCreateOrEditButton('wifi-save') await page.waitForTimeout(testData.timeouts.action) }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async ({ }) => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Verify that edited "WiFi" item fields are saved correctly', async ({ page }) => { qase.id(2148) await mainPage.openElementDetails() await detailsPage.editElement() await createOrEditPage.fillCreateOrEditInput('wifi-name', 'WiFi Title Edited') await createOrEditPage.fillCreateOrEditInput('wifi-comment', 'WiFi Note Edited') await createOrEditPage.clickOnCreateOrEditButton('wifi-save') await page.waitForTimeout(testData.timeouts.action) await mainPage.verifyElementTitle('WiFi Title Edited') await mainPage.openElementDetails() await detailsPage.verifyItemDetailsValue('Add comment', 'WiFi Note Edited') }) test('Empty fields are not displayed in view mode', async () => { qase.id(2150) await detailsPage.editElement() await createOrEditPage.fillCreateOrEditInput('wifi-comment', '') await createOrEditPage.clickOnCreateOrEditButton('wifi-save') await mainPage.openElementDetails() await detailsPage.verifyItemDetailsValueIsNotVisible('Add comment') await mainPage.clickDetailsCloseButton() }) }) ================================================ FILE: e2e/specs/04-Identity/creatingIdentityItem.test.js ================================================ import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Creating Identity Item', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) sideMenuPage = new SideMenuPage(root) utilities = new Utilities(root) mainPage = new MainPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('identity') await utilities.deleteAllElements() await mainPage.clickAddItem('identity') await page.waitForTimeout(testData.timeouts.action) }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async () => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Creating the "Identity" item', async ({ page }) => { qase.id(2151) await createOrEditPage.fillCreateOrEditInput('identity-title', 'Identity Title') await createOrEditPage.fillCreateOrEditInput('identity-fullname', 'Identity Fullname') await createOrEditPage.fillCreateOrEditInput('identity-email', 'identitytest@mail.co') await createOrEditPage.fillCreateOrEditInput('identity-phone', '') await createOrEditPage.fillCreateOrEditInput('identity-address', 'Identity Address') await createOrEditPage.fillCreateOrEditInput('identity-zip', 'Identity Zip') await createOrEditPage.fillCreateOrEditInput('identity-city', 'Identity City') await createOrEditPage.fillCreateOrEditInput('identity-region', 'Identity Region') await createOrEditPage.fillCreateOrEditInput('identity-country', 'Identity Country') await createOrEditPage.fillCreateOrEditInput('identity-passportfullname', 'Identity Passport Fullname') await createOrEditPage.fillCreateOrEditInput('identity-passportnumber', 'Identity Passport Number') await createOrEditPage.fillCreateOrEditInput('identity-passportissuingcountry', 'Identity Issuing Country') await createOrEditPage.fillCreateOrEditInput('identity-passportdateofissue', 'Identity Date of Issue') await createOrEditPage.fillCreateOrEditInput('identity-passportexpirydate', '01/01/2020') await createOrEditPage.fillCreateOrEditInput('identity-passportnationality', '01/01/2025') await createOrEditPage.fillCreateOrEditInput('identity-passportdob', '01/01/1990') await createOrEditPage.fillCreateOrEditInput('identity-passportgender', 'Identity Gender') await createOrEditPage.fillCreateOrEditInput('identity-idcardnumber', 'Identity ID Card Number') await createOrEditPage.fillCreateOrEditInput('identity-idcarddateofissue', '01/01/2025') await createOrEditPage.fillCreateOrEditInput('identity-idcardexpirydate', '01/01/2030') await createOrEditPage.fillCreateOrEditInput('identity-idcardissuingcountry', 'USA') await createOrEditPage.fillCreateOrEditInput('identity-comment', 'Identity Driving License Note') await createOrEditPage.clickOnCreateOrEditButton('identity-save') await page.waitForTimeout(testData.timeouts.action) }) test('Viewing created item. Verify item details', async ({ page }) => { qase.id(2152) await mainPage.openElementDetails() await detailsPage.verifyIdentityDetailsValue('fullname', 'Identity Fullname') await detailsPage.verifyIdentityDetailsValue('email', 'identitytest@mail.co') await detailsPage.verifyIdentityDetailsValue('address', 'Identity Address') await detailsPage.verifyIdentityDetailsValue('zip', 'Identity Zip') await detailsPage.verifyIdentityDetailsValue('city', 'Identity City') await detailsPage.verifyIdentityDetailsValue('region', 'Identity Region') await detailsPage.verifyIdentityDetailsValue('country', 'Identity Country') await detailsPage.verifyIdentityDetailsValue('passportfullname', 'Identity Passport Fullname') await detailsPage.verifyIdentityDetailsValue('passportnumber', 'Identity Passport Number') await detailsPage.verifyIdentityDetailsValue('passportissuingcountry', 'Identity Issuing Country') await detailsPage.verifyIdentityDetailsValue('passportdateofissue', 'Identity Date of Issue') await detailsPage.verifyIdentityDetailsValue('passportexpirydate', '01/01/2020') await detailsPage.verifyIdentityDetailsValue('passportnationality', '01/01/2025') await detailsPage.verifyIdentityDetailsValue('passportdob', '01/01/1990') await detailsPage.verifyIdentityDetailsValue('passportgender', 'Identity Gender') await detailsPage.verifyIdentityDetailsValue('idcardnumber', 'Identity ID Card Number') await detailsPage.verifyIdentityDetailsValue('idcarddateofissue', '01/01/2025') await detailsPage.verifyIdentityDetailsValue('idcardexpirydate', '01/01/2030') await detailsPage.verifyIdentityDetailsValue('idcardissuingcountry', 'USA') await detailsPage.verifyIdentityDetailsValue('note', 'Identity Driving License Note') }) test('Add via Favorite icon', async ({ page }) => { qase.id(2156) await sideMenuPage.selectSideBarCategory('all') await mainPage.clickMainViewHeaderSelect() await mainPage.elementCheckBox(false) await mainPage.clickOnFirstElement() await mainPage.elementCheckBox(true) await mainPage.clickOnMainViewFavoriteIcon() await sideMenuPage.verifySideBarFavoritesFolder('1 items') }) test('Remove via Favorite icon', async ({ page }) => { qase.id(2157) await mainPage.clickMainViewHeaderSelect() await mainPage.clickOnFirstElement() await mainPage.clickOnMainViewFavoriteIcon() await sideMenuPage.verifySideBarFavoritesFolder('0 items') }) test('Add via More options', async ({ page }) => { qase.id(2158) await mainPage.openElementDetails() await detailsPage.openItemBarThreeDotsDropdownMenu() await detailsPage.clickMarkAsFavoriteButton() await sideMenuPage.verifySideBarFavoritesFolder('1 items') }) test('Remove via More options', async ({ page }) => { qase.id(2159) await detailsPage.openItemBarThreeDotsDropdownMenu() await detailsPage.clickRemoveFromFavoritesButton() await sideMenuPage.verifySideBarFavoritesFolder('0 items') }) // test('Add Custom Note', async ({ page }) => { // qase.id(2160) // await mainPage.verifyElementTitle('Identity Title') // await mainPage.openElementDetails() // await detailsPage.editElement() // await createOrEditPage.clickCreateCustomItem() // await createOrEditPage.clickCustomItemOptionNote() // await expect(createOrEditPage.customNoteInput).toHaveCount(1) // await createOrEditPage.fillCustomNoteInput() // await createOrEditPage.clickOnCreateOrEditButton('save') // await page.waitForTimeout(testData.timeouts.action) // await mainPage.clickDetailsCloseButton() // }) // test('Delete Note field', async ({ page }) => { // qase.id(2161) // await mainPage.verifyElementTitle('Identity Title') // await mainPage.openElementDetails() // await detailsPage.editElement() // await expect(createOrEditPage.customNoteInput_first).toHaveCount(2) // await createOrEditPage.deleteCustomNote() // await expect(createOrEditPage.customNoteInput_first).toHaveCount(1) // await createOrEditPage.clickOnCreateOrEditButton('save') // await page.waitForTimeout(testData.timeouts.action) // await mainPage.clickDetailsCloseButton() // }) test('Close via Cross icon', async ({ page }) => { qase.id(2162) await mainPage.verifyElementTitle('Identity Title') await mainPage.openElementDetails() await detailsPage.editElement() await detailsPage.clickElementItemCloseButton() await mainPage.verifyElementTitle('Identity Title') }) test('View uploaded file in Edit mode', async ({ page }) => { qase.id(2163) await detailsPage.editElement() await createOrEditPage.clickOnAttachment() await createOrEditPage.uploadFile() await createOrEditPage.verifyUploadedFileIsVisible() await createOrEditPage.clickOnUploadedFile() await createOrEditPage.clickOnCreateOrEditButton('identity-save') await page.waitForTimeout(testData.timeouts.action) await detailsPage.verifyUploadedFileIsVisible() await detailsPage.clickOnUploadedFile() await detailsPage.verifyUploadedImageIsVisible() await createOrEditPage.clickElementItemCloseButton() }) test('Empty fields not displayed in view mode', async ({ page }) => { qase.id(2165) await mainPage.verifyElementTitle('Identity Title') await mainPage.openElementDetails() await detailsPage.editElement() await createOrEditPage.fillCreateOrEditInput('identity-fullname', '') await createOrEditPage.fillCreateOrEditInput('identity-email', '') await createOrEditPage.fillCreateOrEditInput('identity-phone', '') await createOrEditPage.fillCreateOrEditInput('identity-address', '') await createOrEditPage.fillCreateOrEditInput('identity-zip', '') await createOrEditPage.fillCreateOrEditInput('identity-city', '') await createOrEditPage.fillCreateOrEditInput('identity-region', '') await createOrEditPage.fillCreateOrEditInput('identity-country', '') await createOrEditPage.fillCreateOrEditInput('identity-passportfullname', '') await createOrEditPage.fillCreateOrEditInput('identity-passportnumber', '') await createOrEditPage.fillCreateOrEditInput('identity-passportissuingcountry', '') await createOrEditPage.fillCreateOrEditInput('identity-passportdateofissue', '') await createOrEditPage.fillCreateOrEditInput('identity-passportexpirydate', '') await createOrEditPage.fillCreateOrEditInput('identity-passportnationality', '') await createOrEditPage.fillCreateOrEditInput('identity-passportdob', '') await createOrEditPage.fillCreateOrEditInput('identity-passportgender', '') await createOrEditPage.fillCreateOrEditInput('identity-idcardnumber', '') await createOrEditPage.fillCreateOrEditInput('identity-idcarddateofissue', '') await createOrEditPage.fillCreateOrEditInput('identity-idcardexpirydate', '') await createOrEditPage.fillCreateOrEditInput('identity-idcardissuingcountry', '') await createOrEditPage.fillCreateOrEditInput('identity-comment', '') await createOrEditPage.clickOnCreateOrEditButton('identity-save') await mainPage.openElementDetails() await detailsPage.verifyDetailsNoItems() await mainPage.clickDetailsCloseButton() }) }) ================================================ FILE: e2e/specs/04-Identity/editingDeletingIdentityItem.test.js ================================================ import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Editing/Deleting Identity Item', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('identity') await utilities.deleteAllElements() await mainPage.clickAddItem('identity') await createOrEditPage.fillCreateOrEditInput('identity-title', 'Identity Title') await createOrEditPage.fillCreateOrEditInput('identity-fullname', 'Identity Fullname') await createOrEditPage.fillCreateOrEditInput('identity-email', 'identitytest@mail.co') await createOrEditPage.fillCreateOrEditInput('identity-phone', '') await createOrEditPage.fillCreateOrEditInput('identity-address', 'Identity Address') await createOrEditPage.fillCreateOrEditInput('identity-zip', 'Identity Zip') await createOrEditPage.fillCreateOrEditInput('identity-city', 'Identity City') await createOrEditPage.fillCreateOrEditInput('identity-region', 'Identity Region') await createOrEditPage.fillCreateOrEditInput('identity-country', 'Identity Country') await createOrEditPage.fillCreateOrEditInput('identity-passportfullname', 'Identity Passport Fullname') await createOrEditPage.fillCreateOrEditInput('identity-passportnumber', 'Identity Passport Number') await createOrEditPage.fillCreateOrEditInput('identity-passportissuingcountry', 'Identity Issuing Country') await createOrEditPage.fillCreateOrEditInput('identity-passportdateofissue', 'Identity Date of Issue') await createOrEditPage.fillCreateOrEditInput('identity-passportexpirydate', '01/01/2020') await createOrEditPage.fillCreateOrEditInput('identity-passportnationality', '01/01/2025') await createOrEditPage.fillCreateOrEditInput('identity-passportdob', '01/01/1990') await createOrEditPage.fillCreateOrEditInput('identity-passportgender', 'Identity Gender') await createOrEditPage.fillCreateOrEditInput('identity-idcardnumber', 'Identity ID Card Number') await createOrEditPage.fillCreateOrEditInput('identity-idcarddateofissue', '01/01/2025') await createOrEditPage.fillCreateOrEditInput('identity-idcardexpirydate', '01/01/2030') await createOrEditPage.fillCreateOrEditInput('identity-idcardissuingcountry', 'USA') await createOrEditPage.fillCreateOrEditInput('identity-comment', 'Identity Driving License Note') await createOrEditPage.clickOnCreateOrEditButton('identity-save') await page.waitForTimeout(testData.timeouts.action) }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async () => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Verify that edited "Identity" item fields are saved correctly', async () => { qase.id(2166) await mainPage.openElementDetails() await detailsPage.editElement() await createOrEditPage.fillCreateOrEditInput('identity-title', 'Identity Title Edited') await createOrEditPage.fillCreateOrEditInput('identity-fullname', 'Identity Fullname Edited') await createOrEditPage.fillCreateOrEditInput('identity-email', 'identitytestedited@mail.co') await createOrEditPage.fillCreateOrEditInput('identity-phone', 'Phone Number Edited') await createOrEditPage.fillCreateOrEditInput('identity-address', 'Identity Address Edited') await createOrEditPage.fillCreateOrEditInput('identity-zip', 'Identity Zip Edited') await createOrEditPage.fillCreateOrEditInput('identity-city', 'Identity City Edited') await createOrEditPage.fillCreateOrEditInput('identity-region', 'Identity Region Edited') await createOrEditPage.fillCreateOrEditInput('identity-country', 'Identity Country Edited') await createOrEditPage.fillCreateOrEditInput('identity-passportfullname', 'Identity Passport Fullname Edited') await createOrEditPage.fillCreateOrEditInput('identity-passportnumber', 'Identity Passport Number Edited') await createOrEditPage.fillCreateOrEditInput('identity-passportissuingcountry', 'Identity Issuing Country Edited') await createOrEditPage.fillCreateOrEditInput('identity-passportdateofissue', 'Identity Date of Issue Edited') await createOrEditPage.fillCreateOrEditInput('identity-passportexpirydate', '01/01/2022') await createOrEditPage.fillCreateOrEditInput('identity-passportnationality', '01/01/2027') await createOrEditPage.fillCreateOrEditInput('identity-passportdob', '01/01/1991') await createOrEditPage.fillCreateOrEditInput('identity-passportgender', 'Identity Gender Edited') await createOrEditPage.fillCreateOrEditInput('identity-idcardnumber', 'Identity ID Card Number Edited') await createOrEditPage.fillCreateOrEditInput('identity-idcarddateofissue', '01/01/2026') await createOrEditPage.fillCreateOrEditInput('identity-idcardexpirydate', '01/01/2031') await createOrEditPage.fillCreateOrEditInput('identity-idcardissuingcountry', 'USA Edited') await createOrEditPage.fillCreateOrEditInput('identity-comment', 'Identity Driving License Note Edited') await createOrEditPage.clickOnCreateOrEditButton('identity-save') await page.waitForTimeout(testData.timeouts.action) await mainPage.openElementDetails() await detailsPage.verifyIdentityDetailsValue('fullname', 'Identity Fullname Edited') await detailsPage.verifyIdentityDetailsValue('email', 'identitytestedited@mail.co') await detailsPage.verifyIdentityDetailsValue('address', 'Identity Address Edited') await detailsPage.verifyIdentityDetailsValue('zip', 'Identity Zip Edited') await detailsPage.verifyIdentityDetailsValue('city', 'Identity City Edited') await detailsPage.verifyIdentityDetailsValue('region', 'Identity Region Edited') await detailsPage.verifyIdentityDetailsValue('country', 'Identity Country Edited') await detailsPage.verifyIdentityDetailsValue('passportfullname', 'Identity Passport Fullname Edited') await detailsPage.verifyIdentityDetailsValue('passportnumber', 'Identity Passport Number Edited') await detailsPage.verifyIdentityDetailsValue('passportissuingcountry', 'Identity Issuing Country Edited') await detailsPage.verifyIdentityDetailsValue('passportdateofissue', 'Identity Date of Issue Edited') await detailsPage.verifyIdentityDetailsValue('passportexpirydate', '01/01/2022') await detailsPage.verifyIdentityDetailsValue('passportnationality', '01/01/2027') await detailsPage.verifyIdentityDetailsValue('passportdob', '01/01/1991') await detailsPage.verifyIdentityDetailsValue('passportgender', 'Identity Gender Edited') await detailsPage.verifyIdentityDetailsValue('idcardnumber', 'Identity ID Card Number Edited') await detailsPage.verifyIdentityDetailsValue('idcarddateofissue', '01/01/2026') await detailsPage.verifyIdentityDetailsValue('idcardexpirydate', '01/01/2031') await detailsPage.verifyIdentityDetailsValue('idcardissuingcountry', 'USA Edited') await detailsPage.verifyIdentityDetailsValue('note', 'Identity Driving License Note Edited') }) test('Verify that the "Identity" item is removed after deletion', async () => { qase.id(2168) await utilities.deleteAllElements() await mainPage.verifyElementIsNotVisible() }) test('Verify that the empty collection view is displayed on the Home screen after deleting the last item', async () => { qase.id(2169) await sideMenuPage.selectSideBarCategory('all') await expect(mainPage.emptyCollectionView).toBeVisible() }) }) ================================================ FILE: e2e/specs/05-PassPhrase/creatingPassPhraseItem.test.js ================================================ import clipboard from 'clipboardy' import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Creating PassPhrase Item', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) sideMenuPage = new SideMenuPage(root) utilities = new Utilities(root) mainPage = new MainPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('passPhrase') await utilities.deleteAllElements() await mainPage.clickAddItem('passPhrase') await page.waitForTimeout(testData.timeouts.action) }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async () => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Creating the "PassPhrase" item', async ({ page }) => { qase.id(2209) await createOrEditPage.fillCreateOrEditInput('passphrase-title', 'PassPhrase Title') await clipboard.write( 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12' ) await createOrEditPage.clickOnPasteFromClipboard() await createOrEditPage.clickOnCreateOrEditButton('passphrase-save') await page.waitForTimeout(testData.timeouts.action) }) test('Viewing created item. Verify item details', async ({ page }) => { qase.id(2210) await mainPage.verifyElementTitle('PassPhrase Title') await mainPage.openElementDetails() await detailsPage.verifyAllRecoveryPhraseWords([ 'word1', 'word2', 'word3', 'word4', 'word5', 'word6', 'word7', 'word8', 'word9', 'word10', 'word11', 'word12' ]) }) test('Add via Favorite icon', async ({ page }) => { qase.id(2213) await sideMenuPage.selectSideBarCategory('all') await mainPage.clickMainViewHeaderSelect() await mainPage.elementCheckBox(false) await mainPage.clickOnFirstElement() await mainPage.elementCheckBox(true) await mainPage.clickOnMainViewFavoriteIcon() await sideMenuPage.verifySideBarFavoritesFolder('1 items') }) test('Remove via Favorite icon', async ({ page }) => { qase.id(2214) await mainPage.clickMainViewHeaderSelect() await mainPage.clickOnFirstElement() await mainPage.clickOnMainViewFavoriteIcon() await sideMenuPage.verifySideBarFavoritesFolder('0 items') }) test('Add via More options', async ({ page }) => { qase.id(2215) await mainPage.openElementDetails() await detailsPage.openItemBarThreeDotsDropdownMenu() await detailsPage.clickMarkAsFavoriteButton() await sideMenuPage.verifySideBarFavoritesFolder('1 items') }) test('Remove via More options', async ({ page }) => { qase.id(2216) await detailsPage.openItemBarThreeDotsDropdownMenu() await detailsPage.clickRemoveFromFavoritesButton() await sideMenuPage.verifySideBarFavoritesFolder('0 items') }) // test('Add Custom Note', async ({ page }) => { // qase.id(2217); // await mainPage.verifyElementTitle('PassPhrase Title') // await mainPage.openElementDetails() // await detailsPage.editElement() // await createOrEditPage.clickCreateCustomItem() // await createOrEditPage.clickCustomItemOptionNote() // await expect(createOrEditPage.customNoteInput).toHaveCount(1) // await createOrEditPage.fillCustomNoteInput() // await createOrEditPage.clickOnCreateOrEditButton('save') // await page.waitForTimeout(testData.timeouts.action) // await mainPage.clickDetailsCloseButton() // }) // test('Delete Note field', async ({ page }) => { // qase.id(2218); // await mainPage.verifyElementTitle('PassPhrase Title') // await mainPage.openElementDetails() // await detailsPage.editElement() // await expect(createOrEditPage.customNoteInput_first).toHaveCount(2) // await createOrEditPage.deleteCustomNote() // await expect(createOrEditPage.customNoteInput_first).toHaveCount(1) // await createOrEditPage.clickOnCreateOrEditButton('save') // await page.waitForTimeout(testData.timeouts.action) // await mainPage.clickDetailsCloseButton() // }) test('Close via Cross icon', async ({ page }) => { qase.id(2219) await mainPage.verifyElementTitle('PassPhrase Title') await mainPage.openElementDetails() await detailsPage.editElement() await detailsPage.clickElementItemCloseButton() await mainPage.verifyElementTitle('PassPhrase Title') }) }) ================================================ FILE: e2e/specs/05-PassPhrase/editingDeletingPassPhraseItem.test.js ================================================ import clipboard from 'clipboardy' import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Editing/Deleting PassPhrase Item', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('passPhrase') await utilities.deleteAllElements() await mainPage.clickAddItem('passPhrase') await createOrEditPage.fillCreateOrEditInput('passphrase-title', 'PassPhrase Title') await clipboard.write( 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12' ) await createOrEditPage.clickOnPasteFromClipboard() await createOrEditPage.clickOnCreateOrEditButton('passphrase-save') await page.waitForTimeout(testData.timeouts.action) }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async () => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Verify that edited "PassPhrase" item fields are saved correctly', async () => { qase.id(2658) await mainPage.openElementDetails() await detailsPage.editElement() await createOrEditPage.fillCreateOrEditInput('passphrase-title', 'PassPhrase Title Edited') await clipboard.write(testData.passphrase.text24) await createOrEditPage.clickOnPasteFromClipboard() await createOrEditPage.clickOnCreateOrEditButton('passphrase-save') await page.waitForTimeout(testData.timeouts.action) await mainPage.openElementDetails() await detailsPage.verifyTitle('PassPhrase Title Edited') await detailsPage.verifyAllRecoveryPhraseWords([ 'word1', 'word2', 'word3', 'word4', 'word5', 'word6', 'word7', 'word8', 'word9', 'word10', 'word11', 'word12', 'word13', 'word14', 'word15', 'word16', 'word17', 'word18', 'word19', 'word20', 'word21', 'word22', 'word23', 'word24' ]) }) test('Verify that the "PassPhrase" item is removed after deletion', async () => { qase.id(2221) await utilities.deleteAllElements() await mainPage.verifyElementIsNotVisible() }) test('Verify that the empty collection view is displayed on the Home screen after deleting the last item', async () => { qase.id(2222) await sideMenuPage.selectSideBarCategory('all') await expect(mainPage.emptyCollectionView).toBeVisible() }) }) ================================================ FILE: e2e/specs/06-Note/creatingNoteItem.test.js ================================================ import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Creating Note Item', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) mainPage = new MainPage(root) detailsPage = new DetailsPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('all') await utilities.deleteAllElements() await sideMenuPage.selectSideBarCategory('note') await mainPage.clickAddItem('note') await page.waitForTimeout(testData.timeouts.action) }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async () => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Creating the "Note" item', async ({ page }) => { qase.id(2248) await createOrEditPage.fillCreateOrEditInput('note-title', 'Note Title') await createOrEditPage.fillCreateOrEditInput('note-comment', 'Test Note Text') await createOrEditPage.clickOnCreateOrEditButton('note-save') await page.waitForTimeout(testData.timeouts.action) }) test('Viewing created item. Verify item details', async ({ page }) => { qase.id(2249) await mainPage.openElementDetails() await detailsPage.verifyTitle('Note Title') await detailsPage.verifyNoteText('Test Note Text') }) test('Add via Favorite icon', async ({ page }) => { qase.id(2252) await sideMenuPage.selectSideBarCategory('all') await mainPage.clickMainViewHeaderSelect() await mainPage.elementCheckBox(false) await mainPage.clickOnFirstElement() await mainPage.elementCheckBox(true) await mainPage.clickOnMainViewFavoriteIcon() await sideMenuPage.verifySideBarFavoritesFolder('1 items') }) test('Remove via Favorite icon', async ({ page }) => { qase.id(2253) await mainPage.clickMainViewHeaderSelect() await mainPage.clickOnFirstElement() await mainPage.clickOnMainViewFavoriteIcon() await sideMenuPage.verifySideBarFavoritesFolder('0 items') }) test('Add via More options', async ({ page }) => { qase.id(2254) await mainPage.openElementDetails() await detailsPage.openItemBarThreeDotsDropdownMenu() await detailsPage.clickMarkAsFavoriteButton() await sideMenuPage.verifySideBarFavoritesFolder('1 items') }) test('Remove via More options', async ({ page }) => { qase.id(2255) await detailsPage.openItemBarThreeDotsDropdownMenu() await detailsPage.clickRemoveFromFavoritesButton() await sideMenuPage.verifySideBarFavoritesFolder('0 items') }) // test('Add Custom Note', async ({ page }) => { // qase.id(2256); // await mainPage.verifyElementTitle('Note Title') // await mainPage.openElementDetails() // await detailsPage.editElement() // await createOrEditPage.clickCreateCustomItem() // await createOrEditPage.clickCustomItemOptionNote() // await expect(createOrEditPage.customNoteInput).toHaveCount(1) // await createOrEditPage.fillCustomNoteInput() // await createOrEditPage.clickOnCreateOrEditButton('save') // await page.waitForTimeout(testData.timeouts.action) // await mainPage.clickDetailsCloseButton() // }) // test('Delete Note field', async ({ page }) => { // qase.id(2257); // await mainPage.verifyElementTitle('Note Title') // await mainPage.openElementDetails() // await detailsPage.editElement() // await expect(createOrEditPage.customNoteInput).toHaveCount(2) // await createOrEditPage.deleteCustomNote() // await expect(createOrEditPage.customNoteInput).toHaveCount(1) // await createOrEditPage.clickOnCreateOrEditButton('save') // await page.waitForTimeout(testData.timeouts.action) // await mainPage.clickDetailsCloseButton() // }) test('Close via Cross icon', async ({ page }) => { qase.id(2258) await mainPage.verifyElementTitle('Note Title') await mainPage.openElementDetails() await detailsPage.editElement() await detailsPage.clickElementItemCloseButton() await mainPage.verifyElementTitle('Note Title') }) test('View uploaded file in Edit mode', async ({ page }) => { qase.id(2259) await detailsPage.editElement() await createOrEditPage.clickOnAttachment() await createOrEditPage.uploadFile() await createOrEditPage.verifyUploadedFileIsVisible() await createOrEditPage.clickOnUploadedFile() await createOrEditPage.clickOnCreateOrEditButton('note-save') await page.waitForTimeout(testData.timeouts.action) await detailsPage.verifyUploadedFileIsVisible() await detailsPage.clickOnUploadedFile() await detailsPage.verifyUploadedImageIsVisible() await createOrEditPage.clickElementItemCloseButton() }) test('Empty fields not displayed in view mode', async ({ page }) => { qase.id(2261) await mainPage.verifyElementTitle('Note Title') await mainPage.openElementDetails() await detailsPage.editElement() await createOrEditPage.fillCreateOrEditInput('note-comment', '') await createOrEditPage.clickOnCreateOrEditButton('note-save') await detailsPage.verifyItemDetailsValueIsNotVisible('Add comment') }) }) ================================================ FILE: e2e/specs/06-Note/editingDeleteNoteItem.test.js ================================================ import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Editing/Deleting Note Item', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('note') await utilities.deleteAllElements() await mainPage.clickAddItem('note') await createOrEditPage.fillCreateOrEditInput('note-title', 'Note Title') await createOrEditPage.fillCreateOrEditInput('note-comment', 'Test Note Text') await createOrEditPage.clickOnCreateOrEditButton('note-save') await page.waitForTimeout(testData.timeouts.action) }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async () => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Verify that edited "Note" item fields are saved correctly', async () => { qase.id(2262) await mainPage.openElementDetails() await detailsPage.editElement() await createOrEditPage.fillCreateOrEditInput('note-title', 'EDITED Note Title') await createOrEditPage.fillCreateOrEditInput('note-comment', 'EDITED Test Note Text') await createOrEditPage.clickOnCreateOrEditButton('note-save') await page.waitForTimeout(testData.timeouts.action) await mainPage.openElementDetails() await detailsPage.verifyTitle('EDITED Note Title') await detailsPage.verifyNoteText('EDITED Test Note Text') }) test('Verify that the "Login" item is removed after deletion', async () => { qase.id(2265) await utilities.deleteAllElements() await mainPage.verifyElementIsNotVisible() }) test('Verify that the empty collection view is displayed on the Home screen after deleting the last item', async () => { qase.id(2266) await sideMenuPage.selectSideBarCategory('all') await expect(mainPage.emptyCollectionView).toBeVisible() }) }) ================================================ FILE: e2e/specs/07-CustomField/creatingCustomFieldItem.test.js ================================================ import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Creating Custom Item', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) sideMenuPage = new SideMenuPage(root) utilities = new Utilities(root) mainPage = new MainPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('custom') await utilities.deleteAllElements() await mainPage.clickAddItem('custom') await page.waitForTimeout(testData.timeouts.action) }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async () => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Creating the "Custom" item', async ({ page }) => { qase.id(2546) await createOrEditPage.fillCreateOrEditInput('custom-title', 'Custom Field Title') await createOrEditPage.clickOnCreateOrEditButton('custom-save') await page.waitForTimeout(testData.timeouts.action) }) test('Viewing created item. Verify item details', async ({ page }) => { qase.id(2547) await mainPage.openElementDetails() await detailsPage.verifyTitle('Custom Field Title') }) test('Add via Favorite icon', async ({ page }) => { qase.id(2250) await sideMenuPage.selectSideBarCategory('all') await mainPage.verifyElementTitle('Custom Field Title') await mainPage.openElementDetails() await detailsPage.clickFavoriteButton() await sideMenuPage.verifySideBarFavoritesFolder('1 items') }) test('Remove via Favorite icon', async ({ page }) => { qase.id(2251) await mainPage.openElementDetails() await detailsPage.clickFavoriteButton() }) test('Add via More options', async ({ page }) => { qase.id(2252) await mainPage.openElementDetails() await detailsPage.openItemBarThreeDotsDropdownMenu() await detailsPage.clickMarkAsFavoriteButton() await sideMenuPage.verifySideBarFavoritesFolder('1 items') }) test('Remove via More options', async ({ page }) => { qase.id(2253) await mainPage.openElementDetails() await detailsPage.openItemBarThreeDotsDropdownMenu() await detailsPage.clickRemoveFromFavoritesButton() await sideMenuPage.verifySideBarFavoritesFolder('0 items') }) test('Add Custom Note', async ({ page }) => { qase.id(2254) await mainPage.verifyElementTitle('Custom Field Title') await mainPage.openElementDetails() await detailsPage.editElement() await expect(createOrEditPage.customNoteInput).toHaveCount(1) await createOrEditPage.fillCustomNoteInput() await createOrEditPage.clickOnCreateOrEditButton('custom-save') await page.waitForTimeout(testData.timeouts.action) await mainPage.clickDetailsCloseButton() }) test('Delete Note field', async ({ page }) => { qase.id(2255) await mainPage.verifyElementTitle('Custom Field Title') await mainPage.openElementDetails() await detailsPage.editElement() await expect(createOrEditPage.customNoteInput_first).toHaveCount(1) await createOrEditPage.deleteCustomNote() await expect(createOrEditPage.customNoteInput_first).toHaveCount(1) await createOrEditPage.clickOnCreateOrEditButton('custom-save') await page.waitForTimeout(testData.timeouts.action) await mainPage.clickDetailsCloseButton() }) test('Close via Cross icon', async ({ page }) => { qase.id(2256) await mainPage.verifyElementTitle('Custom Field Title') await mainPage.openElementDetails() await detailsPage.editElement() await detailsPage.clickElementItemCloseButton() await mainPage.verifyElementTitle('Custom Field Title') }) test('View uploaded file in Edit mode', async ({ page }) => { qase.id(2257) await mainPage.verifyElementTitle('Custom Field Title') await mainPage.openElementDetails() await detailsPage.editElement() await createOrEditPage.clickOnAttachment() await createOrEditPage.uploadFile() await createOrEditPage.verifyUploadedFileIsVisible() await createOrEditPage.clickOnUploadedFile() await createOrEditPage.clickOnCreateOrEditButton('custom-save') await page.waitForTimeout(testData.timeouts.action) await detailsPage.verifyUploadedFileIsVisible() await detailsPage.clickOnUploadedFile() await detailsPage.verifyUploadedImageIsVisible() await createOrEditPage.clickElementItemCloseButton() await mainPage.clickDetailsCloseButton() }) // test('Empty fields not displayed in view mode', async ({ page }) => { // qase.id(2259); // await mainPage.verifyElementTitle('Custom Field Title') // await mainPage.openElementDetails() // await detailsPage.editElement() // await createOrEditPage.fillCreateOrEditTextArea('note', '') // await createOrEditPage.clickOnCreateOrEditButton('save') // await mainPage.openElementDetails() // await detailsPage.verifyItemDetailsValueIsNotVisible('Add comment') // await mainPage.clickDetailsCloseButton() // }) }) ================================================ FILE: e2e/specs/07-CustomField/editingDeleteCustomFieldItem.test.js ================================================ import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Editing/Deleting Custom Item', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('custom') await utilities.deleteAllElements() await mainPage.clickAddItem('custom') await createOrEditPage.fillCreateOrEditInput('custom-title', 'Custom Field Title') await createOrEditPage.fillCustomNoteInput() await createOrEditPage.clickOnCreateOrEditButton('custom-save') await page.waitForTimeout(testData.timeouts.action) }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async () => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Verify that edited "Custom" item fields are saved correctly', async () => { qase.id(2572) await mainPage.openElementDetails() await detailsPage.editElement() await createOrEditPage.fillCreateOrEditInput( 'custom-title', 'EDITED Custom Field Title' ) await createOrEditPage.fillCustomNoteInput() await page.waitForTimeout(testData.timeouts.action) await createOrEditPage.clickOnCreateOrEditButton('custom-save') await page.waitForTimeout(testData.timeouts.action) await mainPage.openElementDetails() await page.waitForTimeout(testData.timeouts.action) await mainPage.verifyElementTitle('EDITED Custom Field Title') await detailsPage.verifyItemDetailsValue('Other Field', 'Custom Note') // Verify Note }) test('Verify that custom "Note" fields are not saved in the edited "Custom" item', async () => { qase.id(2573) await detailsPage.editElement() await createOrEditPage.deleteCustomNote() await createOrEditPage.clickOnCreateOrEditButton('custom-save') await detailsPage.verifyItemDetailsValueIsNotVisible('Other Field') }) test('Verify that the "Custom Field" item is removed after deletion', async () => { qase.id(2574) await utilities.deleteAllElements() await mainPage.verifyElementIsNotVisible() }) test('Verify that the empty collection view is displayed on the Home screen after deleting the last item', async () => { qase.id(2575) await sideMenuPage.selectSideBarCategory('all') await expect(mainPage.emptyCollectionView).toBeVisible() }) }) ================================================ FILE: e2e/specs/08-MultipleSelection/multipleSelection.test.js ================================================ import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Multiple Selection', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) sideMenuPage = new SideMenuPage(root) utilities = new Utilities(root) mainPage = new MainPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('all') await utilities.deleteAllElements() await page.waitForTimeout(testData.timeouts.action) }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async () => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Verify that selected items in one tab are deleted on "Delete" click', async ({ page }) => { qase.id(2580) await sideMenuPage.selectSideBarCategory('login') await mainPage.clickAddItem('login') await createOrEditPage.fillCreateOrEditInput('title', 'AAA') await createOrEditPage.clickOnCreateOrEditButton('save') await page.waitForTimeout(testData.timeouts.action) await sideMenuPage.selectSideBarCategory('all') await mainPage.clickMultipleSelectiontButton() await mainPage.clickElementByPosition(0, 'AAA') await mainPage.clickMultipleSelectDeletetButton() await mainPage.clickYesButton() }) test('Verify that selected items across tabs are deleted on "Delete" click', async ({ page }) => { qase.id(2581) await sideMenuPage.selectSideBarCategory('login') await mainPage.clickAddItem('login') await createOrEditPage.fillCreateOrEditInput('title', 'AAA') await createOrEditPage.clickOnCreateOrEditButton('save') await page.waitForTimeout(testData.timeouts.action) await sideMenuPage.selectSideBarCategory('identity') await mainPage.clickAddItem('identity') await createOrEditPage.fillCreateOrEditInput('identity-title', 'BBB') await createOrEditPage.clickOnCreateOrEditButton('identity-save') await page.waitForTimeout(testData.timeouts.action) await sideMenuPage.selectSideBarCategory('login') await mainPage.clickMultipleSelectiontButton() await mainPage.clickElementByPosition(0, 'AAA') await mainPage.clickMultipleSelectDeletetButton() await mainPage.clickYesButton() await page.waitForTimeout(testData.timeouts.action) await sideMenuPage.selectSideBarCategory('identity') await mainPage.clickMultipleSelectiontButton() await mainPage.clickElementByPosition(0, 'BBB') await mainPage.clickMultipleSelectDeletetButton() await mainPage.clickYesButton() await page.waitForTimeout(testData.timeouts.action) }) test('Verify that "Multiple Selection" button is hidden when no items exist', async ({ page }) => { qase.id(2582) await sideMenuPage.selectSideBarCategory('all') await mainPage.verifyEmptyCollection() }) test('Verify that "Multiple Selection" mode can be canceled', async ({ page }) => { qase.id(2583) await sideMenuPage.selectSideBarCategory('all') await utilities.deleteAllElements() await page.waitForTimeout(testData.timeouts.action) await sideMenuPage.selectSideBarCategory('login') await mainPage.clickAddItem('login') await createOrEditPage.fillCreateOrEditInput('title', 'AAA') await createOrEditPage.clickOnCreateOrEditButton('save') await page.waitForTimeout(testData.timeouts.action) await mainPage.clickMultipleSelectiontButton() await page.waitForTimeout(testData.timeouts.action) await mainPage.clickElementByPosition(0, 'AAA') await mainPage.clickMultipleSelectiontButton() }) test('Verify that "Delete" button is enabled only when items are selected', async ({ page }) => { qase.id(2584) await sideMenuPage.selectSideBarCategory('login') await mainPage.clickMultipleSelectiontButton() await page.waitForTimeout(testData.timeouts.action) await mainPage.clickElementByPosition(0, 'AAA') await mainPage.verifyMultipleSelectDeleteButtonIsEnabled() await page.waitForTimeout(testData.timeouts.action) await mainPage.clickMultipleSelectiontButton() await page.waitForTimeout(testData.timeouts.action) }) test('Verify that multiple items can be added to a folder simultaneously', async ({ page }) => { qase.id(2585) await sideMenuPage.selectSideBarCategory('all') await utilities.deleteAllElements() await page.waitForTimeout(testData.timeouts.action) await sideMenuPage.createFolder('Test Folder') await page.waitForTimeout(testData.timeouts.action) await sideMenuPage.selectSideBarCategory('login') await mainPage.clickAddItem('login') await createOrEditPage.fillCreateOrEditInput('title', 'AAA') await createOrEditPage.clickOnCreateOrEditButton('save') await page.waitForTimeout(testData.timeouts.action) await mainPage.clickAddItem('login') await createOrEditPage.fillCreateOrEditInput('title', 'BBB') await createOrEditPage.clickOnCreateOrEditButton('save') await page.waitForTimeout(testData.timeouts.action) await mainPage.clickMultipleSelectiontButton() await page.waitForTimeout(testData.timeouts.action) await mainPage.clickElementByPosition(0, 'BBB') await mainPage.clickElementByPosition(1, 'AAA') await page.waitForTimeout(testData.timeouts.action) await mainPage.clickMultipleSelectMoveButon() await page.waitForTimeout(testData.timeouts.action) await mainPage.clickMoveFolderChip('Test Folder') await mainPage.clickMoveFolderSubmit() await page.waitForTimeout(testData.timeouts.action) await sideMenuPage.selectSideBarCategory('all') await mainPage.verifyElementFolderName('Test Folder') await sideMenuPage.deleteMultipleItemsFolder('Test Folder') await page.waitForTimeout(testData.timeouts.action) await sideMenuPage.clickDeleteFolderButton() }) }) ================================================ FILE: e2e/specs/09-SortingAndFiltering/sorting.test.js ================================================ import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Sorting test', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) sideMenuPage = new SideMenuPage(root) utilities = new Utilities(root) mainPage = new MainPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) await sideMenuPage.selectSideBarCategory('all') await utilities.deleteAllElements() }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) }) test.afterAll(async () => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Verify that items are sorted by most recently modified when "Recent" is selected', async () => { qase.id(2592) await sideMenuPage.selectSideBarCategory('login') await mainPage.clickAddItem('login') await createOrEditPage.fillCreateOrEditInput('title', 'AAA') await createOrEditPage.clickOnCreateOrEditButton('save') await page.waitForTimeout(testData.timeouts.action) await sideMenuPage.selectSideBarCategory('identity') await mainPage.clickAddItem('identity') await createOrEditPage.fillCreateOrEditInput('identity-title', 'BBB') await createOrEditPage.clickOnCreateOrEditButton('identity-save') await page.waitForTimeout(testData.timeouts.action) await sideMenuPage.selectSideBarCategory('identity') await mainPage.clickAddItem('identity') await createOrEditPage.fillCreateOrEditInput('identity-title', 'CCC') await createOrEditPage.clickOnCreateOrEditButton('identity-save') await page.waitForTimeout(testData.timeouts.action) await page.waitForTimeout(testData.timeouts.action) await sideMenuPage.selectSideBarCategory('all') await page.waitForTimeout(testData.timeouts.action) await mainPage.verifyElementByPosition(0, 'CCC') await mainPage.verifyElementByPosition(1, 'BBB') await mainPage.verifyElementByPosition(2, 'AAA') }) test('Verify that items are sorted from newest to oldest when "Newest to oldest" is selected', async ({ page }) => { qase.id(2593) await mainPage.clickSortButton() await mainPage.selectSortOption('last_updated_newest') await mainPage.verifyElementByPosition('0', 'CCC') await mainPage.verifyElementByPosition('1', 'BBB') await mainPage.verifyElementByPosition('2', 'AAA') }) test('Verify that items are sorted from oldest to newest when "Oldest to newest" is selected', async ({ page }) => { qase.id(2594) await mainPage.clickSortButton() await mainPage.selectSortOption('last_updated_oldest') await mainPage.verifyElementByPosition('0', 'AAA') await mainPage.verifyElementByPosition('1', 'BBB') await mainPage.verifyElementByPosition('2', 'CCC') await page.waitForTimeout(testData.timeouts.action) }) test('Verify that favorite items are displayed first on the Home screen', async ({ page }) => { qase.id(2595) await sideMenuPage.selectSideBarCategory('login') await mainPage.openElementDetails() await detailsPage.clickFavoriteButton() await sideMenuPage.selectSideBarCategory('identity') await mainPage.openElementDetails() await detailsPage.clickFavoriteButton() await sideMenuPage.selectSideBarCategory('all') await page.waitForTimeout(testData.timeouts.action) await mainPage.verifyElementByPosition('0', 'AAA') await mainPage.verifyElementByPosition('1', 'BBB') await mainPage.verifyElementByPosition('2', 'CCC') }) test('Verify that only favorite items are displayed when "Favorite" is selected', async ({ page }) => { qase.id(2596) await sideMenuPage.openSideBarFolder('Favorites') await page.waitForTimeout(testData.timeouts.action) await mainPage.verifyElementByPosition('0', 'AAA') await mainPage.verifyElementByPosition('1', 'BBB') await sideMenuPage.selectSideBarCategory('all') }) }) ================================================ FILE: e2e/specs/10-Settings/settings.test.js ================================================ import { qase } from 'playwright-qase-reporter' import { LoginPage, MainPage, SideMenuPage, CreateOrEditPage, Utilities, DetailsPage, SettingsPage } from '../../components/index.js' import { test, expect } from '../../fixtures/app.runner.js' import testData from '../../fixtures/test-data.js' test.describe('Settings test', () => { test.describe.configure({ mode: 'serial' }) let loginPage, createOrEditPage, sideMenuPage, mainPage, utilities, detailsPage, page, settingsPage test.beforeAll(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) sideMenuPage = new SideMenuPage(root) utilities = new Utilities(root) mainPage = new MainPage(root) await loginPage.loginToApplication(testData.credentials.validPassword) }) test.beforeEach(async ({ app }) => { page = await app.getPage() const root = page.locator('body') loginPage = new LoginPage(root) mainPage = new MainPage(root) sideMenuPage = new SideMenuPage(root) createOrEditPage = new CreateOrEditPage(root) utilities = new Utilities(root) detailsPage = new DetailsPage(root) settingsPage = new SettingsPage(root) }) test.afterAll(async () => { await utilities.deleteAllElements() await sideMenuPage.clickSidebarExitButton() }) test('Verify Security Settings', async ({ page }) => { qase.id(2605) await sideMenuPage.clickSidebarSettingsButton() await settingsPage.verifySettingsDropdownSectionIsVisible('security') await settingsPage.verifySettingsDropdownNavigationIsVisible('app-preferences') await settingsPage.verifySettingsDropdownNavigationIsVisible('master-password') await settingsPage.clickPearPassFunctionDropdown('auto-lock-select') await page.waitForTimeout(testData.timeouts.action) await settingsPage.getPearPassFunctionDropdownOption('seconds_30') await settingsPage.getPearPassFunctionDropdownOption('minutes_1') await settingsPage.getPearPassFunctionDropdownOption('minutes_5') await settingsPage.getPearPassFunctionDropdownOption('minutes_15') await settingsPage.getPearPassFunctionDropdownOption('minutes_30') await settingsPage.getPearPassFunctionDropdownOption('hours_1') await settingsPage.getPearPassFunctionDropdownOption('hours_4') await settingsPage.getPearPassFunctionDropdownOption('never') await settingsPage.getPearPassFunctionDropdown('auto-lock-select').click({ force: true }) await page.waitForTimeout(testData.timeouts.action) }) test('Verify Syncing Settings', async ({ page }) => { qase.id(2606) await settingsPage.verifySettingsDropdownSectionIsVisible('syncing') await settingsPage.verifySettingsDropdownNavigationIsVisible('blind-peering') await settingsPage.verifySettingsDropdownNavigationIsVisible('your-devices') }) test('Verify Vault Settings', async ({ page }) => { qase.id(2607) await settingsPage.verifySettingsDropdownSectionIsVisible('vault') await settingsPage.verifySettingsDropdownNavigationIsVisible('your-vaults') await settingsPage.verifySettingsDropdownNavigationIsVisible('import-items') await settingsPage.verifySettingsDropdownNavigationIsVisible('export-items') }) test('Verify Appearance Settings', async ({ page }) => { qase.id(2608) await settingsPage.verifySettingsDropdownSectionIsVisible('appearance') await settingsPage.verifySettingsDropdownNavigationIsVisible('language') }) test('Verify About Settings', async ({ page }) => { qase.id(2609) await settingsPage.verifySettingsDropdownSectionIsVisible('about') await settingsPage.verifySettingsDropdownNavigationIsVisible('report-a-problem') await settingsPage.verifySettingsDropdownNavigationIsVisible('app-version') await settingsPage.clickBackSettingsButton() }) }) ================================================ FILE: electron/clipboardCleanup.cjs ================================================ const { spawn } = require('child_process') const crypto = require('crypto') const fs = require('fs') const path = require('path') const DEFAULT_CLIPBOARD_CLEAR_DELAY_MS = 30000 const CLIPBOARD_CLEANUP_STATE_FILE = 'pearpass-clipboard-cleanup-current.token' function getClipboardCleanupStatePath(app) { return path.join(app.getPath('temp'), CLIPBOARD_CLEANUP_STATE_FILE) } function removeFileIfExists(filePath) { try { fs.unlinkSync(filePath) } catch (err) { if (err && err.code !== 'ENOENT') throw err } } function removeClipboardCleanupTokenIfCurrent(statePath, token) { try { const currentToken = fs.readFileSync(statePath, 'utf8') if (currentToken === token) { removeFileIfExists(statePath) } } catch (err) { if (err && err.code !== 'ENOENT') throw err } } function spawnDetachedClipboardHelper(secretPath, token, statePath, delayMs) { const helperPath = path.join(__dirname, 'clipboardCleanupHelper.cjs') if (!fs.existsSync(helperPath)) { throw new Error(`Clipboard cleanup helper not found: ${helperPath}`) } const child = spawn( process.execPath, [helperPath, secretPath, token, statePath, String(delayMs)], { detached: true, env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, stdio: 'inherit', windowsHide: true } ) child.unref() } function spawnDetachedWindowsClipboardHelper( secretPath, token, statePath, delayMs ) { const scriptPath = path.join(__dirname, 'clipboardCleanup.windows.ps1') if (!fs.existsSync(scriptPath)) { throw new Error(`Windows clipboard cleanup script not found: ${scriptPath}`) } const child = spawn( 'cmd.exe', [ '/c', 'start', '""', '/min', 'powershell.exe', '-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, '-SecretPath', secretPath, '-StatePath', statePath, '-Token', token, '-DelayMs', String(delayMs) ], { detached: true, stdio: 'inherit', windowsHide: true } ) child.unref() } function scheduleClipboardCleanup({ app, clipboard, logger, isWindows, text, delayMs }) { const finalDelayMs = Number.isFinite(delayMs) && delayMs > 0 ? delayMs : DEFAULT_CLIPBOARD_CLEAR_DELAY_MS const textToMatch = typeof text === 'string' ? text : clipboard.readText() if (typeof textToMatch !== 'string' || textToMatch.length === 0) { return false } const token = crypto.randomUUID() const statePath = getClipboardCleanupStatePath(app) const secretPath = path.join( app.getPath('temp'), `pearpass-clipboard-secret-${token}.txt` ) try { fs.writeFileSync(secretPath, textToMatch, { encoding: 'utf8', mode: 0o600 }) fs.writeFileSync(statePath, token, { encoding: 'utf8', mode: 0o600 }) if (isWindows) { spawnDetachedWindowsClipboardHelper( secretPath, token, statePath, finalDelayMs ) } else { spawnDetachedClipboardHelper(secretPath, token, statePath, finalDelayMs) } return true } catch (err) { try { removeFileIfExists(secretPath) removeClipboardCleanupTokenIfCurrent(statePath, token) } catch (_) {} logger.warn( 'MAIN', 'Failed to schedule detached clipboard cleanup:', err && err.message ? err.message : err ) return false } } module.exports = { scheduleClipboardCleanup } ================================================ FILE: electron/clipboardCleanup.test.js ================================================ /* eslint-env jest */ jest.mock('fs', () => ({ existsSync: jest.fn(() => true), readFileSync: jest.fn(), unlinkSync: jest.fn(), writeFileSync: jest.fn() })) jest.mock('crypto', () => ({ randomUUID: jest.fn(() => 'token-1') })) jest.mock('child_process', () => ({ spawn: jest.fn(() => ({ unref: jest.fn() })) })) describe('clipboardCleanup', () => { beforeEach(() => { jest.clearAllMocks() }) it('uses the Windows script', () => { const path = require('path') const fs = require('fs') const { spawn } = require('child_process') const { scheduleClipboardCleanup } = require('./clipboardCleanup.cjs') const app = { getPath: jest.fn((name) => name === 'temp' ? 'C:\\Temp' : `/unknown/${name}` ) } const clipboard = { readText: jest.fn(() => 'secret') } const logger = { warn: jest.fn() } const result = scheduleClipboardCleanup({ app, clipboard, logger, isWindows: true, text: 'secret', delayMs: 30000 }) expect(result).toBe(true) expect(fs.writeFileSync).toHaveBeenNthCalledWith( 1, path.join('C:\\Temp', 'pearpass-clipboard-secret-token-1.txt'), 'secret', { encoding: 'utf8', mode: 0o600 } ) expect(fs.writeFileSync).toHaveBeenNthCalledWith( 2, path.join('C:\\Temp', 'pearpass-clipboard-cleanup-current.token'), 'token-1', { encoding: 'utf8', mode: 0o600 } ) expect(spawn).toHaveBeenCalledWith( 'cmd.exe', expect.arrayContaining([ '/c', 'start', '""', '/min', 'powershell.exe', '-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', path.join(process.cwd(), 'electron', 'clipboardCleanup.windows.ps1') ]), expect.objectContaining({ detached: true, stdio: 'inherit', windowsHide: true }) ) }) }) ================================================ FILE: electron/clipboardCleanup.windows.ps1 ================================================ param( [string]$SecretPath, [string]$StatePath, [string]$Token, [int]$DelayMs ) try { $expected = '' try { if (-not (Test-Path -LiteralPath $SecretPath)) { exit 0 } $expected = [System.IO.File]::ReadAllText($SecretPath, [System.Text.Encoding]::UTF8) } finally { Remove-Item -LiteralPath $SecretPath -Force -ErrorAction SilentlyContinue } Start-Sleep -Milliseconds $DelayMs $currentToken = '' try { if (Test-Path -LiteralPath $StatePath) { $currentToken = [System.IO.File]::ReadAllText($StatePath, [System.Text.Encoding]::UTF8) } } catch { $currentToken = '' } if (-not [string]::Equals($currentToken, $Token, [System.StringComparison]::Ordinal)) { exit 0 } $clipboardText = '' try { $clipboardText = Get-Clipboard -Raw } catch { $clipboardText = '' } if ([string]::Equals($clipboardText, $expected, [System.StringComparison]::Ordinal)) { Set-Clipboard -Value '' } $latestToken = '' try { if (Test-Path -LiteralPath $StatePath) { $latestToken = [System.IO.File]::ReadAllText($StatePath, [System.Text.Encoding]::UTF8) } } catch { $latestToken = '' } if ([string]::Equals($latestToken, $Token, [System.StringComparison]::Ordinal)) { Remove-Item -LiteralPath $StatePath -Force -ErrorAction SilentlyContinue } } catch { exit 1 } ================================================ FILE: electron/clipboardCleanupHelper.cjs ================================================ const { spawnSync } = require('child_process') const fs = require('fs') const linuxWaylandClipboard = require('./linuxWaylandClipboard.cjs') const linuxX11Clipboard = require('./linuxX11Clipboard.cjs') function removeFileIfExists(filePath) { try { fs.unlinkSync(filePath) } catch (err) { if (err && err.code !== 'ENOENT') throw err } } function readSecretFromFile(secretPath) { try { return fs.readFileSync(secretPath, 'utf8') } finally { removeFileIfExists(secretPath) } } function readCurrentToken(statePath) { try { return fs.readFileSync(statePath, 'utf8') } catch (err) { if (err && err.code === 'ENOENT') return '' throw err } } function clearCurrentTokenIfMatches(statePath, token) { if (readCurrentToken(statePath) === token) { removeFileIfExists(statePath) } } function describeLinuxSession() { const waylandDisplay = process.env.WAYLAND_DISPLAY || '' const sessionType = process.env.XDG_SESSION_TYPE || '' const display = process.env.DISPLAY || '' return `WAYLAND_DISPLAY=${waylandDisplay || '(unset)'} XDG_SESSION_TYPE=${sessionType || '(unset)'} DISPLAY=${display || '(unset)'}` } function logLinuxClipboardSkip(sessionLabel) { process.stderr.write( `PearPass clipboard cleanup skipped: ${sessionLabel} clipboard command unavailable or failed. (${describeLinuxSession()})\n` ) } function sleep(delayMs) { return new Promise((resolve) => setTimeout(resolve, delayMs)) } function runClipboardCommand(command, args, input) { return spawnSync(command, args, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'pipe'] }) } function readClipboard() { if (process.platform === 'darwin') { const result = runClipboardCommand('/usr/bin/pbpaste', [], undefined) if (result.error || result.status !== 0) { throw result.error || new Error('pbpaste failed') } return result.stdout || '' } if (process.platform === 'linux') { const isWayland = linuxWaylandClipboard.isWaylandSession() const linuxClipboard = isWayland ? linuxWaylandClipboard : linuxX11Clipboard const sessionLabel = isWayland ? 'Wayland' : 'X11' const result = linuxClipboard.readClipboard() if (typeof result === 'string') return result logLinuxClipboardSkip(sessionLabel) return null } throw new Error(`Unsupported platform: ${process.platform}`) } function clearClipboard() { if (process.platform === 'darwin') { const result = runClipboardCommand('/usr/bin/pbcopy', [], '') if (result.error || result.status !== 0) { throw result.error || new Error('pbcopy failed') } return } if (process.platform === 'linux') { const isWayland = linuxWaylandClipboard.isWaylandSession() const linuxClipboard = isWayland ? linuxWaylandClipboard : linuxX11Clipboard const sessionLabel = isWayland ? 'Wayland' : 'X11' if (linuxClipboard.clearClipboard()) return logLinuxClipboardSkip(sessionLabel) return } throw new Error(`Unsupported platform: ${process.platform}`) } async function runClipboardCleanup({ secretPath, token, statePath, delayMs = 30000 }) { const expectedText = readSecretFromFile(secretPath) await sleep(delayMs) if (readCurrentToken(statePath) !== token) { return false } try { const clipboardText = readClipboard() if (typeof clipboardText !== 'string') { return false } if (clipboardText === expectedText) { clearClipboard() } return true } finally { clearCurrentTokenIfMatches(statePath, token) } } async function main(argv = process.argv) { const [, , secretPath, token, statePath, delayMsArg] = argv const delayMs = Number.parseInt(delayMsArg, 10) if (!secretPath || !token || !statePath) { process.exitCode = 1 return } try { await runClipboardCleanup({ secretPath, token, statePath, delayMs: Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 30000 }) } catch (err) { process.stderr.write( `PearPass clipboard cleanup failed: ${err && err.message ? err.message : err}\n` ) process.exitCode = 1 } } if (require.main === module) { main() } module.exports = { clearClipboard, clearCurrentTokenIfMatches, logLinuxClipboardSkip, main, readClipboard, readCurrentToken, readSecretFromFile, runClipboardCleanup, sleep } ================================================ FILE: electron/clipboardCleanupHelper.test.js ================================================ /* eslint-env jest */ jest.mock('fs', () => ({ readFileSync: jest.fn(), unlinkSync: jest.fn(), existsSync: jest.fn(() => true), promises: { mkdir: jest.fn() } })) jest.mock('child_process', () => ({ spawnSync: jest.fn(() => ({ status: 0, stdout: '', error: null })) })) jest.mock('./linuxWaylandClipboard.cjs', () => ({ isWaylandSession: jest.fn(() => false), readClipboard: jest.fn(() => null), clearClipboard: jest.fn(() => false) })) jest.mock('./linuxX11Clipboard.cjs', () => ({ readClipboard: jest.fn(() => null), clearClipboard: jest.fn(() => false) })) const originalPlatform = process.platform const setPlatform = (platform) => { Object.defineProperty(process, 'platform', { configurable: true, value: platform }) } const loadHelper = () => { jest.resetModules() return require('./clipboardCleanupHelper.cjs') } const getFs = () => require('fs') const getSpawnSync = () => require('child_process').spawnSync describe('clipboardCleanupHelper', () => { beforeEach(() => { jest.clearAllMocks() jest.useFakeTimers() }) afterEach(() => { jest.useRealTimers() setPlatform(originalPlatform) }) it('clears the clipboard only for the latest matching token', async () => { setPlatform('darwin') const helper = loadHelper() const fs = getFs() const spawnSync = getSpawnSync() fs.readFileSync .mockReturnValueOnce('secret') .mockReturnValueOnce('token-1') .mockReturnValueOnce('token-1') spawnSync .mockReturnValueOnce({ status: 0, stdout: 'secret' }) .mockReturnValueOnce({ status: 0, stdout: '' }) const cleanupPromise = helper.runClipboardCleanup({ secretPath: '/tmp/secret.txt', token: 'token-1', statePath: '/tmp/state.token', delayMs: 30000 }) await jest.advanceTimersByTimeAsync(30000) await expect(cleanupPromise).resolves.toBe(true) expect(spawnSync).toHaveBeenNthCalledWith( 1, '/usr/bin/pbpaste', [], expect.objectContaining({ input: undefined }) ) expect(spawnSync).toHaveBeenNthCalledWith( 2, '/usr/bin/pbcopy', [], expect.objectContaining({ input: '' }) ) expect(fs.unlinkSync).toHaveBeenCalledWith('/tmp/secret.txt') expect(fs.unlinkSync).toHaveBeenCalledWith('/tmp/state.token') }) it('does not clear when a newer clipboard token replaced it', async () => { setPlatform('darwin') const helper = loadHelper() const fs = getFs() const spawnSync = getSpawnSync() fs.readFileSync.mockReturnValueOnce('secret').mockReturnValueOnce('token-2') const cleanupPromise = helper.runClipboardCleanup({ secretPath: '/tmp/secret.txt', token: 'token-1', statePath: '/tmp/state.token', delayMs: 30000 }) await jest.advanceTimersByTimeAsync(30000) await expect(cleanupPromise).resolves.toBe(false) expect(spawnSync).not.toHaveBeenCalled() expect(fs.unlinkSync).toHaveBeenCalledWith('/tmp/secret.txt') expect(fs.unlinkSync).not.toHaveBeenCalledWith('/tmp/state.token') }) it('skips Linux cleanup gracefully when no clipboard tool is available', async () => { setPlatform('linux') const helper = loadHelper() const fs = getFs() const linuxX11Clipboard = require('./linuxX11Clipboard.cjs') const stderrSpy = jest .spyOn(process.stderr, 'write') .mockImplementation(() => true) fs.readFileSync .mockReturnValueOnce('secret') .mockReturnValueOnce('token-1') .mockReturnValueOnce('token-1') const cleanupPromise = helper.runClipboardCleanup({ secretPath: '/tmp/secret.txt', token: 'token-1', statePath: '/tmp/state.token', delayMs: 30000 }) await jest.advanceTimersByTimeAsync(30000) await expect(cleanupPromise).resolves.toBe(false) expect(linuxX11Clipboard.readClipboard).toHaveBeenCalled() expect(stderrSpy).toHaveBeenCalledWith( expect.stringContaining('PearPass clipboard cleanup skipped:') ) expect(fs.unlinkSync).toHaveBeenCalledWith('/tmp/secret.txt') expect(fs.unlinkSync).toHaveBeenCalledWith('/tmp/state.token') stderrSpy.mockRestore() }) }) ================================================ FILE: electron/flatpak-paths.cjs ================================================ const fs = require('fs') const path = require('path') function isFlatpakRuntime(options = {}) { const env = options.env || process.env const existsSync = options.existsSync || fs.existsSync const flatpakInfoPath = options.flatpakInfoPath || '/.flatpak-info' return Boolean(env.FLATPAK_ID) || existsSync(flatpakInfoPath) } function isSnapRuntime(options = {}) { const env = options.env || process.env return Boolean(env.SNAP_NAME) } function getSnapRealHome(options = {}) { const env = options.env || process.env // $HOME is remapped to ~/snap// inside the sandbox; snapd // exposes the real home as SNAP_REAL_HOME. if (!isSnapRuntime(options)) return env.HOME || '' return env.SNAP_REAL_HOME || env.HOME || '' } function getHostHome(options = {}) { const env = options.env || process.env if (!isFlatpakRuntime(options)) return env.HOME || '' // Inside flatpak $HOME is remapped to the per-app sandbox home. // With --filesystem=home the real host home is mounted at /home/$USER // and is where host-side browsers (e.g. Chrome) read NativeMessagingHosts. const user = env.USER || env.USERNAME return user ? path.join('/home', user) : env.HOME || '' } function getFlatpakCompatRoots(options = {}) { const env = options.env || process.env const homeDir = env.HOME if (!homeDir) return null return { config: path.join(homeDir, '.config'), data: path.join(homeDir, '.config', 'pearpass-flatpak-data'), cache: path.join(homeDir, '.config', 'pearpass-flatpak-cache') } } function mapFlatpakPathToSandbox(targetPath, options = {}) { if (!targetPath || typeof targetPath !== 'string') return targetPath const env = options.env || process.env const compatRoots = getFlatpakCompatRoots(options) if (!compatRoots) return targetPath const mappings = [ [env.XDG_CONFIG_HOME, compatRoots.config], [env.XDG_DATA_HOME, compatRoots.data], [env.XDG_CACHE_HOME, compatRoots.cache] ].filter(([prefix]) => typeof prefix === 'string' && prefix.length > 0) for (const [hostPrefix, sandboxPrefix] of mappings) { if (targetPath === hostPrefix) return sandboxPrefix if (targetPath.startsWith(hostPrefix + path.sep)) { return path.join(sandboxPrefix, path.relative(hostPrefix, targetPath)) } } return targetPath } function getSandboxSafePath(targetPath, options = {}) { if (!isFlatpakRuntime(options)) return targetPath return mapFlatpakPathToSandbox(targetPath, options) } module.exports = { getFlatpakCompatRoots, getHostHome, getSandboxSafePath, getSnapRealHome, isFlatpakRuntime, isSnapRuntime, mapFlatpakPathToSandbox } ================================================ FILE: electron/flatpak-paths.test.js ================================================ const { getFlatpakCompatRoots, getSandboxSafePath, getSnapRealHome, isFlatpakRuntime, isSnapRuntime, mapFlatpakPathToSandbox } = require('./flatpak-paths.cjs') describe('flatpak path helpers', () => { const env = { HOME: '/home/alvaro', XDG_CONFIG_HOME: '/home/alvaro/.var/app/com.pears.pass/config', XDG_DATA_HOME: '/home/alvaro/.var/app/com.pears.pass/data', XDG_CACHE_HOME: '/home/alvaro/.var/app/com.pears.pass/cache' } it('detects flatpak via FLATPAK_ID', () => { expect(isFlatpakRuntime({ env: { FLATPAK_ID: 'com.pears.pass' } })).toBe( true ) }) it('derives flatpak compatibility roots under the user config directory', () => { expect(getFlatpakCompatRoots({ env })).toEqual({ config: '/home/alvaro/.config', data: '/home/alvaro/.config/pearpass-flatpak-data', cache: '/home/alvaro/.config/pearpass-flatpak-cache' }) }) it('maps host-side flatpak config paths into sandbox-safe paths', () => { expect( mapFlatpakPathToSandbox( '/home/alvaro/.var/app/com.pears.pass/config/PearPass', { env } ) ).toBe('/home/alvaro/.config/PearPass') }) it('detects snap via SNAP_NAME', () => { expect(isSnapRuntime({ env: { SNAP_NAME: 'pearpass' } })).toBe(true) expect(isSnapRuntime({ env: {} })).toBe(false) }) it('returns SNAP_REAL_HOME inside snap, HOME otherwise', () => { expect( getSnapRealHome({ env: { SNAP_NAME: 'pearpass', SNAP_REAL_HOME: '/home/alvaro', HOME: '/home/alvaro/snap/pearpass/current' } }) ).toBe('/home/alvaro') expect(getSnapRealHome({ env: { HOME: '/home/alvaro' } })).toBe( '/home/alvaro' ) expect(getSnapRealHome({ env: {} })).toBe('') }) it('returns sandbox-safe path only when running inside flatpak', () => { const targetPath = '/home/alvaro/.var/app/com.pears.pass/data/PearPass' expect( getSandboxSafePath(targetPath, { env: { ...env, FLATPAK_ID: 'com.pears.pass' } }) ).toBe('/home/alvaro/.config/pearpass-flatpak-data/PearPass') expect( getSandboxSafePath(targetPath, { env, existsSync: () => false }) ).toBe(targetPath) }) }) ================================================ FILE: electron/linuxWaylandClipboard.cjs ================================================ const { spawnSync } = require('child_process') const { readClipboardWithFallback, clearClipboardWithFallback } = require('./linuxWaylandClipboardFallback.cjs') function isWaylandSession() { return ( Boolean(process.env.WAYLAND_DISPLAY) || process.env.XDG_SESSION_TYPE === 'wayland' ) } function runCommand(command, args, input) { return spawnSync(command, args, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'pipe'] }) } function readClipboard() { const result = runCommand('wl-paste', ['--no-newline'], undefined) if (result.error) { return readClipboardWithFallback() } if (result.status === 0) return result.stdout || '' if (result.status === 1) return '' process.stderr.write( `[linuxWaylandClipboard] wl-paste exited with unexpected status ${result.status}: ${result.stderr}\n` ) return readClipboardWithFallback() } function clearClipboard() { const clearResult = runCommand('wl-copy', ['--clear'], undefined) if (!clearResult.error && clearResult.status === 0) return true if (clearResult.error) { return clearClipboardWithFallback() } process.stderr.write( `[linuxWaylandClipboard] wl-copy --clear failed (status ${clearResult.status}), trying empty input fallback: ${clearResult.stderr}\n` ) const emptyResult = runCommand('wl-copy', [], '') if (!emptyResult.error && emptyResult.status === 0) return true process.stderr.write( `[linuxWaylandClipboard] wl-copy empty fallback also failed (status ${emptyResult.status}): ${emptyResult.stderr}\n` ) return clearClipboardWithFallback() } module.exports = { clearClipboard, isWaylandSession, readClipboard } ================================================ FILE: electron/linuxWaylandClipboardFallback.cjs ================================================ const { spawnSync } = require('child_process') const fs = require('fs') const os = require('os') const path = require('path') const TEMP_WL_PASTE_NAME = 'pearpass-wl-paste' const TEMP_WL_COPY_NAME = 'pearpass-wl-copy' function getWlPasteBinaryArchitectureName() { return process.arch === 'arm64' ? 'wl-paste-arm64' : 'wl-paste-x86_64' } function getWlCopyBinaryArchitectureName() { return process.arch === 'arm64' ? 'wl-copy-arm64' : 'wl-copy-x86_64' } function resolveBundledBinarySourcePath(archName) { const packagedPath = path.join(__dirname, '..', '..', 'bin', archName) if (fs.existsSync(packagedPath)) return packagedPath const devPath = path.join(__dirname, '..', 'resources', 'bin', archName) if (fs.existsSync(devPath)) return devPath return null } function prepareBundledBinary(archName, tempName) { const sourcePath = resolveBundledBinarySourcePath(archName) if (!sourcePath) { process.stderr.write( `[linuxWaylandClipboardFallback] Bundled ${archName} binary not found in packaged or dev location.\n` ) return null } const destPath = path.join(os.tmpdir(), tempName) try { fs.copyFileSync(sourcePath, destPath) fs.chmodSync(destPath, 0o755) return destPath } catch (err) { process.stderr.write( `[linuxWaylandClipboardFallback] Failed to extract bundled binary ${archName}: ${err && err.message ? err.message : err}\n` ) return null } } function prepareWlPasteBinary() { return prepareBundledBinary( getWlPasteBinaryArchitectureName(), TEMP_WL_PASTE_NAME ) } function prepareWlCopyBinary() { return prepareBundledBinary( getWlCopyBinaryArchitectureName(), TEMP_WL_COPY_NAME ) } function readClipboardWithFallback() { const binaryPath = prepareWlPasteBinary() if (!binaryPath) return null const result = spawnSync(binaryPath, ['--no-newline'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) if (result.error) { process.stderr.write( `[linuxWaylandClipboardFallback] Bundled wl-paste failed to start: ${result.error.message}\n` ) return null } if (result.status === 0) return result.stdout || '' if (result.status === 1) return '' process.stderr.write( `[linuxWaylandClipboardFallback] Bundled wl-paste exited with unexpected status ${result.status}: ${result.stderr}\n` ) return null } function clearClipboardWithFallback() { const binaryPath = prepareWlCopyBinary() if (!binaryPath) return false const clearResult = spawnSync(binaryPath, ['--clear'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) if (!clearResult.error && clearResult.status === 0) return true if (clearResult.error) { process.stderr.write( `[linuxWaylandClipboardFallback] Bundled wl-copy --clear failed to start: ${clearResult.error.message}\n` ) } else { process.stderr.write( `[linuxWaylandClipboardFallback] Bundled wl-copy --clear failed (status ${clearResult.status}), trying empty input fallback: ${clearResult.stderr}\n` ) } const emptyResult = spawnSync(binaryPath, [], { encoding: 'utf8', input: '', stdio: ['pipe', 'pipe', 'pipe'] }) if (!emptyResult.error && emptyResult.status === 0) return true process.stderr.write( `[linuxWaylandClipboardFallback] Bundled wl-copy empty fallback also failed (status ${emptyResult.status}): ${emptyResult.stderr}\n` ) return false } module.exports = { readClipboardWithFallback, clearClipboardWithFallback } ================================================ FILE: electron/linuxX11Clipboard.cjs ================================================ const { spawnSync } = require('child_process') const { readClipboardWithFallback, clearClipboardWithFallback } = require('./linuxX11ClipboardFallback.cjs') function runCommand(command, args, input) { return spawnSync(command, args, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'pipe'] }) } function readClipboard() { const commands = [ ['xsel', ['--clipboard', '--output']], ['xclip', ['-selection', 'clipboard', '-o']] ] for (const [command, args] of commands) { const result = runCommand(command, args, undefined) if (!result.error && result.status === 0) { return result.stdout || '' } } return readClipboardWithFallback() } function clearClipboard() { const commands = [ ['xsel', ['--clipboard', '--input']], ['xclip', ['-selection', 'clipboard']] ] for (const [command, args] of commands) { const result = runCommand(command, args, '') if (!result.error && result.status === 0) { return true } } return clearClipboardWithFallback() } module.exports = { clearClipboard, readClipboard } ================================================ FILE: electron/linuxX11ClipboardFallback.cjs ================================================ const { spawnSync } = require('child_process') const fs = require('fs') const os = require('os') const path = require('path') const TEMP_BINARY_NAME = 'pearpass-xsel' /** * Returns the architecture-specific bundled xsel binary filename. * @returns {'xsel-arm64' | 'xsel-x86_64'} */ function getBinaryArchitectureName() { return process.arch === 'arm64' ? 'xsel-arm64' : 'xsel-x86_64' } /** * Resolves the bundled xsel binary source path purely from __dirname. * No arguments needed — this file always knows where it lives on disk. * * Packaged Electron app structure: * resources/app/electron/linuxClipboardFallback.cjs ← __dirname * resources/bin/xsel-x86_64 ← extraResources (to: "bin") * → path.join(__dirname, '..', '..', 'bin', archName) * * Dev structure: * /electron/linuxClipboardFallback.cjs ← __dirname * /resources/bin/xsel-x86_64 * → path.join(__dirname, '..', 'resources', 'bin', archName) * * @returns {string | null} Absolute path to the binary, or null if not found in either location */ function resolveBundledBinarySourcePath() { const archName = getBinaryArchitectureName() // Packaged: resources/app/electron/ → ../../bin/ const packagedPath = path.join(__dirname, '..', '..', 'bin', archName) if (fs.existsSync(packagedPath)) return packagedPath // Dev: /electron/ → ../resources/bin/ const devPath = path.join(__dirname, '..', 'resources', 'bin', archName) if (fs.existsSync(devPath)) return devPath return null } /** * Extracts the bundled xsel binary to a temp location and ensures it is * executable. Returns the path to the extracted binary, or null if the * source binary is not found in any known location. * * @returns {string | null} */ function prepareBundledBinary() { const sourcePath = resolveBundledBinarySourcePath() if (!sourcePath) { process.stderr.write( '[linuxClipboardFallback] Bundled xsel binary not found in packaged or dev location.\n' ) return null } const destPath = path.join(os.tmpdir(), TEMP_BINARY_NAME) try { fs.copyFileSync(sourcePath, destPath) fs.chmodSync(destPath, 0o755) return destPath } catch (err) { process.stderr.write( `[linuxClipboardFallback] Failed to extract bundled binary: ${err && err.message ? err.message : err}\n` ) return null } } /** * Reads the clipboard content using the bundled xsel binary. * Returns the clipboard text, or null if the fallback is unavailable or fails. * * @returns {string | null} */ function readClipboardWithFallback() { const binaryPath = prepareBundledBinary() if (!binaryPath) return null const result = spawnSync(binaryPath, ['--clipboard', '--output'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) if (!result.error && result.status === 0) { return result.stdout || '' } process.stderr.write( `[linuxClipboardFallback] Bundled xsel read failed. status=${result.status}, error=${result.error}\n` ) return null } /** * Clears the clipboard using the bundled xsel binary. * Returns true on success, false if the fallback is unavailable or fails. * * @returns {boolean} */ function clearClipboardWithFallback() { const binaryPath = prepareBundledBinary() if (!binaryPath) return false const result = spawnSync(binaryPath, ['--clipboard', '--input'], { encoding: 'utf8', input: '', stdio: ['pipe', 'pipe', 'pipe'] }) if (!result.error && result.status === 0) { return true } process.stderr.write( `[linuxClipboardFallback] Bundled xsel clear failed. status=${result.status}, error=${result.error}\n` ) return false } module.exports = { readClipboardWithFallback, clearClipboardWithFallback } ================================================ FILE: electron/main.cjs ================================================ /* eslint-disable no-unused-vars */ /* eslint-disable no-underscore-dangle */ /** * Electron main process: creates the window, starts pear-runtime (P2P OTA, bare workers, storage), * and registers secure IPC handlers so the renderer can use runtime and vault services. */ const fs = require('fs') const path = require('path') const { app, BrowserWindow, ipcMain, nativeImage, shell, clipboard } = require('electron') const PearRuntime = require('pear-runtime') const getPearRuntimeLegacyStorage = require('pear-runtime-legacy-storage') const { isLinux, isWindows, isMac } = require('which-runtime') const { scheduleClipboardCleanup } = require('./clipboardCleanup.cjs') let debugMode = false ;(async () => { try { const { DEBUG_MODE } = await import('../src/constants/appConstants.js') debugMode = DEBUG_MODE } catch { // fall back to default debugMode = false } })() const pkg = require('../package.json') const { getSandboxSafePath, isFlatpakRuntime, isSnapRuntime } = require('./flatpak-paths.cjs') const runtimeConfig = require('./runtime-config.cjs') const devicePreferences = require('../src/utils/devicePreferences.cjs') const { getLogPaths, removeLogFiles, setupLogging } = require('../src/utils/logHelper.cjs') const { logger, loggingForced, enableWorkletFileLogging } = setupLogging({ app, pkg, debugMode, getStorageDir: () => getStorageDir(), getVaultClient: () => vaultClient }) // Effective logging state. Initialized in app.whenReady (after setName, so // getStorageDir() resolves correctly). Mutable so the in-app toggle can flip // it at runtime via the vault:setLogging IPC. let loggingActive = false /** * Emit a structured startup marker to stderr. * * The main-process logger is a no-op when DEBUG_MODE=false (i.e. in every * packaged build), which means the CI smoke test has no way to observe * runtime progress and local failures can't be diagnosed from `journalctl`. * This helper writes directly to process.stderr so markers survive regardless * of logger configuration. Keep the output format stable — the flatpak smoke * test greps for `[PEARPASS] ` lines. */ function emitStartupMarker(name, detail) { try { const hasDetail = typeof detail === 'string' && detail.length > 0 const line = hasDetail ? `[PEARPASS] ${name} ${detail}\n` : `[PEARPASS] ${name}\n` process.stderr.write(line) } catch { // never let the marker path break startup } } // Enable auto-reload during development for main + renderer code if (!app.isPackaged) { try { // Watch the project root; electron-reload will restart Electron or // reload windows when files change. Renderer JS is rebuilt into dist/. require('electron-reload')(path.join(__dirname, '..'), { // Avoid watching node_modules to reduce noise ignored: /node_modules|[\/\\]\./, awaitWriteFinish: true }) } catch (err) { logger.error('MAIN', 'Failed to enable electron-reload:', err) } } /** @type {import('electron').BrowserWindow | null} */ let mainWindow = null /** @type {import('pear-runtime') | null} */ let pearRuntime = null /** @type {import('bare-sidecar') | null} */ let workletSidecar = null /** @type {import('@tetherto/pearpass-lib-vault-core').PearpassVaultClient | null} */ let vaultClient = null function getExecPath() { if (!app.isPackaged) return null if (isLinux && process.env.APPIMAGE) return process.env.APPIMAGE if (isWindows) return true return path.join(process.resourcesPath, '..', '..') } function getWorkletPath() { const workletDir = path.join( 'node_modules', '@tetherto/pearpass-lib-vault-core', 'src', 'worklet' ) if (app.isPackaged) { // Packaged: Bare runs .js as CJS, so use the CJS bundle from build.worklet.mjs return path.join(process.resourcesPath, 'app', workletDir, 'app.cjs') } // Dev: ESM app.js so Bare's loader can resolve fs -> bare-fs etc. const appPath = app.getAppPath() return path.join(appPath, workletDir, 'app.js') } function getStorageDir() { return getSandboxSafePath(app.getPath('userData')) } // Resolve storage root for this pear app. // 1) If the legacy Pear platform store knows this app (existing install), // use that path for full compatibility. // 2) Otherwise, fall back to an Electron-owned per-link directory under // userData so multiple links can coexist on the same machine. async function resolveRuntimeStorageDir() { const { legacyChannelLink, upgrade } = runtimeConfig || {} let storageDir = getStorageDir() const linkId = upgrade.replace(/^pear:\/\//, '') if (isFlatpakRuntime() || isSnapRuntime()) { storageDir = path.join(storageDir, 'app-storage', 'by-dkey', linkId) logger.info('[MAIN]', 'Using sandbox per-link storage root:', storageDir) return storageDir } try { const legacyStorageDir = legacyChannelLink ? await getPearRuntimeLegacyStorage(legacyChannelLink) : null if (legacyStorageDir) { storageDir = getSandboxSafePath(legacyStorageDir) logger.info('[MAIN]', 'Using pear legacy storage root:', storageDir) } else { storageDir = path.join(storageDir, 'app-storage', 'by-dkey', linkId) logger.warn( 'MAIN', 'pear-runtime-legacy-storage returned null; using per-link Electron storage:', storageDir ) } } catch (err) { storageDir = path.join(getStorageDir(), 'app-storage', 'by-dkey', linkId) logger.warn( 'MAIN', 'Failed to resolve legacy pear storage, using per-link Electron storage:', legacyChannelLink, err && err.message ? err.message : err, 'storageDir=', storageDir ) } return storageDir } function getNativeBridgePath() { const bundleFile = path.join('dist', 'native-messaging-bridge.bundle.cjs') if (app.isPackaged) { return path.join(process.resourcesPath, 'app', bundleFile) } return path.join(app.getAppPath(), bundleFile) } /** * In dev, when PEARPASS_DEV_RESET=1, clear vault/encryption data so the app * Only runs when NODE_ENV !== 'production'. */ function clearVaultStorageForDevReset(storageDir) { if (process.env.NODE_ENV === 'production') return if (process.env.PEARPASS_DEV_RESET !== '1') return const dirs = ['encryption', 'vaults', 'vault', 'pear-runtime'] for (const name of dirs) { const dir = path.join(storageDir, name) try { if (fs.existsSync(dir)) { fs.rmSync(dir, { recursive: true, force: true }) logger.log('MAIN', `Dev reset: removed ${name} at ${dir}`) } } catch (err) { logger.warn( 'MAIN', `Dev reset: failed to remove ${name} at ${dir}:`, err && err.message ? err.message : err ) } } } const WORKLET_READY_TIMEOUT_MS = 15000 const WORKLET_READY_SIGNAL = 'WORKLET_READY' function waitForWorkletReady(sidecar) { const ipcStream = sidecar?._process?.stdio?.[3] if (ipcStream) { // Having an IPC pipe is treated as "ready enough" — bare-sidecar has // already finished its side of the handshake. emitStartupMarker('WORKLET_READY', 'via=ipc-stream') return Promise.resolve(true) } return new Promise((resolve) => { const timeout = setTimeout(() => { cleanup() emitStartupMarker( 'WORKLET_READY_TIMEOUT', `ms=${WORKLET_READY_TIMEOUT_MS}` ) resolve(false) }, WORKLET_READY_TIMEOUT_MS) let buffer = '' const onData = (d) => { const s = (d && (typeof d === 'string' ? d : d.toString?.())) || '' buffer += s if (buffer.includes(WORKLET_READY_SIGNAL)) { cleanup() emitStartupMarker('WORKLET_READY', 'via=stdio-signal') resolve(true) } } const cleanup = () => { clearTimeout(timeout) if (sidecar.stderr) sidecar.stderr.removeListener('data', onData) if (sidecar.stdout) sidecar.stdout.removeListener('data', onData) } sidecar.stderr?.on?.('data', onData) sidecar.stdout?.on?.('data', onData) }) } /** * Start pear-runtime and the vault worklet (bare worker). Called after app is ready. */ async function startRuntime() { const upgrade = runtimeConfig.upgrade if (!upgrade) { logger.warn( 'MAIN', 'Pear runtime: no upgrade link configured. Running without P2P OTA.' ) await startWorkletOnly() return } const storageDir = getStorageDir() // to clear local vault/encryption data so the app starts from scratch. clearVaultStorageForDevReset(storageDir) const workletPath = getWorkletPath() const { PearpassVaultClient } = await import( '@tetherto/pearpass-lib-vault-core' ) const extension = isLinux ? '.AppImage' : isMac ? '.app' : '.msix' pearRuntime = new PearRuntime({ // pear runtime doesn't care about pear (platform) directory dir: storageDir, upgrade, version: runtimeConfig.version, app: app.isPackaged ? getExecPath() : null, bundled: !!app.isPackaged, name: `${pkg.productName}${extension}` }) await pearRuntime.ready() logger.info('[MAIN]', 'workletPath', workletPath) if (!fs.existsSync(workletPath)) { throw new Error(`Worklet not found: ${workletPath}`) } workletSidecar = pearRuntime.run(workletPath) emitStartupMarker('WORKLET_SPAWNED', 'mode=pear-runtime') workletSidecar.on('error', (err) => { logger.error('MAIN', '[worklet IPC error]', err.code || err.message, err) }) const ipcStream = workletSidecar._process?.stdio?.[3] if (ipcStream) ipcStream.on('error', (err) => { logger.error( 'MAIN', '[worklet IPC pipe error]', err.code || err.message, err ) }) workletSidecar.stderr?.on('data', (d) => logger.error('MAIN', '[worklet stderr]', d?.toString?.() || d) ) workletSidecar.stdout?.on('data', (d) => logger.log('MAIN', '[worklet stdout]', d?.toString?.() || d) ) workletSidecar._process?.on?.('exit', (code, sig) => { logger.error('MAIN', '[worklet exit] code=', code, 'signal=', sig) }) workletSidecar._process?.on?.('error', (err) => { logger.error('MAIN', '[worklet process error]', err) }) await waitForWorkletReady(workletSidecar) const storagePath = await resolveRuntimeStorageDir() emitStartupMarker('STORAGE_PATH_SET', storagePath) try { vaultClient = new PearpassVaultClient(workletSidecar, storagePath, { debugMode, logger }) emitStartupMarker('VAULT_CLIENT_READY') } catch (error) { emitStartupMarker( 'VAULT_CLIENT_ERROR', (error && (error.stack || error.message)) || String(error) ) throw error } if (loggingActive) { await enableWorkletFileLogging() } vaultClient.on('update', () => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('vault:update') } }) pearRuntime.updater.on('updating', () => { if (mainWindow && !mainWindow.isDestroyed()) { logger.info('runtime:updating', 'sending updating event') mainWindow.webContents.send('runtime:updating') } }) pearRuntime.updater.on('updated', async () => { if (mainWindow && !mainWindow.isDestroyed()) { logger.info('runtime:updated', 'sending updated event') await pearRuntime.updater.applyUpdate() mainWindow.webContents.send('runtime:updated') } }) } /** * Run only the worklet via bare-sidecar (no P2P runtime). Used when no upgrade link is set (e.g. dev). */ async function startWorkletOnly() { // When there is no upgrade link, we don't need pear-runtime's full // update machinery – we just need to run the worklet via bare-sidecar. // bare-sidecar is a dependency of pear-runtime and will be hoisted into // this app's node_modules, so we can require it directly. const Sidecar = require('bare-sidecar') const { PearpassVaultClient } = await import( '@tetherto/pearpass-lib-vault-core' ) const workletPath = getWorkletPath() if (!fs.existsSync(workletPath)) { throw new Error(`Worklet not found: ${workletPath}`) } // Dev-only: allow `PEARPASS_DEV_RESET=1 npm run dev` (or `npm run dev:reset`) // to clear local vault/encryption data so the app starts from scratch. clearVaultStorageForDevReset(getStorageDir()) // In packaged builds, Bare's module resolution uses the process cwd. let previousCwd = null if (app.isPackaged) { const appRoot = path.join(process.resourcesPath, 'app') if (fs.existsSync(appRoot)) { previousCwd = process.cwd() process.chdir(appRoot) logger.log('MAIN', 'Worklet cwd set to', appRoot) } } try { workletSidecar = new Sidecar(workletPath) } finally { if (previousCwd !== null) { process.chdir(previousCwd) } } emitStartupMarker('WORKLET_SPAWNED', 'mode=bare-sidecar') workletSidecar.on('error', (err) => { logger.error('MAIN', '[worklet IPC error]', err.code || err.message, err) }) const ipcStream = workletSidecar._process?.stdio?.[3] if (ipcStream) ipcStream.on('error', (err) => { logger.error( 'MAIN', '[worklet IPC pipe error]', err.code || err.message, err ) }) workletSidecar.stderr?.on('data', (d) => logger.error('MAIN', '[worklet stderr]', d?.toString?.() || d) ) workletSidecar.stdout?.on('data', (d) => logger.log('MAIN', '[worklet stdout]', d?.toString?.() || d) ) workletSidecar._process?.on?.('exit', (code, sig) => { logger.error('MAIN', '[worklet exit] code=', code, 'signal=', sig) }) workletSidecar._process?.on?.('error', (err) => { logger.error('MAIN', '[worklet process error]', err) }) await waitForWorkletReady(workletSidecar) const storagePath = getStorageDir() emitStartupMarker('STORAGE_PATH_SET', storagePath) try { vaultClient = new PearpassVaultClient(workletSidecar, storagePath, { debugMode, logger }) emitStartupMarker('VAULT_CLIENT_READY') } catch (error) { emitStartupMarker( 'VAULT_CLIENT_ERROR', (error && (error.stack || error.message)) || String(error) ) throw error } if (loggingActive) { await enableWorkletFileLogging() } vaultClient.on('update', () => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('vault:update') } }) } function createWindow() { const isV2 = runtimeConfig.designVersion === 2 // Resolve app icon per-platform let iconPath = null if (process.platform === 'darwin') { iconPath = app.isPackaged ? path.join(process.resourcesPath, 'assets', 'darwin', 'icon.png') : path.join(__dirname, '..', 'assets', 'darwin', 'icon.png') } else if (process.platform === 'win32') { iconPath = app.isPackaged ? path.join(process.resourcesPath, 'assets', 'win32', 'icon.ico') : path.join(__dirname, '..', 'assets', 'win32', 'icon.ico') } else { iconPath = app.isPackaged ? path.join(process.resourcesPath, 'assets', 'linux', 'icon.png') : path.join(__dirname, '..', 'assets', 'linux', 'icon.png') } let iconImage = null try { iconImage = nativeImage.createFromPath(iconPath) } catch { iconImage = null } // Set Dock icon explicitly on macOS if (process.platform === 'darwin' && iconImage && !iconImage.isEmpty()) { try { app.dock.setIcon(iconImage) } catch { // ignore dock icon errors } } mainWindow = new BrowserWindow({ width: 1440, height: 1024, minWidth: 816, ...(isMac && isV2 ? { titleBarStyle: 'hidden', trafficLightPosition: { x: 18, y: 12 } } : {}), backgroundColor: '#1F2430', icon: iconPath && iconImage && !iconImage.isEmpty() ? iconPath : undefined, autoHideMenuBar: true, webPreferences: { preload: path.join(__dirname, 'preload.cjs'), nodeIntegration: true, contextIsolation: false, sandbox: false } }) mainWindow.loadFile(path.join(__dirname, '..', 'index.html')) emitStartupMarker('WINDOW_CREATED') // Open external links in the default browser instead of the Electron window mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url) return { action: 'deny' } }) mainWindow.webContents.on('will-navigate', (event, url) => { const appUrl = mainWindow.webContents.getURL() if (url !== appUrl) { event.preventDefault() shell.openExternal(url) } }) mainWindow.on('closed', () => { mainWindow = null }) } function fromSerializableArg(data) { if (data && typeof data === 'object' && data.__base64) { return Buffer.from(data.__base64, 'base64') } if (data && typeof data === 'object' && !Array.isArray(data)) { const out = {} for (const k of Object.keys(data)) { out[k] = fromSerializableArg(data[k]) } return out } if (Array.isArray(data)) { return data.map(fromSerializableArg) } return data } function toSerializableArg(value) { if (Buffer.isBuffer(value)) { return { __base64: value.toString('base64') } } if (value && typeof value === 'object' && !Array.isArray(value)) { const out = {} for (const k of Object.keys(value)) { out[k] = toSerializableArg(value[k]) } return out } if (Array.isArray(value)) { return value.map(toSerializableArg) } return value } function registerIPC() { ipcMain.on('get-app-path', (e) => { e.returnValue = app.getAppPath() }) ipcMain.handle('app:getVersion', () => app.getVersion()) ipcMain.handle('runtime:getConfig', async () => { const storage = await resolveRuntimeStorageDir() return { storage, key: runtimeConfig.upgrade || null, upgrade: runtimeConfig.upgrade, version: runtimeConfig.version, productName: runtimeConfig.productName, applink: runtimeConfig.upgrade || '', userDataPath: getStorageDir(), execPath: isWindows && process.windowsStore ? path.join( process.env.LOCALAPPDATA, 'Microsoft', 'WindowsApps', path.basename(process.execPath) ) : process.execPath, bridgePath: getNativeBridgePath() } }) ipcMain.handle('runtime:applyUpdate', async () => { logger.info( '[MAIN]', 'runtime:applyUpdate', pearRuntime?.updater?.applyUpdate ) return await pearRuntime.updater.applyUpdate() }) ipcMain.handle('runtime:restart', async () => { logger.info('[MAIN]', 'runtime:restart') if (isMac || isLinux) { app.relaunch() app.exit(0) } else { app.exit(0) } }) ipcMain.handle( 'runtime:checkUpdated', async () => !!(pearRuntime && pearRuntime.updated) ) ipcMain.handle('shell:openExternal', async (_event, url) => { await shell.openExternal(url) }) ipcMain.handle('vault:invoke', async (_event, { method, args }) => { if (!vaultClient) { throw new Error('Vault client not ready') } const fn = vaultClient[method] if (typeof fn !== 'function') { throw new Error(`Unknown vault method: ${method}`) } const rawArgs = args || [] const deserialized = rawArgs.map(fromSerializableArg) try { const result = await fn.apply(vaultClient, deserialized) return { ok: true, data: toSerializableArg(result) } } catch (err) { return { ok: false, error: err.message || String(err), code: err.code } } }) ipcMain.handle('clipboard:clearAfter', async (_event, { text, delayMs }) => scheduleClipboardCleanup({ app, clipboard, logger, isWindows, text, delayMs }) ) ipcMain.handle('vault:openLogsFolder', async () => { const { logsDir, mainPath } = getLogPaths(getStorageDir()) fs.mkdirSync(logsDir, { recursive: true }) if (fs.existsSync(mainPath)) { shell.showItemInFolder(mainPath) } else { await shell.openPath(logsDir) } }) ipcMain.handle('vault:isLoggingEnabled', () => ({ enabled: loggingActive, forced: loggingForced })) ipcMain.handle('vault:setLogging', async (_event, payload) => { if (loggingForced) { return { enabled: true, forced: true } } const next = !!(payload && payload.enabled) if (next === loggingActive) { return { enabled: loggingActive, forced: false } } loggingActive = next try { devicePreferences.write(getStorageDir(), { loggingEnabled: loggingActive }) } catch (err) { logger.warn('MAIN', 'Failed to persist device preferences', err) } if (loggingActive) { // Toggle ON: clear any leftover files for a clean session removeLogFiles(getStorageDir()) logger.setLogPath(getStorageDir()) await enableWorkletFileLogging() return { enabled: true, forced: false } } // Toggle OFF: stop worklet writes first, then close main, then delete if (vaultClient) { try { await vaultClient.setLogOptions({ logFile: null }) } catch (err) { logger.warn('MAIN', 'setLogOptions(disable) failed', err) } } logger.clearLogPath() removeLogFiles(getStorageDir()) return { enabled: false, forced: false } }) } app.whenReady().then(async () => { emitStartupMarker('PEARPASS_MAIN_READY') app.setName(pkg.productName) const { loggingEnabled } = devicePreferences.read(getStorageDir()) loggingActive = loggingForced || loggingEnabled if (loggingActive) { logger.setLogPath(getStorageDir()) } registerIPC() try { await startRuntime() } catch (err) { emitStartupMarker( 'STARTUP_ERROR', (err && (err.stack || err.message)) || String(err) ) logger.error('MAIN', 'Failed to start runtime/worklet:', err) } createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() } }) }) async function cleanup() { if (workletSidecar) { try { workletSidecar.destroy() } catch (_) {} } if (pearRuntime && typeof pearRuntime.close === 'function') { try { await pearRuntime.close() } catch (_) {} } } app.on('window-all-closed', async () => { app.quit() }) app.on('before-quit', async () => { await cleanup() }) ================================================ FILE: electron/preload.cjs ================================================ /* eslint-disable no-underscore-dangle */ /** * Preload: with contextIsolation false, runs in the same context as the page. * Injects Node globals (__dirname, __filename) and Pear placeholder so the original */ const path = require('path') const { ipcRenderer } = require('electron') const pkg = require('../package.json') const appPath = ipcRenderer.sendSync('get-app-path') // Required by fs-native-extensions (pulled in via pear-ipc): binding.js uses __filename const fsNativeExtDir = path.join( appPath, 'node_modules', 'fs-native-extensions' ) global.__dirname = fsNativeExtDir global.__filename = path.join(fsNativeExtDir, 'binding.js') global.global = global window.electronAPI = { productName: pkg.productName, getAppVersion: () => ipcRenderer.invoke('app:getVersion'), getConfig: () => ipcRenderer.invoke('runtime:getConfig'), onRuntimeUpdating: (cb) => { const sub = () => cb() ipcRenderer.on('runtime:updating', sub) return () => ipcRenderer.removeListener('runtime:updating', sub) }, onRuntimeUpdated: (cb) => { const sub = () => cb() ipcRenderer.on('runtime:updated', sub) return () => ipcRenderer.removeListener('runtime:updated', sub) }, applyUpdate: () => ipcRenderer.invoke('runtime:applyUpdate'), restart: () => ipcRenderer.invoke('runtime:restart'), checkUpdated: () => ipcRenderer.invoke('runtime:checkUpdated'), clearClipboardAfter: (text, delayMs) => ipcRenderer.invoke('clipboard:clearAfter', { text, delayMs }), vaultInvoke: (method, args) => ipcRenderer.invoke('vault:invoke', { method, args }), vaultOnUpdate: (cb) => { const sub = () => cb() ipcRenderer.on('vault:update', sub) return () => ipcRenderer.removeListener('vault:update', sub) }, openExternal: (url) => ipcRenderer.invoke('shell:openExternal', url), openLogsFolder: () => ipcRenderer.invoke('vault:openLogsFolder'), isLoggingEnabled: () => ipcRenderer.invoke('vault:isLoggingEnabled'), setLogging: (enabled) => ipcRenderer.invoke('vault:setLogging', { enabled: !!enabled }) } ================================================ FILE: electron/preload.test.js ================================================ /* eslint-disable no-underscore-dangle */ /* eslint-env jest */ const path = require('path') jest.mock('electron', () => ({ ipcRenderer: { sendSync: jest.fn(() => '/fake/app/path'), invoke: jest.fn(), on: jest.fn(), removeListener: jest.fn() } })) // Helper to load the preload script fresh for each test const loadPreload = () => { // Ensure we start from a clean module state jest.resetModules() return require('./preload.cjs') } describe('preload.cjs', () => { beforeEach(() => { // Provide a minimal window shim so preload.cjs can attach electronAPI globalThis.window = globalThis.window || {} // Clean up globals that the preload may set delete globalThis.__dirname delete globalThis.__filename if (typeof window !== 'undefined') { delete window.electronAPI } jest.clearAllMocks() // Load the preload script (which will set globals and window.electronAPI) loadPreload() }) it('sets Node-related globals correctly', () => { const expectedDir = path.join( '/fake/app/path', 'node_modules', 'fs-native-extensions' ) expect(globalThis.__dirname).toBe(expectedDir) expect(globalThis.__filename).toBe(path.join(expectedDir, 'binding.js')) }) it('exposes electronAPI on window with expected methods', () => { expect(window.electronAPI).toBeDefined() expect(typeof window.electronAPI.getAppVersion).toBe('function') expect(typeof window.electronAPI.getConfig).toBe('function') expect(typeof window.electronAPI.onRuntimeUpdating).toBe('function') expect(typeof window.electronAPI.onRuntimeUpdated).toBe('function') expect(typeof window.electronAPI.applyUpdate).toBe('function') expect(typeof window.electronAPI.restart).toBe('function') expect(typeof window.electronAPI.checkUpdated).toBe('function') expect(typeof window.electronAPI.clearClipboardAfter).toBe('function') expect(typeof window.electronAPI.vaultInvoke).toBe('function') expect(typeof window.electronAPI.vaultOnUpdate).toBe('function') }) it('routes simple invoke-based APIs through ipcRenderer.invoke', async () => { const { ipcRenderer } = require('electron') await window.electronAPI.getAppVersion() await window.electronAPI.getConfig() await window.electronAPI.applyUpdate() await window.electronAPI.restart() await window.electronAPI.checkUpdated() await window.electronAPI.clearClipboardAfter('secret', 30000) expect(ipcRenderer.invoke).toHaveBeenCalledWith('app:getVersion') expect(ipcRenderer.invoke).toHaveBeenCalledWith('runtime:getConfig') expect(ipcRenderer.invoke).toHaveBeenCalledWith('runtime:applyUpdate') expect(ipcRenderer.invoke).toHaveBeenCalledWith('runtime:restart') expect(ipcRenderer.invoke).toHaveBeenCalledWith('runtime:checkUpdated') expect(ipcRenderer.invoke).toHaveBeenCalledWith('clipboard:clearAfter', { text: 'secret', delayMs: 30000 }) }) it('routes vaultInvoke through ipcRenderer.invoke with payload', async () => { const { ipcRenderer } = require('electron') await window.electronAPI.vaultInvoke('doSomething', { foo: 'bar' }) expect(ipcRenderer.invoke).toHaveBeenCalledWith('vault:invoke', { method: 'doSomething', args: { foo: 'bar' } }) }) it('subscribes and unsubscribes to runtime updating events', () => { const { ipcRenderer } = require('electron') const cb = jest.fn() const unsubscribe = window.electronAPI.onRuntimeUpdating(cb) expect(ipcRenderer.on).toHaveBeenCalledTimes(1) const [channel, handler] = ipcRenderer.on.mock.calls[0] expect(channel).toBe('runtime:updating') expect(typeof handler).toBe('function') // When unsubscribe is called, it should remove the same handler unsubscribe() expect(ipcRenderer.removeListener).toHaveBeenCalledWith( 'runtime:updating', handler ) }) it('subscribes and unsubscribes to runtime updated events', () => { const { ipcRenderer } = require('electron') const cb = jest.fn() const unsubscribe = window.electronAPI.onRuntimeUpdated(cb) expect(ipcRenderer.on).toHaveBeenCalledTimes(1) const [channel, handler] = ipcRenderer.on.mock.calls[0] expect(channel).toBe('runtime:updated') expect(typeof handler).toBe('function') unsubscribe() expect(ipcRenderer.removeListener).toHaveBeenCalledWith( 'runtime:updated', handler ) }) it('subscribes and unsubscribes to vault update events', () => { const { ipcRenderer } = require('electron') const cb = jest.fn() const unsubscribe = window.electronAPI.vaultOnUpdate(cb) expect(ipcRenderer.on).toHaveBeenCalledTimes(1) const [channel, handler] = ipcRenderer.on.mock.calls[0] expect(channel).toBe('vault:update') expect(typeof handler).toBe('function') unsubscribe() expect(ipcRenderer.removeListener).toHaveBeenCalledWith( 'vault:update', handler ) }) }) ================================================ FILE: electron/runtime-config.cjs ================================================ /** * Pear runtime config for P2P OTA updates. */ const fs = require('fs') const path = require('path') const pkg = require('../package.json') function readDesignVersion() { try { const flagsPath = path.join( __dirname, '..', 'node_modules', '@tetherto/pearpass-lib-constants', 'src', 'constants', 'flags.js' ) const content = fs.readFileSync(flagsPath, 'utf8') const match = content.match(/DESKTOP_DESIGN_VERSION\s*=\s*(\d+)/) return match ? Number(match[1]) : 1 } catch { return 1 } } module.exports = { upgrade: pkg.upgrade || null, version: pkg.version ?? 0, productName: pkg.productName ?? pkg.name ?? 'PearPass', legacyChannelLink: pkg.legacyChannelLink || null, designVersion: readDesignVersion() } ================================================ FILE: electron-builder.linux.json ================================================ { "appId": "com.pears.pass", "icon": "assets/linux/icon.png", "asar": false, "directories": { "output": "out" }, "files": [ "electron/**/*", "dist/**/*", "index.html", "index.js", "packages/**/*", "assets/**/*", "src/utils/**/*", "!**/node_modules/react-native/**/*", "!**/node_modules/@react-native/**/*" ], "extraResources": [ { "from": "assets", "to": "assets" }, { "from": "resources/bin", "to": "bin" } ], "linux": { "icon": "assets/linux/icon.png", "target": [ "AppImage" ], "artifactName": "${productName}.${ext}" }, "toolsets": { "appimage": "1.0.2" }, "afterPack": "scripts/afterPack.cjs" } ================================================ FILE: electron-builder.mac.json ================================================ { "appId": "com.pears.pass", "icon": "assets/darwin/icon.png", "asar": false, "directories": { "output": "out" }, "files": [ "electron/**/*", "dist/**/*", "index.html", "index.js", "packages/**/*", "assets/**/*", "src/utils/**/*", "!**/node_modules/react-native/**/*", "!**/node_modules/@react-native/**/*" ], "extraResources": [ { "from": "assets", "to": "assets" }, { "from": "resources/bin", "to": "bin" } ], "mac": { "category": "public.app-category.utilities", "icon": "assets/darwin/icon.png", "target": "dmg", "hardenedRuntime": true, "gatekeeperAssess": false }, "afterSign": "scripts/notarize.cjs", "afterPack": "scripts/afterPack.cjs", "dmg": {} } ================================================ FILE: eslint.config.js ================================================ import { eslintConfig } from '@tetherto/tether-dev-docs' import globals from 'globals' export default [ { ignores: ['src/PearPass/**'] }, ...eslintConfig, { files: ['**/*.test.{js,jsx,mjs,cjs,ts,tsx}'], languageOptions: { globals: globals.jest } }, { files: ['**/*.{js,jsx,mjs,cjs}'], rules: { 'no-unused-vars': [ 'error', { varsIgnorePattern: '^React$', ignoreRestSiblings: true } ] } }, { files: ['**/*.{ts,tsx}'], rules: { '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^React$', ignoreRestSiblings: true } ] } } ] ================================================ FILE: flatpak/appimage/.gitkeep ================================================ ================================================ FILE: flatpak/com.pears.pass.desktop ================================================ [Desktop Entry] Name=PearPass GenericName=Password Manager Comment=A secure, decentralized and fully local password manager Exec=pearpass %U Icon=com.pears.pass Terminal=false Type=Application Categories=Utility;Security; Keywords=password;manager;vault;security;credentials;encryption;sync; StartupNotify=true StartupWMClass=PearPass MimeType=x-scheme-handler/pearpass; ================================================ FILE: flatpak/com.pears.pass.metainfo.xml ================================================ com.pears.pass PearPass A secure, decentralized and fully local password manager Tether Data S.A. de C.V. CC0-1.0 Apache-2.0 https://pearpass.io https://github.com/tetherto/pearpass-app-desktop/issues https://github.com/tetherto/pearpass-app-desktop

PearPass is a distributed password manager powered by Pear Runtime. It provides secure storage of passwords, credit card details, and secure notes, with the ability to distribute and synchronize data across multiple devices.

Features:

  • Secure password, identity, credit card, notes, and custom fields storage
  • Cross-device and cross-platform synchronization
  • Offline access to your credentials
  • Strong encryption for data security
  • Password strength analysis
  • Random password generator
  • Easy-to-use interface
  • Fully decentralized - your data stays on your devices
com.pears.pass.desktop #4CAF50 #1F2430 Main vault view with password entries https://pearpass.io/screenshots/vault-view.png Password generator with strength indicator https://pearpass.io/screenshots/password-generator.png Secure notes and custom fields https://pearpass.io/screenshots/secure-notes.png

Latest stable release of PearPass.

Initial release of PearPass password manager.

  • Secure password storage with encryption
  • Credit card and secure notes support
  • Cross-device synchronization via Pear Runtime
  • Password strength analysis and generator
Utility Security password password manager security vault credentials encryption sync decentralized p2p pear pearpass 768 keyboard pointing offline-only
================================================ FILE: flatpak/com.pears.pass.yaml ================================================ app-id: com.pears.pass runtime: org.gnome.Platform runtime-version: '49' sdk: org.gnome.Sdk command: pearpass finish-args: - --share=ipc - --share=network - --socket=x11 - --socket=fallback-x11 - --socket=system-bus - --device=dri - --filesystem=home - --filesystem=xdg-config/pear:create - --talk-name=org.freedesktop.Notifications - --talk-name=org.freedesktop.secrets - --system-talk-name=org.freedesktop.DBus - --system-talk-name=org.bluez - --system-talk-name=org.freedesktop.UPower modules: - name: pearpass buildsystem: simple build-commands: # ── Extract AppImage ────────────────────────────────────────────── - chmod +x PearPass.AppImage - ./PearPass.AppImage --appimage-extract # ── Install extracted AppImage payload ──────────────────────────── - mkdir -p /app/lib/pearpass - cp -a squashfs-root/. /app/lib/pearpass/ # ── Install wrapper script ──────────────────────────────────────── - mkdir -p /app/bin - | cat > /app/bin/pearpass <<'WRAPPER' #!/bin/sh APP_ROOT="/app/lib/pearpass" export LD_LIBRARY_PATH="$APP_ROOT${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" # Force X11 backend: Wayland auto-detection inside the flatpak sandbox # is unreliable and was producing a black window on CI-built bundles. # X11 works natively on X11 sessions and via XWayland on Wayland sessions. export GDK_BACKEND=x11 cd "$APP_ROOT" exec "$APP_ROOT/pearpass-app-desktop" \ --no-sandbox \ --disable-gpu \ --disable-gpu-sandbox \ --disable-dev-shm-usage \ --enable-features=UseOzonePlatform \ --ozone-platform=x11 \ "$@" WRAPPER - chmod +x /app/bin/pearpass # ── Native messaging host entry point ──────────────────────────── # Invoked via `flatpak run --command=pearpass-native-host com.pears.pass` # by the host-side wrapper script that Chrome's Native Messaging API # launches. Runs the bridge bundle through Electron's embedded Node. - | cat > /app/bin/pearpass-native-host <<'NMHOST' #!/bin/sh # Call the Electron binary directly, bypassing the pearpass-app-desktop # shell launcher which unconditionally injects --no-sandbox. Under # ELECTRON_RUN_AS_NODE=1 the binary runs as Node and rejects that flag, # causing Chrome to see "Native host has exited". APP_ROOT="/app/lib/pearpass" export LD_LIBRARY_PATH="$APP_ROOT${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" export ELECTRON_RUN_AS_NODE=1 exec "$APP_ROOT/pearpass-app-desktop.bin" \ "$APP_ROOT/resources/app/dist/native-messaging-bridge.bundle.cjs" \ "$@" NMHOST - chmod +x /app/bin/pearpass-native-host # ── Desktop integration ─────────────────────────────────────────── - install -Dm644 com.pears.pass.desktop /app/share/applications/com.pears.pass.desktop - install -Dm644 com.pears.pass.metainfo.xml /app/share/metainfo/com.pears.pass.metainfo.xml # ── Install icon (pre-resized to 512x512 by build script) ──────── - install -Dm644 icon-512.png /app/share/icons/hicolor/512x512/apps/com.pears.pass.png sources: - type: file path: appimage/PearPass.AppImage - type: file path: com.pears.pass.desktop - type: file path: com.pears.pass.metainfo.xml - type: file path: icon-512.png ================================================ FILE: forge.config.cjs ================================================ const fs = require('fs') const path = require('path') const { isWindows } = require('which-runtime') const pkg = require('./package.json') const appName = pkg.productName ?? pkg.name function getWindowsKitVersion() { const programFiles = process.env['PROGRAMFILES(X86)'] || process.env.PROGRAMFILES if (!programFiles) return undefined const kitsDir = path.join(programFiles, 'Windows Kits') try { for (const kit of fs.readdirSync(kitsDir).sort().reverse()) { const binDir = path.join(kitsDir, kit, 'bin') if (!fs.existsSync(binDir)) continue const version = fs .readdirSync(binDir) .filter((d) => /^\d+\.\d+\.\d+\.\d+$/.test(d)) .sort() .pop() if (version) return version } } catch { return undefined } } const packagerConfig = { icon: path.join(__dirname, 'assets', 'win32', 'icon'), protocols: [{ name: appName, schemes: [pkg.name] }], derefSymlinks: true, ignore: [ // RN deps — bundled by esbuild, never required at runtime /(^|\/)node_modules\/react-native(\/|$)/, /(^|\/)node_modules\/@react-native(\/|$)/, /^\/e2e($|\/)/, /^\/docs($|\/)/, ] } /** @type {import('@electron-forge/shared-types').ForgeConfig} */ module.exports = { packagerConfig, makers: [ { name: '@electron-forge/maker-msix', config: { appManifest: path.join( __dirname, 'build-assets', 'win', 'AppxManifest.xml' ), packageAssets: path.join(__dirname, 'build-assets', 'icon'), createPri: false, windowsKitVersion: getWindowsKitVersion(), manifestVariables: { publisher: 'CN="Tether Operations, SA de CV", O="Tether Operations, SA de CV", L=San Salvador, C=SV, SERIALNUMBER=2025120324, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.3=SV' }, windowsSignOptions: { certificateSha1: '874b95fdc8a490a3d3bab28643902948b2c7ad43', signWithParams: '/sha1 874b95fdc8a490a3d3bab28643902948b2c7ad43', timestampServer: 'http://timestamp.digicert.com', fileDigestAlgorithm: 'sha256', timestampDigestAlgorithm: 'sha256' } } } ], hooks: { preMake: async (_config, options) => { const targetArch = options?.arch || process.arch const msixArch = targetArch === 'arm64' ? 'arm64' : 'x64' const pkgJson = JSON.parse( fs.readFileSync(path.resolve('package.json'), 'utf8') ) const [major, minor, patch] = pkgJson.version .split('-')[0] .split('.') .map(Number) const msixVersion = `${major}.${minor}.${patch}.0` const manifestPath = path.resolve('build-assets/win/AppxManifest.xml') const manifest = fs.readFileSync(manifestPath, 'utf8') fs.writeFileSync( manifestPath, manifest .replace(/Version="\d+\.\d+\.\d+\.\d+"/, `Version="${msixVersion}"`) .replace( /ProcessorArchitecture="\w+"/, `ProcessorArchitecture="${msixArch}"` ) ) }, postMake: async (forgeConfig, results) => { for (const result of results) { if (result.platform !== 'win32') continue for (let i = 0; i < result.artifacts.length; i++) { const artifact = result.artifacts[i] if (!artifact.endsWith('.msix')) continue const dir = path.dirname(artifact) const ext = path.extname(artifact) const base = path.basename(artifact, ext) const renamed = path.join(dir, `${base}-${result.arch}${ext}`) fs.renameSync(artifact, renamed) result.artifacts[i] = renamed } } }, readPackageJson: async (forgeConfig, packageJson) => { if (process.env.PEARPASS_UPGRADE_LINK) { packageJson.upgrade = process.env.PEARPASS_UPGRADE_LINK } if (process.env.PEARPASS_LEGACY_CHANNEL_LINK) { packageJson.legacyChannelLink = process.env.PEARPASS_LEGACY_CHANNEL_LINK } if (process.env.BUILD_VERSION) { packageJson.version = process.env.BUILD_VERSION } return packageJson } }, plugins: [ { name: 'electron-forge-plugin-universal-prebuilds', config: {} }, { name: 'electron-forge-plugin-prune-prebuilds', config: {} } ] } ================================================ FILE: index.html ================================================
================================================ FILE: index.js ================================================ import Runtime from 'pear-electron' import Bridge from 'pear-bridge' const bridge = new Bridge({ waypoint: '/index.html' }) await bridge.ready() const runtime = new Runtime() const pipe = await runtime.start({ bridge }) pipe.on('close', () => Pear.exit()) Pear.teardown(() => pipe.destroy()) ================================================ FILE: jest.config.js ================================================ export default { testEnvironment: 'jsdom', transform: { '^.+\\.[jt]sx?$': 'babel-jest' }, moduleNameMapper: { '^@tetherto/pearpass-lib-ui-theme-provider$': '/node_modules/@tetherto/pearpass-lib-ui-theme-provider/src/index.js', '^pearpass-lib-ui-theme-provider$': '/node_modules/@tetherto/pearpass-lib-ui-theme-provider/src/index.js' }, testPathIgnorePatterns: [ '/node_modules/', '/.yalc/', '/packages/', '/e2e/', '/dist/' ], transformIgnorePatterns: [ 'node_modules/(?!(htm|react-strict-dom|@tetherto/pearpass-lib-ui-theme-provider|@tetherto/pearpass-lib-ui-react-components|@tetherto/pear-apps-lib-ui-react-hooks|@tetherto/pear-apps-utils-validator|@tetherto/pearpass-lib-vault|@tetherto/pearpass-lib-vault-core|@tetherto/pearpass-lib-ui-kit|@tetherto/pearpass-utils-password-check|@tetherto/pearpass-utils-password-generator|@tetherto/pear-apps-utils-pattern-search|@tetherto/pear-apps-utils-avatar-initials|@tetherto/pear-apps-lib-feedback|@tetherto/pear-apps-utils-generate-unique-id|@tetherto/pearpass-lib-constants|@tetherto/pear-apps-utils-date|@tetherto/pear-apps-utils-qr)/)' ], globals: { Pear: { config: { tier: 'dev' } } } } ================================================ FILE: lingui.config.js ================================================ import { defineConfig } from '@lingui/cli' import { formatter } from '@lingui/format-json' export default defineConfig({ locales: ['en'], sourceLocale: 'en', catalogs: [ { path: './src/locales/{locale}/messages', include: ['./src'], exclude: [ './src/native-messaging-bridge/node_modules/**', './src/PearPass/**' ] } ], format: formatter({ style: 'minimal' }), compileNamespace: 'es' }) ================================================ FILE: package.json ================================================ { "name": "pearpass-app-desktop", "productName": "PearPass", "version": "2.0.0", "description": "PearPass password manager", "author": "PearPass", "main": "electron/main.cjs", "upgrade": "pear://dbkezmhetxwo95ab1kcojfraw1eryzf7kex5cahykf6b9c3amd6o", "legacyChannelLink": "pear://tywsat7gz8m65ejx4zjn3773pbdc4j8m66tukis8dgzekraymtzo", "build": { "appId": "com.pears.pass", "icon": "assets/darwin/icon.png", "asar": false, "files": [ "electron/**/*", "dist/**/*", "index.html", "index.js", "packages/**/*", "assets/**/*", "src/utils/**/*" ], "extraResources": [ { "from": "assets", "to": "assets" }, { "from": "resources/bin", "to": "bin" } ], "mac": { "category": "public.app-category.utilities", "icon": "assets/darwin/icon.png", "target": "dmg", "hardenedRuntime": true, "gatekeeperAssess": false }, "afterSign": "scripts/notarize.cjs", "afterPack": "scripts/afterPack.cjs", "dmg": {}, "linux": { "icon": "assets/linux/icon.png", "target": [ "AppImage" ], "artifactName": "${productName}.${ext}" }, "toolsets": { "appimage": "1.0.2" }, "publish": [ { "provider": "github", "owner": "tetherto", "repo": "pearpass-app-desktop" } ] }, "pear": { "name": "pearpass-app-desktop", "routes": ".", "unrouted": [ "/packages/pearpass-lib-vault-core/src/worklet/app.js" ], "gui": { "backgroundColor": "#1F2430", "height": "1024", "width": "1440", "minWidth": "816" }, "links": [ "https://hooks.slack.com/services/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", "https://docs.google.com/forms/d/e/1FAIpQLScLltvRe64VzMDRzOVjGtHWZ3KafLC2zzvkEoJfTzJkFd67OA/*" ], "stage": { "entrypoints": [], "prefetch": [ "assets", "resources" ], "ignore": [ ".github", "appling", ".git", ".gitignore", ".husky", "e2e", "scripts", "src", "tsconfig.json", "eslint.config.js", "jest.config.js", "flatpak" ] } }, "type": "module", "license": "Apache-2.0", "scripts": { "test": "jest", "build:app": "lingui extract && lingui compile && tsc", "lint:fix": "eslint --fix ./src", "lint": "eslint ./src", "build:worklet": "node scripts/build.worklet.mjs", "build": "node scripts/build.worklet.mjs && npm run build:app && node scripts/bundle-renderer.mjs && node scripts/bundle-bridge.mjs", "build:prod": "cross-env NODE_ENV=production npm run build", "bundle:renderer": "node scripts/bundle-renderer.mjs", "bundle:bridge": "node scripts/bundle-bridge.mjs", "lingui:extract": "lingui extract", "lingui:compile": "lingui compile", "prepare": "husky", "postinstall": "node scripts/patch-electron-dock-name.mjs", "watch:packages": "nodemon --watch packages --ext js,json --exec \"echo 'Packages changed'\"", "electron": "electron .", "dev": "concurrently -n \"TypeScript,Bundle,Bridge,Electron\" -c \"blue,cyan,magenta,green\" \"node node_modules/typescript/bin/tsc --watch\" \"node scripts/bundle-renderer.mjs --watch\" \"node scripts/bundle-bridge.mjs --watch\" \"wait-on dist/renderer.bundle.js && electron .\"", "dev:reset": "PEARPASS_DEV_RESET=1 npm run dev", "dist:prepare:dev": "npm install && npm run build && npm prune --omit=dev && npm install electron --no-save", "dist:mac:arm64": "npx electron-builder --mac --arm64 -c electron-builder.mac.json --publish never", "dist:mac:x64": "npx electron-builder --mac --x64 -c electron-builder.mac.json --publish never", "dist:mac:arm64:dev": "SKIP_NOTARIZE=true CSC_IDENTITY_AUTO_DISCOVERY=false npm run dist:mac:arm64", "dist:mac:x64:dev": "SKIP_NOTARIZE=true CSC_IDENTITY_AUTO_DISCOVERY=false npm run dist:mac:x64", "dist:win:x64": "electron-forge make --platform=win32 --arch=x64", "dist:win:arm64": "electron-forge make --platform=win32 --arch=arm64", "dist:linux:x64": "npx electron-builder --linux --x64 -c electron-builder.linux.json --publish never", "dist:linux:arm64": "npx electron-builder --linux --arm64 -c electron-builder.linux.json --publish never", "make": "npm run build:prod && npm run dist:win:x64 && npm run dist:win:arm64", "dist:snap:x64": "npx electron-builder --linux snap --x64 --publish never", "dist:snap:arm64": "npx electron-builder --linux snap --arm64 --publish never", "dist:snap:x64:dev": "npm run dist:prepare:dev && dist:snap:x64 && npm install", "dist:snap:arm64:dev": "npm run dist:prepare:dev && dist:snap:arm64 && npm install", "pear:build:darwin:arm64": "npx pear-build --package=./package.json --darwin-arm64-app ./out/mac-arm64/PearPass.app --target ../pearpass-app-desktop-$npm_package_version", "pear:build:darwin:x64": "npx pear-build --package=./package.json --darwin-x64-app ./out/mac/PearPass.app --target ../pearpass-app-desktop-$npm_package_version", "pear:build:linux:x64": "npx pear-build --package=./package.json --linux-x64-app ./out/PearPass.AppImage --target ../pearpass-app-desktop-$npm_package_version", "pear:build:linux:arm64": "npx pear-build --package=./package.json --linux-arm64-app ./out/PearPass.AppImage --target ../pearpass-app-desktop-$npm_package_version", "pear:build:win:x64": "npx pear-build --package=./package.json --win32-x64-app ./out/PearPass/PearPass.msix --target ../pearpass-app-desktop-%npm_package_version%", "pear:build:win:arm64": "npx pear-build --package=./package.json --win32-arm64-app ./out/PearPass/PearPass.msix --target ../pearpass-app-desktop-%npm_package_version%" }, "devDependencies": { "@babel/core": "7.26.10", "@babel/preset-env": "7.26.9", "@babel/preset-react": "7.26.3", "@babel/preset-typescript": "^7.28.5", "@electron-forge/cli": "^7.6.0", "@electron-forge/maker-msix": "^7.6.0", "@electron/notarize": "^2.2.1", "@lingui/cli": "5.1.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.2.0", "@tetherto/tether-dev-docs": "git+https://github.com/tetherto/tether-dev-docs.git", "@types/node": "25.4.0", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", "@typescript-eslint/eslint-plugin": "^8.46.0", "@typescript-eslint/parser": "^8.46.0", "@vercel/ncc": "0.38.3", "babel-jest": "29.7.0", "bare-pack": "1.4.8", "cmake-pear": "2.0.0", "concurrently": "9.2.1", "cross-env": "^10.1.0", "electron": "^33.0.0", "electron-builder": "^26.8.1", "electron-forge-plugin-prune-prebuilds": "^1.0.0", "electron-forge-plugin-universal-prebuilds": "^1.0.0", "electron-reload": "^2.0.0-alpha.1", "esbuild": "^0.24.2", "eslint": "^9.39.3", "eslint-config-prettier": "9.1.0", "eslint-plugin-eslint-plugin": "6.4.0", "eslint-plugin-import": "2.31.0", "eslint-plugin-prettier": "5.2.1", "eslint-plugin-react": "7.37.4", "globals": "15.14.0", "husky": "9.1.7", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", "nodemon": "^3.0.0", "patch-package": "^8.0.1", "pear-build": "^1.1.0", "pear-interface": "1.0.0", "pear-runtime-updater": "^3.0.10", "prettier": "3.4.2", "typescript": "5.9.3", "wait-on": "^8.0.0", "which-runtime": "^1.3.2" }, "dependencies": { "@lingui/core": "5.1.2", "@lingui/format-json": "5.1.2", "@lingui/macro": "5.1.2", "@lingui/react": "5.1.2", "@rive-app/react-webgl2": "^4.28.1", "@tetherto/pear-apps-lib-feedback": "git+https://github.com/tetherto/pear-apps-lib-feedback.git", "@tetherto/pear-apps-lib-ui-react-hooks": "git+https://github.com/tetherto/pear-apps-lib-ui-react-hooks.git", "@tetherto/pear-apps-utils-avatar-initials": "git+https://github.com/tetherto/pear-apps-utils-avatar-initials.git", "@tetherto/pear-apps-utils-date": "git+https://github.com/tetherto/pear-apps-utils-date.git", "@tetherto/pear-apps-utils-generate-unique-id": "git+https://github.com/tetherto/pear-apps-utils-generate-unique-id.git", "@tetherto/pear-apps-utils-pattern-search": "git+https://github.com/tetherto/pear-apps-utils-pattern-search.git", "@tetherto/pear-apps-utils-qr": "git+https://github.com/tetherto/pear-apps-utils-qr.git", "@tetherto/pear-apps-utils-validator": "git+https://github.com/tetherto/pear-apps-utils-validator.git", "@tetherto/pearpass-lib-constants": "git+https://github.com/tetherto/pearpass-lib-constants.git", "@tetherto/pearpass-lib-data-export": "git+https://github.com/tetherto/pearpass-lib-data-export.git", "@tetherto/pearpass-lib-data-import": "git+https://github.com/tetherto/pearpass-lib-data-import.git", "@tetherto/pearpass-lib-native-messaging-bridge": "git+https://github.com/tetherto/pearpass-lib-native-messaging-bridge.git", "@tetherto/pearpass-lib-ui-kit": "git+https://github.com/tetherto/pearpass-lib-ui-react-native-components.git#52a0203fac624124e214ffa965eccb840c48d715", "@tetherto/pearpass-lib-ui-theme-provider": "git+https://github.com/tetherto/pearpass-lib-ui-theme-provider.git", "@tetherto/pearpass-lib-vault": "git+https://github.com/tetherto/pearpass-lib-vault.git", "@tetherto/pearpass-lib-vault-core": "git+https://github.com/tetherto/pearpass-lib-vault-core.git#de5403042d35c6f200c37c10c28adfc3c82151b1", "@tetherto/pearpass-utils-password-check": "git+https://github.com/tetherto/pearpass-utils-password-check.git", "@tetherto/pearpass-utils-password-generator": "git+https://github.com/tetherto/pearpass-utils-password-generator.git", "autopass": "~3.3.0", "bare-os": "3.6.2", "bare-subprocess": "5.2.1", "buffer": "6.0.3", "compact-encoding": "2.18.0", "dotenv": "17.2.1", "electron-updater": "^6.8.3", "htm": "3.1.1", "hyperblobs": "^2.11.1", "hyperdht": "^6.29.6", "hyperdrive": "^13.3.2", "jszip": "3.10.1", "mirror-drive": "^1.14.1", "pear-aliases": "1.0.6", "pear-bridge": "1.2.4", "pear-electron": "1.7.25-rc.0", "pear-ipc": "6.4.0", "pear-run": "1.0.5", "pear-runtime": "^1.1.1", "pear-runtime-legacy-storage": "^1.1.0", "react": "19.1.0", "react-dom": "19.1.0", "styled-components": "6.1.19" }, "optionalDependencies": { "@esbuild/win32-x64": "0.24.2" }, "overrides": { "@xmldom/xmldom": "^0.8.10", "react": "19.1.0", "react-dom": "19.1.0", "electron-windows-msix": "git+https://github.com/chetasr/electron-windows-msix.git" } } ================================================ FILE: scripts/afterPack.cjs ================================================ #!/usr/bin/env node /** * Electron-builder afterPack hook. * * 1. Prune native `prebuilds/-` dirs that don't match the * current target. Native modules in the bare/holepunch ecosystem ship * prebuilds for every platform (darwin/linux/win32/android/ios × x64/arm64 * + simulators). * * 2. Linux only: remove chrome-sandbox (squashfs/AppImage cannot preserve * SUID bits, so Chromium's setuid sandbox probe crashes before Node.js * even starts) and wrap the real Electron binary with a shell script that * passes --no-sandbox. app.commandLine.appendSwitch('no-sandbox') in * main.cjs is too late — Chromium's zygote sandbox decision happens * before Node.js initializes. The wrapper ensures --no-sandbox reaches * Chromium at process start. */ const fs = require('fs') const fsp = require('fs/promises') const path = require('path') const ARCH_NAMES = { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' } exports.default = async function afterPack(context) { await prunePrebuilds(context) if (context.electronPlatformName === 'linux') { await wrapLinuxNoSandbox(context) } } async function prunePrebuilds(context) { const { appOutDir, electronPlatformName, arch, packager } = context const archName = ARCH_NAMES[arch] ?? String(arch) const target = `${electronPlatformName}-${archName}` let appRoot if (electronPlatformName === 'darwin' || electronPlatformName === 'mas') { const appName = packager.appInfo.productFilename appRoot = path.join( appOutDir, `${appName}.app`, 'Contents', 'Resources', 'app' ) } else { appRoot = path.join(appOutDir, 'resources', 'app') } const nodeModules = path.join(appRoot, 'node_modules') if (!fs.existsSync(nodeModules)) { console.warn( `afterPack: node_modules not found at ${nodeModules}, skipping prune` ) return } const stats = { kept: 0, removed: 0, bytes: 0 } await pruneTree(appRoot, target, stats) const mb = (stats.bytes / (1024 * 1024)).toFixed(1) console.log( `afterPack: pruned ${stats.removed} prebuild dir(s), kept ${stats.kept} ` + `matching '${target}'/-universal, freed ~${mb} MB` ) } async function pruneTree(base, target, stats) { await pruneOneLevel(path.join(base, 'prebuilds'), target, stats) const nm = path.join(base, 'node_modules') const entries = await safeReadDir(nm) await Promise.all( entries.map(async (entry) => { if (!entry.isDirectory()) return const full = path.join(nm, entry.name) if (entry.name.startsWith('@')) { const subs = await safeReadDir(full) await Promise.all( subs.map((sub) => sub.isDirectory() ? pruneTree(path.join(full, sub.name), target, stats) : null ) ) } else { await pruneTree(full, target, stats) } }) ) } async function pruneOneLevel(dir, target, stats) { const entries = await safeReadDir(dir) for (const entry of entries) { if (!entry.isDirectory()) continue if (entry.name === target || entry.name.endsWith('-universal')) { stats.kept++ continue } const full = path.join(dir, entry.name) stats.bytes += await dirSize(full) await fsp.rm(full, { recursive: true, force: true }) stats.removed++ } } async function safeReadDir(dir) { try { return await fsp.readdir(dir, { withFileTypes: true }) } catch { return [] } } async function dirSize(dir) { let total = 0 const stack = [dir] while (stack.length) { const d = stack.pop() let entries try { entries = await fsp.readdir(d, { withFileTypes: true }) } catch { continue } for (const e of entries) { const full = path.join(d, e.name) try { if (e.isDirectory()) stack.push(full) else { const s = await fsp.stat(full) total += (s.blocks ?? 0) * 512 || s.size } } catch {} } } return total } async function wrapLinuxNoSandbox(context) { const appOutDir = context.appOutDir // 1. Remove chrome-sandbox (cannot work inside squashfs / AppImage) const sandboxBin = path.join(appOutDir, 'chrome-sandbox') if (fs.existsSync(sandboxBin)) { fs.unlinkSync(sandboxBin) console.log(`afterPack: removed ${sandboxBin}`) } // 2. Wrap the real binary so --no-sandbox is on the command line // before Chromium's early startup const execName = context.packager.executableName const realBin = path.join(appOutDir, execName) const renamedBin = path.join(appOutDir, `${execName}.bin`) if (!fs.existsSync(realBin)) { console.warn( `afterPack: executable not found at ${realBin}, skipping wrapper` ) return } fs.renameSync(realBin, renamedBin) const wrapper = [ '#!/bin/bash', `exec "$(dirname "$(readlink -f "$0")")/${execName}.bin" --no-sandbox "$@"`, '' ].join('\n') fs.writeFileSync(realBin, wrapper, { mode: 0o755 }) console.log(`afterPack: created --no-sandbox wrapper for ${execName}`) } ================================================ FILE: scripts/apply-flavor.mjs ================================================ #!/usr/bin/env node /** * Apply build-flavor branding to the working tree. CI-only: mutates icon * files and config files in place when PEARPASS_FLAVOR=nightly. * * Swaps nightly icons over the stable paths, rewrites productName/appId in * package.json + electron-builder configs, and patches the Windows MSIX * AppxManifest.xml identity + display names. No-op for any other flavor. */ import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.resolve(__dirname, '..') const flavor = process.env.PEARPASS_FLAVOR || 'release' if (flavor === 'release') { process.exit(0) } if (flavor !== 'nightly') { console.error(`[apply-flavor] Unknown PEARPASS_FLAVOR="${flavor}"`) process.exit(1) } const STABLE_NAME = 'PearPass' const NIGHTLY_NAME = 'PearPass-nightly' const STABLE_APP_ID = 'com.pears.pass' const NIGHTLY_APP_ID = 'com.pears.pass.nightly' const MSIX_STABLE_IDENTITY = 'PearPass' const MSIX_NIGHTLY_IDENTITY = 'PearPass-Nightly' const iconSwaps = [ ['assets/darwin/icon-nightly.png', 'assets/darwin/icon.png'], ['assets/linux/icon-nightly.png', 'assets/linux/icon.png'], ['assets/win32/icon-nightly.png', 'assets/win32/icon.png'], ['assets/win32/icon-nightly.ico', 'assets/win32/icon.ico'], ['build-assets/icon/PearPass-nightly.png', 'build-assets/icon/PearPass.png'] ] for (const [from, to] of iconSwaps) { const src = path.join(root, from) const dst = path.join(root, to) if (!fs.existsSync(src)) { console.error(`[apply-flavor] Missing nightly asset: ${from}`) process.exit(1) } fs.copyFileSync(src, dst) console.log(`[apply-flavor] swapped ${to} <- ${from}`) } function rewriteJson(relPath, mutate) { const abs = path.join(root, relPath) const json = JSON.parse(fs.readFileSync(abs, 'utf8')) mutate(json) fs.writeFileSync(abs, JSON.stringify(json, null, 2) + '\n', 'utf8') console.log(`[apply-flavor] rewrote ${relPath}`) } rewriteJson('package.json', (pkg) => { if (pkg.productName !== STABLE_NAME) { throw new Error( `[apply-flavor] package.json productName is "${pkg.productName}", expected "${STABLE_NAME}"` ) } pkg.productName = NIGHTLY_NAME if (pkg.build?.appId === STABLE_APP_ID) { pkg.build.appId = NIGHTLY_APP_ID } }) rewriteJson('electron-builder.mac.json', (cfg) => { if (cfg.appId === STABLE_APP_ID) cfg.appId = NIGHTLY_APP_ID }) rewriteJson('electron-builder.linux.json', (cfg) => { if (cfg.appId === STABLE_APP_ID) cfg.appId = NIGHTLY_APP_ID }) const manifestPath = path.join(root, 'build-assets/win/AppxManifest.xml') let manifest = fs.readFileSync(manifestPath, 'utf8') // Order matters: Name="PearPass" is a substring of DisplayName="PearPass", so // swap the more specific tokens first to avoid collateral rewrites. const manifestSwaps = [ [`DisplayName="${MSIX_STABLE_IDENTITY}"`, `DisplayName="${NIGHTLY_NAME}"`], [ `${MSIX_STABLE_IDENTITY}`, `${NIGHTLY_NAME}` ], // electron-packager derives the .exe name from productName, so the manifest // must point at PearPass-nightly.exe once productName is rewritten. [`app\\${STABLE_NAME}.exe`, `app\\${NIGHTLY_NAME}.exe`], [`Alias="${STABLE_NAME}.exe"`, `Alias="${NIGHTLY_NAME}.exe"`], [`Name="${MSIX_STABLE_IDENTITY}"`, `Name="${MSIX_NIGHTLY_IDENTITY}"`] ] for (const [from, to] of manifestSwaps) { if (!manifest.includes(from)) { throw new Error( `[apply-flavor] AppxManifest.xml missing expected token: ${from}` ) } manifest = manifest.split(from).join(to) } fs.writeFileSync(manifestPath, manifest, 'utf8') console.log('[apply-flavor] rewrote build-assets/win/AppxManifest.xml') console.log(`[apply-flavor] flavor="${flavor}" applied`) ================================================ FILE: scripts/build-flatpak.sh ================================================ #!/bin/bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" FLATPAK_DIR="$PROJECT_DIR/flatpak" BUILD_DIR="$PROJECT_DIR/build/flatpak" APPIMAGE_PATH="" ARCH="" VERSION="" log_info() { echo -e "\033[1;34m[INFO]\033[0m $*"; } log_ok() { echo -e "\033[1;32m[OK]\033[0m $*"; } log_error() { echo -e "\033[1;31m[ERROR]\033[0m $*"; } usage() { cat < [--arch arm64|x64] Options: --local Path to a locally-built AppImage (required) --arch Target architecture (default: auto-detect) -h, --help Show this help message Example: $(basename "$0") --local ./dist/PearPass-1.6.0-arm64.AppImage EOF exit 0 } detect_arch() { local machine machine="$(uname -m)" case "$machine" in x86_64) echo "x64" ;; aarch64|arm64) echo "arm64" ;; *) log_error "Unsupported architecture: $machine"; exit 1 ;; esac } check_prerequisites() { local missing=() command -v flatpak >/dev/null 2>&1 || missing+=("flatpak") command -v flatpak-builder >/dev/null 2>&1 || missing+=("flatpak-builder") if (( ${#missing[@]} )); then log_error "Missing tools: ${missing[*]}" log_error "Install with: sudo apt install ${missing[*]}" exit 1 fi # Check runtime & SDK if ! flatpak info org.gnome.Platform//49 >/dev/null 2>&1; then log_error "Missing org.gnome.Platform//49" log_error "Install: flatpak install flathub org.gnome.Platform//49" exit 1 fi if ! flatpak info org.gnome.Sdk//49 >/dev/null 2>&1; then log_error "Missing org.gnome.Sdk//49" log_error "Install: flatpak install flathub org.gnome.Sdk//49" exit 1 fi log_ok "Prerequisites satisfied" } parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --local) APPIMAGE_PATH="$2"; shift 2 ;; --arch) ARCH="$2"; shift 2 ;; -h|--help) usage ;; *) log_error "Unknown option: $1"; usage ;; esac done if [[ -z "$APPIMAGE_PATH" ]]; then log_error "--local is required" usage fi # Resolve to absolute path without requiring the parent directory to exist. case "$APPIMAGE_PATH" in /*) ;; *) APPIMAGE_PATH="$PWD/$APPIMAGE_PATH" ;; esac if [[ ! -f "$APPIMAGE_PATH" ]]; then local fallback_out="$PROJECT_DIR/out/PearPass.AppImage" local fallback_dist="$PROJECT_DIR/dist/PearPass.AppImage" if [[ "$(basename "$APPIMAGE_PATH")" == "PearPass.AppImage" && -f "$fallback_out" ]]; then APPIMAGE_PATH="$fallback_out" log_info "Using detected AppImage: $APPIMAGE_PATH" elif [[ "$(basename "$APPIMAGE_PATH")" == "PearPass.AppImage" && -f "$fallback_dist" ]]; then APPIMAGE_PATH="$fallback_dist" log_info "Using detected AppImage: $APPIMAGE_PATH" else log_error "AppImage not found: $APPIMAGE_PATH" log_error "Expected output is usually ./out/PearPass.AppImage after dist:linux build" exit 1 fi fi [[ -z "$ARCH" ]] && ARCH="$(detect_arch)" VERSION="$(jq -r '.version' "$PROJECT_DIR/package.json")" log_info "AppImage : $APPIMAGE_PATH" log_info "Arch : $ARCH" log_info "Version : $VERSION" } prepare_icon() { log_info "Extracting and resizing icon from AppImage ..." local tmpdir local src_icon="" tmpdir="$(mktemp -d)" cp "$APPIMAGE_PATH" "$tmpdir/PearPass.AppImage" chmod +x "$tmpdir/PearPass.AppImage" (cd "$tmpdir" && ./PearPass.AppImage --appimage-extract >/dev/null 2>&1) local dst_icon="$FLATPAK_DIR/icon-512.png" for candidate in \ "$tmpdir/squashfs-root/resources/assets/linux/icon.png" \ "$tmpdir/squashfs-root/usr/share/icons/PearPass.png" do if [[ -f "$candidate" ]]; then src_icon="$candidate" break fi done if [[ -z "$src_icon" ]]; then log_error "Icon not found inside AppImage (checked resources/assets/linux/icon.png and usr/share/icons/PearPass.png)" rm -rf "$tmpdir" exit 1 fi # Resize to 512x512 using ffmpeg (widely available) if command -v ffmpeg >/dev/null 2>&1; then ffmpeg -y -i "$src_icon" -vf "scale=512:512" "$dst_icon" 2>/dev/null elif command -v convert >/dev/null 2>&1; then convert "$src_icon" -resize 512x512 "$dst_icon" elif command -v magick >/dev/null 2>&1; then magick "$src_icon" -resize 512x512 "$dst_icon" else log_error "No image resize tool found (need ffmpeg, imagemagick convert, or magick)" rm -rf "$tmpdir" exit 1 fi rm -rf "$tmpdir" log_ok "Icon resized to 512x512: $dst_icon" } build_flatpak() { log_info "Staging AppImage into flatpak/appimage/ ..." mkdir -p "$FLATPAK_DIR/appimage" cp "$APPIMAGE_PATH" "$FLATPAK_DIR/appimage/PearPass.AppImage" chmod +x "$FLATPAK_DIR/appimage/PearPass.AppImage" log_info "Building flatpak ..." mkdir -p "$BUILD_DIR" flatpak-builder --force-clean \ --repo="$BUILD_DIR/repo" \ "$BUILD_DIR/build-dir" \ "$FLATPAK_DIR/com.pears.pass.yaml" log_ok "Build complete. Creating bundle ..." local bundle_name="pearpass_${VERSION}_${ARCH}.flatpak" flatpak build-bundle \ "$BUILD_DIR/repo" \ "$BUILD_DIR/$bundle_name" \ com.pears.pass log_ok "Flatpak bundle: $BUILD_DIR/$bundle_name" ls -lh "$BUILD_DIR/$bundle_name" } cleanup() { log_info "Cleaning staging files ..." rm -f "$FLATPAK_DIR/appimage/PearPass.AppImage" rm -f "$FLATPAK_DIR/icon-512.png" } # ── Main ──────────────────────────────────────────────────────────────── parse_args "$@" check_prerequisites prepare_icon build_flatpak cleanup log_ok "Done!" ================================================ FILE: scripts/build-snap.sh ================================================ #!/bin/bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" SNAP_DIR="$PROJECT_DIR/snap" LOCAL_DIR="$SNAP_DIR/local" BUILD_DIR="$PROJECT_DIR/build/snap" APPIMAGE_PATH="" UNPACKED_PATH="" ARCH="" VERSION="" SNAP_OUT="" log_info() { echo -e "\033[1;34m[INFO]\033[0m $*"; } log_ok() { echo -e "\033[1;32m[OK]\033[0m $*"; } log_error() { echo -e "\033[1;31m[ERROR]\033[0m $*"; } usage() { cat < | --local ) [--arch arm64|x64] Options: --unpacked
Path to electron-builder's linux-*-unpacked directory (preferred — skips AppImage round-trip and avoids mksquashfs glibc issues on newer hosts). --local Path to a locally-built AppImage. Used as a fallback when --unpacked is unavailable. --arch Target architecture (default: auto-detect) -h, --help Show this help message Examples: $(basename "$0") --unpacked ./out/linux-arm64-unpacked $(basename "$0") --local ./out/PearPass.AppImage EOF exit 0 } detect_arch() { local machine machine="$(uname -m)" case "$machine" in x86_64) echo "amd64" ;; aarch64|arm64) echo "arm64" ;; *) log_error "Unsupported architecture: $machine"; exit 1 ;; esac } check_prerequisites() { local missing=() command -v snapcraft >/dev/null 2>&1 || missing+=("snapcraft") if (( ${#missing[@]} )); then log_error "Missing tools: ${missing[*]}" log_error "Install snapcraft: sudo snap install snapcraft --classic" exit 1 fi # snapcraft 8+ on core22 needs lxd (default) or multipass as a backend. if ! command -v lxd >/dev/null 2>&1 && ! command -v multipass >/dev/null 2>&1; then log_error "snapcraft needs an LXD or Multipass backend" log_error " sudo snap install lxd && sudo lxd init --auto && sudo usermod -aG lxd \$USER" log_error " (then log out and back in, or run: newgrp lxd)" exit 1 fi log_ok "Prerequisites satisfied" } parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --local) APPIMAGE_PATH="${2:?--local requires a path}"; shift 2 ;; --unpacked) # Bare --unpacked → auto-detect below. --unpacked → explicit. if [[ $# -ge 2 && "$2" != --* ]]; then UNPACKED_PATH="$2"; shift 2 else shift 1 fi ;; --arch) ARCH="${2:?--arch requires a value}"; shift 2 ;; -h|--help) usage ;; *) log_error "Unknown option: $1"; usage ;; esac done [[ -z "$ARCH" ]] && ARCH="$(detect_arch)" if [[ -z "$UNPACKED_PATH" && -z "$APPIMAGE_PATH" ]]; then # Match electron-builder's naming: linux-arm64-unpacked for arm64, # linux-unpacked for x64. case "$ARCH" in arm64) arch_dir="linux-arm64-unpacked" ;; amd64|x64) arch_dir="linux-unpacked" ;; *) log_error "Unsupported arch for auto-detect: $ARCH"; exit 1 ;; esac for candidate in "$PROJECT_DIR/out/$arch_dir" "$PROJECT_DIR/dist/$arch_dir"; do if [[ -d "$candidate" ]]; then UNPACKED_PATH="$candidate" log_info "Auto-detected unpacked dir: $UNPACKED_PATH" break fi done fi if [[ -z "$UNPACKED_PATH" && -z "$APPIMAGE_PATH" ]]; then log_error "Pass --unpacked or --local " usage fi if [[ -n "$UNPACKED_PATH" ]]; then case "$UNPACKED_PATH" in /*) ;; *) UNPACKED_PATH="$PWD/$UNPACKED_PATH" ;; esac if [[ ! -d "$UNPACKED_PATH" ]]; then log_error "Unpacked directory not found: $UNPACKED_PATH" exit 1 fi elif [[ -n "$APPIMAGE_PATH" ]]; then case "$APPIMAGE_PATH" in /*) ;; *) APPIMAGE_PATH="$PWD/$APPIMAGE_PATH" ;; esac if [[ ! -f "$APPIMAGE_PATH" ]]; then log_error "AppImage not found: $APPIMAGE_PATH" log_error "Build one first with: npm run dist:linux:" exit 1 fi fi VERSION="$(jq -r '.version' "$PROJECT_DIR/package.json")" log_info "Source : ${UNPACKED_PATH:-$APPIMAGE_PATH}" log_info "Arch : $ARCH" log_info "Version : $VERSION" } stage_sources() { mkdir -p "$LOCAL_DIR" # Clean stale staging so unpacked/ wins deterministically over PearPass.AppImage. rm -rf "$LOCAL_DIR/unpacked" "$LOCAL_DIR/PearPass.AppImage" if [[ -n "$UNPACKED_PATH" ]]; then log_info "Staging unpacked Electron payload into snap/local/unpacked/ ..." # Hard-link to avoid duplicating ~1 GB on the same filesystem. cp -al "$UNPACKED_PATH" "$LOCAL_DIR/unpacked" else log_info "Staging AppImage into snap/local/ ..." cp "$APPIMAGE_PATH" "$LOCAL_DIR/PearPass.AppImage" chmod +x "$LOCAL_DIR/PearPass.AppImage" fi } build_snap() { log_info "Building snap (this may take several minutes on first run) ..." mkdir -p "$BUILD_DIR" cd "$PROJECT_DIR" # `--output ` is unreliable across snapcraft+LXD versions, # so we glob the result in PROJECT_DIR instead. snapcraft pack local OUT OUT="$(ls -1t "$PROJECT_DIR"/pearpass_*_"${ARCH}".snap 2>/dev/null | head -n1)" if [[ -z "$OUT" || ! -f "$OUT" ]]; then log_error "snapcraft reported success but no pearpass_*_${ARCH}.snap was found in $PROJECT_DIR" exit 1 fi SNAP_OUT="$BUILD_DIR/$(basename "$OUT")" mv -f "$OUT" "$SNAP_OUT" log_ok "Snap bundle: $SNAP_OUT" ls -lh "$SNAP_OUT" } cleanup() { log_info "Cleaning staging files ..." rm -rf "$LOCAL_DIR/unpacked" rm -f "$LOCAL_DIR/PearPass.AppImage" } # ── Main ──────────────────────────────────────────────────────────────── parse_args "$@" check_prerequisites stage_sources build_snap cleanup SNAP_NAME="$(awk '/^name:/ {print $2; exit}' "$SNAP_DIR/snapcraft.yaml")" log_ok "Done!" log_info "Install with:" log_info " sudo snap install --dangerous $SNAP_OUT" log_info " sudo snap connect ${SNAP_NAME}:browser-native-messaging" ================================================ FILE: scripts/build.worklet.mjs ================================================ #!/usr/bin/env node /* eslint-disable no-underscore-dangle */ /** * Bundle the vault worklet (ESM) to CommonJS for the packaged app so Bare can run it. * Only worklet source is bundled; all node_modules are external so Bare resolves them at runtime */ import path from 'path' import { fileURLToPath } from 'url' import * as esbuild from 'esbuild' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.join(__dirname, '..') const workletDir = path.join( root, 'node_modules', '@tetherto/pearpass-lib-vault-core', 'src', 'worklet' ) /** Externalize any package (non-relative specifier) so node_modules are never inlined. */ const externalizeNodeModules = { name: 'externalize-node-modules', setup(build) { build.onResolve({ filter: /^[^./]/ }, (args) => { // Skip Windows absolute paths (e.g. C:\, F:\) if (/^[A-Za-z]:[/\\]/.test(args.path)) return null return { path: args.path, external: true } }) } } async function buildWorklet() { await esbuild.build({ entryPoints: [path.join(workletDir, 'app.js')], bundle: true, platform: 'node', format: 'cjs', target: 'node18', outfile: path.join(workletDir, 'app.cjs'), plugins: [externalizeNodeModules], alias: { fs: 'bare-fs', path: 'bare-path', buffer: 'bare-buffer', crypto: 'bare-crypto', os: 'bare-os' }, logLevel: 'info' }) } buildWorklet() ================================================ FILE: scripts/bundle-bridge.mjs ================================================ #!/usr/bin/env node /** * Bundle the native messaging bridge into a single CJS file for Electron (ELECTRON_RUN_AS_NODE). * Node built-ins and pear-ipc are external: they resolve at runtime. */ import * as esbuild from 'esbuild' import path from 'path' import { fileURLToPath } from 'url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.join(__dirname, '..') const watch = process.argv.includes('--watch') const ctx = await esbuild.context({ entryPoints: [ path.join( root, 'node_modules', '@tetherto', 'pearpass-lib-native-messaging-bridge', 'index.js' ) ], bundle: true, outfile: path.join(root, 'dist', 'native-messaging-bridge.bundle.cjs'), platform: 'node', target: ['node18'], format: 'cjs', external: [ 'fs', 'fs/promises', 'path', 'os', 'net', 'events', 'crypto', 'child_process', 'pear-ipc' ], logLevel: 'info' }) if (watch) { await ctx.watch() console.log('Watching for changes...') } else { await ctx.rebuild() ctx.dispose() } ================================================ FILE: scripts/bundle-renderer.mjs ================================================ #!/usr/bin/env node /** * Bundle the renderer (app.electron.tsx + deps) into a single file for Electron. * Node built-ins are external: they resolve at runtime in the renderer (nodeIntegration: true). */ import * as esbuild from 'esbuild' import { readFile } from 'fs/promises' import { createRequire } from 'module' import { fileURLToPath } from 'url' import path from 'path' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.join(__dirname, '..') const watch = process.argv.includes('--watch') const require = createRequire(import.meta.url) const postcss = require('postcss') const babel = require('@babel/core') const fg = require('fast-glob') const reactStrictDomPostcssPlugin = require('react-strict-dom/postcss-plugin') const strictDomBabelConfig = require(path.join(root, 'babel.strict-dom.cjs')) const strictDomCssInclude = [ 'app.electron.tsx', 'src/**/*.{js,jsx,mjs,ts,tsx}', 'node_modules/@tetherto/pearpass-lib-ui-kit/dist/**/*.js' ] const strictDomRuntimePaths = [ `${path.sep}node_modules${path.sep}react-strict-dom${path.sep}dist${path.sep}`, `${path.sep}node_modules${path.sep}@tetherto${path.sep}pearpass-lib-ui-kit${path.sep}dist${path.sep}` ] function shouldTransformStrictDomRuntime(filePath) { return strictDomRuntimePaths.some((runtimePath) => filePath.includes(runtimePath)) } function getLoader(filePath) { if (filePath.endsWith('.tsx')) return 'tsx' if (filePath.endsWith('.ts')) return 'ts' if (filePath.endsWith('.jsx')) return 'jsx' return 'js' } function strictDomBabelPlugin() { return { name: 'strict-dom-babel', setup(build) { build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (args) => { if (!shouldTransformStrictDomRuntime(args.path)) return null const source = await readFile(args.path, 'utf8') const transformed = await babel.transformAsync(source, { filename: args.path, sourceMaps: true, ...strictDomBabelConfig }) return { contents: transformed?.code ?? source, loader: getLoader(args.path), resolveDir: path.dirname(args.path) } }) } } } function strictDomCssPlugin() { return { name: 'strict-dom-css', setup(build) { build.onLoad({ filter: /strict\.css$/ }, async (args) => { const source = await readFile(args.path, 'utf8') const watchFiles = fg.sync(strictDomCssInclude, { cwd: root, absolute: true, onlyFiles: true, dot: false }) const result = await postcss([ reactStrictDomPostcssPlugin({ cwd: root, include: strictDomCssInclude, babelConfig: strictDomBabelConfig, useCSSLayers: true }) ]).process(source, { from: args.path }) return { contents: result.css, loader: 'css', resolveDir: path.dirname(args.path), watchFiles } }) } } } const ctx = await esbuild.context({ entryPoints: [path.join(root, 'app.electron.tsx')], bundle: true, outfile: path.join(root, 'dist', 'renderer.bundle.js'), platform: 'browser', target: ['es2020'], format: 'iife', sourcemap: true, define: { 'process.env.NODE_ENV': JSON.stringify( process.env.NODE_ENV || 'development' ) }, loader: { '.js': 'jsx', '.mjs': 'js' }, resolveExtensions: [ '.web.mjs', '.web.js', '.web.mts', '.web.ts', '.web.jsx', '.web.tsx', '.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.css', '.json' ], plugins: [strictDomBabelPlugin(), strictDomCssPlugin()], jsx: 'automatic', alias: { react: path.join(root, 'node_modules', 'react'), 'react-dom': path.join(root, 'node_modules', 'react-dom') }, external: [ 'fs', 'path', 'os', 'net', 'crypto', 'child_process', 'fs/promises', 'require-addon', 'fs-native-extensions', 'sodium-native', 'crypto' ], logLevel: 'info' }) if (watch) { await ctx.watch() console.log('Watching for changes...') } else { await ctx.rebuild() ctx.dispose() } ================================================ FILE: scripts/create-linux-tarball.sh ================================================ #!/bin/bash # PearPass Desktop - Create Linux tar.gz archive from AppImage # Creates a consistent archive structure for Flatpak/Snapcraft builds # # Usage: # ./scripts/create-linux-tarball.sh [--appimage PATH] [--arch x64|arm64] [--output DIR] # # Default: # --appimage: appling/PearPass.AppImage # --arch: auto-detect from current system # --output: build/ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # Default values APPIMAGE_PATH="${PROJECT_ROOT}/appling/PearPass.AppImage" ARCH="" OUTPUT_DIR="${PROJECT_ROOT}/build" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } detect_arch() { local machine=$(uname -m) case "$machine" in x86_64) echo "x64" ;; aarch64|arm64) echo "arm64" ;; *) log_error "Unsupported architecture: $machine"; exit 1 ;; esac } parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --appimage) APPIMAGE_PATH="$2" shift 2 ;; --arch) ARCH="$2" shift 2 ;; --output) OUTPUT_DIR="$2" shift 2 ;; -h|--help) echo "Usage: $0 [--appimage PATH] [--arch x64|arm64] [--output DIR]" exit 0 ;; *) log_error "Unknown option: $1" exit 1 ;; esac done if [[ -z "$ARCH" ]]; then ARCH=$(detect_arch) fi } main() { parse_args "$@" # Validate AppImage exists if [[ ! -f "$APPIMAGE_PATH" ]]; then log_error "AppImage not found: $APPIMAGE_PATH" exit 1 fi log_info "Using AppImage: $APPIMAGE_PATH" log_info "Target architecture: $ARCH" # Create temp directory for extraction EXTRACT_DIR=$(mktemp -d) TARBALL_DIR=$(mktemp -d) trap "rm -rf '$EXTRACT_DIR' '$TARBALL_DIR'" EXIT # Extract AppImage log_info "Extracting AppImage..." cd "$EXTRACT_DIR" chmod +x "$APPIMAGE_PATH" "$APPIMAGE_PATH" --appimage-extract > /dev/null 2>&1 # Create tarball structure mkdir -p "$TARBALL_DIR/lib" mkdir -p "$TARBALL_DIR/pear-pass" # Find and copy binary log_info "Copying binary..." BINARY_PATH=$(find squashfs-root -name "pear-pass" -type f -executable 2>/dev/null | head -n 1) if [[ -z "$BINARY_PATH" ]]; then BINARY_PATH=$(find squashfs-root -name "pearpass" -type f -executable 2>/dev/null | head -n 1) fi if [[ -n "$BINARY_PATH" ]]; then cp "$BINARY_PATH" "$TARBALL_DIR/pearpass" chmod +x "$TARBALL_DIR/pearpass" log_info " Found binary: $BINARY_PATH" else log_error "Could not find main binary in AppImage" log_info "Available executables:" find squashfs-root -type f -executable exit 1 fi # Find and copy app.bundle log_info "Copying app.bundle..." BUNDLE_PATH=$(find squashfs-root -name "app.bundle" -type f 2>/dev/null | head -n 1) if [[ -n "$BUNDLE_PATH" ]]; then cp "$BUNDLE_PATH" "$TARBALL_DIR/pear-pass/app.bundle" log_info " Found bundle: $BUNDLE_PATH" else log_warn "app.bundle not found in AppImage" fi # Find and copy native .so libraries log_info "Copying native libraries..." SO_COUNT=0 while IFS= read -r -d '' so_file; do cp "$so_file" "$TARBALL_DIR/lib/" ((SO_COUNT++)) done < <(find squashfs-root -name "*.so" -type f -print0 2>/dev/null) || true log_info " Copied $SO_COUNT libraries" # Show contents log_info "Archive contents:" find "$TARBALL_DIR" -type f | sed 's|'"$TARBALL_DIR"'| |' # Create output directory and tarball mkdir -p "$OUTPUT_DIR" TARBALL_NAME="PearPass.linux.${ARCH}.bin.tar.gz" cd "$TARBALL_DIR" tar -czvf "$OUTPUT_DIR/$TARBALL_NAME" . > /dev/null log_info "Created: $OUTPUT_DIR/$TARBALL_NAME" log_info "Done!" } main "$@" ================================================ FILE: scripts/notarize.cjs ================================================ #!/usr/bin/env node /** * Electron-builder afterSign hook: notarize the macOS app using the "notary" keychain profile. * CI must run: xcrun notarytool store-credentials "notary" ... before the build. */ const path = require('path') const { notarize } = require('@electron/notarize') exports.default = async function notarizeHook(context) { const { electronPlatformName, appOutDir, packager } = context if (electronPlatformName !== 'darwin') return if (process.env.SKIP_NOTARIZE === 'true') { console.log('Skipping notarization') return } const appName = packager.appInfo.productFilename const appPath = path.join(appOutDir, `${appName}.app`) await notarize({ appPath, keychainProfile: 'notary', tool: 'notarytool' }) } ================================================ FILE: scripts/patch-electron-dock-name.mjs ================================================ #!/usr/bin/env node /* eslint-disable no-underscore-dangle */ /** * On macOS, make the dock show "PearPass" when running `electron .` in development. * The dock uses the .app bundle *folder name*, not Info.plist. So we: * 1. Rename Electron.app -> PearPass.app * 2. Update electron's path.txt so it launches PearPass.app * 3. Patch Info.plist in the renamed bundle and clear quarantine (avoid translocation) * Run automatically after npm install (postinstall) on darwin only. */ import { execSync } from 'child_process' import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.resolve(__dirname, '..') const dist = path.join(root, 'node_modules', 'electron', 'dist') const electronApp = path.join(dist, 'Electron.app') const pathTxt = path.join(root, 'node_modules', 'electron', 'path.txt') if (process.platform !== 'darwin') { process.exit(0) } let appName = 'PearPass' try { const pkg = JSON.parse( fs.readFileSync(path.join(root, 'package.json'), 'utf8') ) appName = pkg.build?.productName || pkg.productName || appName } catch { // keep default } // Sanitize for folder name (no slashes) const appFolderName = `${appName}.app` const renamedApp = path.join(dist, appFolderName) if (!fs.existsSync(electronApp)) { // Already patched (renamed) or electron not installed if (fs.existsSync(renamedApp)) { process.exit(0) } process.exit(0) } try { // 1. Rename Electron.app -> PearPass.app (dock uses .app folder name) fs.renameSync(electronApp, renamedApp) console.log( `[patch-electron-dock-name] Renamed Electron.app -> ${appFolderName}` ) // 2. Update path.txt so "electron" CLI launches the renamed app (binary inside is still "Electron") const macPath = `${appFolderName}/Contents/MacOS/Electron` fs.writeFileSync(pathTxt, macPath, 'utf8') console.log(`[patch-electron-dock-name] Updated path.txt to ${macPath}`) // 3. Patch Info.plist in the renamed bundle const plistPath = path.join(renamedApp, 'Contents', 'Info.plist') const PlistBuddy = '/usr/libexec/PlistBuddy' execSync(`"${PlistBuddy}" -c "Set :CFBundleName ${appName}" "${plistPath}"`, { stdio: 'inherit' }) execSync( `"${PlistBuddy}" -c "Set :CFBundleDisplayName ${appName}" "${plistPath}"`, { stdio: 'inherit' } ) // 4. Clear quarantine so macOS doesn't translocate (run from copy with old name) execSync(`xattr -cr "${renamedApp}"`, { stdio: 'ignore' }) console.log( `[patch-electron-dock-name] Dock will show "${appName}". Quit the app and run again.` ) } catch (err) { console.warn('[patch-electron-dock-name] Failed:', err.message) process.exit(1) } ================================================ FILE: snap/snapcraft.yaml ================================================ name: pearpass adopt-info: pearpass title: PearPass summary: A secure, decentralized and fully local password manager description: | PearPass is a privacy-focused password manager. Vaults sync peer-to-peer between your devices via the Pear runtime — no cloud, no servers, no account. license: Apache-2.0 base: core22 grade: stable confinement: strict compression: lzo architectures: - build-on: amd64 - build-on: arm64 apps: pearpass: command: usr/bin/pearpass common-id: com.pears.pass desktop: usr/share/applications/com.pears.pass.desktop extensions: [gnome] plugs: - home - network - network-bind - desktop - desktop-legacy - wayland - x11 - unity7 - opengl - removable-media - browser-native-messaging native-host: # Browsers exec /snap/bin/pearpass.native-host directly (the manifest's # `path` field). snapd routes that to this app slot, which pipes JSON # over stdio through Electron's embedded Node. command: usr/bin/pearpass-native-host extensions: [gnome] plugs: - home - network - network-bind # Directory-scoped (not file-scoped): file scope's AppArmor expansion # `{,/,/**}` blocks mkdir of the parent, and browsers don't create # NativeMessagingHosts/ themselves (verified for host + snap Firefox 150). # Snap only writes com.pears.pass.json inside each granted dir. # brave/current resolves via snap-Brave's revision symlink — smoke-tested # with Firefox, retest under snap-Brave. plugs: browser-native-messaging: interface: personal-files write: - $HOME/.mozilla/native-messaging-hosts - $HOME/.config/chromium/NativeMessagingHosts - $HOME/.config/google-chrome/NativeMessagingHosts - $HOME/.config/microsoft-edge/NativeMessagingHosts - $HOME/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts - $HOME/snap/chromium/common/chromium/NativeMessagingHosts - $HOME/snap/firefox/common/.mozilla/native-messaging-hosts - $HOME/snap/brave/current/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts parts: pearpass: plugin: nil source: snap/local build-packages: - jq # stage-packages intentionally empty: Electron bundles ffmpeg/GL/vulkan, # the gnome extension + core22 base provide GTK/NSS/dbus. Add specific # .so files here if a missing-symbol error surfaces at runtime. override-build: | set -eux # Snap version follows package.json; `adopt-info: pearpass` lets # this part publish the value via craftctl. craftctl set version="$(jq -r .version "${CRAFT_PROJECT_DIR}/package.json")" # build-snap.sh stages the Electron payload as either snap/local/unpacked/ # (preferred, pre-extracted from electron-builder) or snap/local/PearPass.AppImage # (fallback, extracted here via AppRun). UNPACKED="${CRAFT_PART_SRC}/unpacked" APPIMAGE="${CRAFT_PART_SRC}/PearPass.AppImage" install -d "${CRAFT_PART_INSTALL}/lib/pearpass" if [ -d "${UNPACKED}" ]; then cp -a "${UNPACKED}/." "${CRAFT_PART_INSTALL}/lib/pearpass/" elif [ -f "${APPIMAGE}" ]; then WORKDIR="$(mktemp -d)" cp "${APPIMAGE}" "${WORKDIR}/PearPass.AppImage" chmod +x "${WORKDIR}/PearPass.AppImage" cd "${WORKDIR}" ./PearPass.AppImage --appimage-extract >/dev/null cp -a squashfs-root/. "${CRAFT_PART_INSTALL}/lib/pearpass/" cd - else echo "Expected ${UNPACKED}/ or ${APPIMAGE}" >&2 echo "Stage one via scripts/build-snap.sh, not snapcraft directly." >&2 exit 1 fi chmod +x "${CRAFT_PART_INSTALL}/lib/pearpass/pearpass-app-desktop" || true chmod +x "${CRAFT_PART_INSTALL}/lib/pearpass/pearpass-app-desktop.bin" || true install -d "${CRAFT_PART_INSTALL}/usr/bin" # Main GUI launcher. --no-sandbox + ozone X11 mirrors the flatpak # launcher; Wayland auto-detect inside snap is unreliable and X11 via # XWayland works on both session types. cat > "${CRAFT_PART_INSTALL}/usr/bin/pearpass" <<'WRAPPER' #!/bin/sh APP_ROOT="${SNAP}/lib/pearpass" export LD_LIBRARY_PATH="${APP_ROOT}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" export GDK_BACKEND=x11 cd "${APP_ROOT}" exec "${APP_ROOT}/pearpass-app-desktop" \ --no-sandbox \ --disable-gpu-sandbox \ --disable-dev-shm-usage \ --enable-features=UseOzonePlatform \ --ozone-platform=x11 \ "$@" WRAPPER chmod +x "${CRAFT_PART_INSTALL}/usr/bin/pearpass" # Native messaging host. Bypass the launcher (which forces # --no-sandbox) and call .bin directly: ELECTRON_RUN_AS_NODE rejects # --no-sandbox and Chrome would see "Native host has exited". cat > "${CRAFT_PART_INSTALL}/usr/bin/pearpass-native-host" <<'NMHOST' #!/bin/sh APP_ROOT="${SNAP}/lib/pearpass" export LD_LIBRARY_PATH="${APP_ROOT}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" export ELECTRON_RUN_AS_NODE=1 exec "${APP_ROOT}/pearpass-app-desktop.bin" \ "${APP_ROOT}/resources/app/dist/native-messaging-bridge.bundle.cjs" \ "$@" NMHOST chmod +x "${CRAFT_PART_INSTALL}/usr/bin/pearpass-native-host" # Reuse flatpak desktop entry + metainfo + icon; sed below patches # Exec/Icon for snap-resolved paths. install -Dm644 "${CRAFT_PROJECT_DIR}/flatpak/com.pears.pass.desktop" \ "${CRAFT_PART_INSTALL}/usr/share/applications/com.pears.pass.desktop" install -Dm644 "${CRAFT_PROJECT_DIR}/flatpak/com.pears.pass.metainfo.xml" \ "${CRAFT_PART_INSTALL}/usr/share/metainfo/com.pears.pass.metainfo.xml" install -Dm644 "${CRAFT_PROJECT_DIR}/assets/linux/icon.png" \ "${CRAFT_PART_INSTALL}/meta/gui/pearpass.png" sed -i 's|^Exec=.*|Exec=pearpass %U|' \ "${CRAFT_PART_INSTALL}/usr/share/applications/com.pears.pass.desktop" sed -i 's|^Icon=.*|Icon=${SNAP}/meta/gui/pearpass.png|' \ "${CRAFT_PART_INSTALL}/usr/share/applications/com.pears.pass.desktop" ================================================ FILE: src/app/App/appConfig.js ================================================ export const appConfig = { headerWithLogo: ['settings', 'welcome', 'intro'] } ================================================ FILE: src/app/App/hooks/useInactivity.js ================================================ import { useEffect, useRef } from 'react' import { closeAllInstances, useUserData, useVaults } from '@tetherto/pearpass-lib-vault' import { NAVIGATION_ROUTES } from '../../../constants/navigation' import { useLoadingContext } from '../../../context/LoadingContext' import { useModal } from '../../../context/ModalContext' import { useRouter } from '../../../context/RouterContext' import { getAutoLockTimeoutMs, useAutoLockPreferences } from '../../../hooks/useAutoLockPreferences' import { logger } from '../../../utils/logger' const DEDUPE_WINDOW_MS = 50 /** * @returns {void} */ export function useInactivity() { const lastResetAtRef = useRef(0) const { setIsLoading } = useLoadingContext() const { navigate } = useRouter() const { refetch: refetchUser } = useUserData() const { closeModal } = useModal() const resetTimerRef = useRef(() => {}) const { resetState } = useVaults() const timerRef = useRef(null) const { shouldBypassAutoLock } = useAutoLockPreferences() resetTimerRef.current = () => { if (shouldBypassAutoLock) { return } const now = Date.now() if (now - lastResetAtRef.current < DEDUPE_WINDOW_MS) { return } lastResetAtRef.current = now if (timerRef.current) { clearTimeout(timerRef.current) } const timeoutMs = getAutoLockTimeoutMs() if (timeoutMs === null) { return } timerRef.current = setTimeout(async () => { const userData = await refetchUser() logger.info( 'INACTIVITY-TIMER', `Inactivity timer triggered, user data: ${JSON.stringify(userData)}` ) if (!userData.isLoggedIn) { return } setIsLoading(true) await closeAllInstances() closeModal() navigate('welcome', { state: NAVIGATION_ROUTES.MASTER_PASSWORD }) resetState() setIsLoading(false) logger.info('INACTIVITY-TIMER', 'Inactivity timer reset') }, timeoutMs) } const resetTimer = () => resetTimerRef.current() const activityEvents = [ 'mousemove', 'keydown', 'mousedown', 'touchstart', 'scroll' ] useEffect(() => { if (shouldBypassAutoLock && timerRef.current) { clearTimeout(timerRef.current) timerRef.current = null } }, [shouldBypassAutoLock]) useEffect(() => { window.addEventListener('reset-timer', resetTimer) return () => { window.removeEventListener('reset-timer', resetTimer) } }, []) useEffect(() => { // Handler for IPC activity const handleIPCActivity = () => resetTimer() // Handler for settings changes - reset timer with new values const handleSettingsChange = () => resetTimer() activityEvents.forEach((event) => window.addEventListener(event, resetTimer) ) // Listen for IPC activity events window.addEventListener('ipc-activity', handleIPCActivity) // Listen for auto-lock settings changes window.addEventListener('auto-lock-settings-changed', handleSettingsChange) resetTimer() return () => { activityEvents.forEach((event) => window.removeEventListener(event, resetTimer) ) window.removeEventListener('ipc-activity', handleIPCActivity) window.removeEventListener( 'auto-lock-settings-changed', handleSettingsChange ) if (timerRef.current) clearTimeout(timerRef.current) } }, []) } ================================================ FILE: src/app/App/hooks/useInactivity.test.js ================================================ import React from 'react' import { render, act } from '@testing-library/react' const { useInactivity } = require('./useInactivity') jest.mock('@tetherto/pearpass-lib-vault', () => ({ closeAllInstances: jest.fn(() => Promise.resolve()), useVaults: () => ({ resetState: jest.fn() }), useUserData: () => ({ refetch: jest.fn(() => Promise.resolve({ isLoggedIn: true })) }) })) jest.mock('../../../hooks/useAutoLockPreferences', () => ({ getAutoLockTimeoutMs: jest.fn(() => 500), useAutoLockPreferences: jest.fn(() => ({ shouldBypassAutoLock: false })) })) jest.mock('../../../context/LoadingContext', () => ({ useLoadingContext: () => ({ setIsLoading: jest.fn() }) })) jest.mock('../../../context/RouterContext', () => ({ useRouter: () => ({ currentPage: 'home', data: {}, navigate: jest.fn() }) })) jest.mock('../../../context/ModalContext', () => ({ useModal: () => ({ closeModal: jest.fn() }) })) describe('useInactivity', () => { let addEventListenerSpy let removeEventListenerSpy let clearTimeoutSpy let setTimeoutSpy let originalClearTimeout let originalSetTimeout beforeEach(() => { jest.useFakeTimers() originalClearTimeout = global.clearTimeout originalSetTimeout = global.setTimeout if (typeof global.clearTimeout !== 'function') { global.clearTimeout = () => {} } if (typeof global.setTimeout !== 'function') { global.setTimeout = () => {} } addEventListenerSpy = jest.spyOn(window, 'addEventListener') removeEventListenerSpy = jest.spyOn(window, 'removeEventListener') clearTimeoutSpy = jest.spyOn(global, 'clearTimeout') setTimeoutSpy = jest.spyOn(global, 'setTimeout') }) afterEach(() => { global.clearTimeout = originalClearTimeout global.setTimeout = originalSetTimeout jest.useRealTimers() jest.clearAllMocks() }) const TestComponent = () => { useInactivity() return null } it('adds and removes event listeners on mount/unmount', () => { const { unmount } = render() expect(addEventListenerSpy).toHaveBeenCalled() unmount() expect(removeEventListenerSpy).toHaveBeenCalled() }) it('registers activity event listeners including reset-timer and ipc-activity', () => { render() const expectedEvents = [ 'reset-timer', 'mousemove', 'keydown', 'mousedown', 'touchstart', 'scroll', 'ipc-activity', 'auto-lock-settings-changed' ] expectedEvents.forEach((event) => { expect(addEventListenerSpy).toHaveBeenCalledWith( event, expect.any(Function) ) }) }) it('dedupes rapid successive resetTimer calls', () => { render() // Grab the last registered listener for, say, mousemove const mouseListener = addEventListenerSpy.mock.calls.find( ([name]) => name === 'mousemove' )[1] act(() => { mouseListener() mouseListener() }) // Only one timeout should be scheduled because of dedupe expect(setTimeoutSpy).toHaveBeenCalledTimes(1) }) it('schedules and clears timeout on unmount', () => { const { unmount } = render() expect(setTimeoutSpy).toHaveBeenCalled() unmount() expect(clearTimeoutSpy).toHaveBeenCalled() }) }) ================================================ FILE: src/app/App/hooks/useOnExtension.test.js ================================================ import React from 'react' import { render } from '@testing-library/react' import { HANDLER_EVENTS } from '../../../constants/services' const { useOnExtensionExit } = require('./useOnExtensionExit') const mockNavigate = jest.fn() const mockResetState = jest.fn() jest.mock('@tetherto/pearpass-lib-vault', () => ({ useVaults: () => ({ resetState: mockResetState }) })) jest.mock('../../../context/RouterContext', () => ({ useRouter: () => ({ navigate: mockNavigate }) })) describe('UseOnExtensionExit', () => { let addEventListenerSpy let removeEventListenerSpy beforeEach(() => { addEventListenerSpy = jest.spyOn(window, 'addEventListener') removeEventListenerSpy = jest.spyOn(window, 'removeEventListener') }) afterEach(() => { jest.clearAllMocks() }) it('should add and remove extension-exit listener on mount/unmount', () => { function TestComponent() { useOnExtensionExit() return null } const { unmount } = render() expect(addEventListenerSpy).toHaveBeenCalledWith( HANDLER_EVENTS.extensionExit, expect.any(Function) ) unmount() expect(removeEventListenerSpy).toHaveBeenCalledWith( HANDLER_EVENTS.extensionExit, expect.any(Function) ) }) it('should call navigate and resetState on extension-exit event', () => { let handler addEventListenerSpy.mockImplementation((event, cb) => { if (event === HANDLER_EVENTS.extensionExit) { handler = cb } }) function TestComponent() { useOnExtensionExit() return null } render() // Simulate event handler() expect(mockNavigate).toHaveBeenCalledWith('welcome', { state: 'masterPassword' }) expect(mockResetState).toHaveBeenCalled() }) }) ================================================ FILE: src/app/App/hooks/useOnExtensionExit.js ================================================ import { useEffect } from 'react' import { useVaults } from '@tetherto/pearpass-lib-vault' import { NAVIGATION_ROUTES } from '../../../constants/navigation' import { HANDLER_EVENTS } from '../../../constants/services' import { useRouter } from '../../../context/RouterContext' export const useOnExtensionExit = () => { const { navigate } = useRouter() const { resetState } = useVaults() useEffect(() => { const handleExtensionExit = () => { navigate('welcome', { state: NAVIGATION_ROUTES.MASTER_PASSWORD }) resetState() } window.addEventListener(HANDLER_EVENTS.extensionExit, handleExtensionExit) return () => { window.removeEventListener( HANDLER_EVENTS.extensionExit, handleExtensionExit ) } }, [navigate]) } ================================================ FILE: src/app/App/hooks/useOnExtensionLockOut.js ================================================ import { useEffect } from 'react' import { NAVIGATION_ROUTES } from '../../../constants/navigation' import { HANDLER_EVENTS } from '../../../constants/services' import { useRouter } from '../../../context/RouterContext' export const useOnExtensionLockOut = () => { const { navigate } = useRouter() useEffect(() => { const handleExtensionLockOut = () => { navigate('welcome', { state: NAVIGATION_ROUTES.SCREEN_LOCKED }) } window.addEventListener( HANDLER_EVENTS.extensionLock, handleExtensionLockOut ) return () => { window.removeEventListener( HANDLER_EVENTS.extensionLock, handleExtensionLockOut ) } }, [navigate]) } ================================================ FILE: src/app/App/hooks/useOnExtensionLockOut.test.js ================================================ import React from 'react' import { render } from '@testing-library/react' import { useOnExtensionLockOut } from './useOnExtensionLockOut' import { NAVIGATION_ROUTES } from '../../../constants/navigation' import { HANDLER_EVENTS } from '../../../constants/services' const mockNavigate = jest.fn() jest.mock('../../../context/RouterContext', () => ({ useRouter: () => ({ navigate: mockNavigate }) })) describe('useOnExtensionLockOut', () => { let addEventListenerSpy let removeEventListenerSpy beforeEach(() => { addEventListenerSpy = jest.spyOn(window, 'addEventListener') removeEventListenerSpy = jest.spyOn(window, 'removeEventListener') }) afterEach(() => { jest.clearAllMocks() }) it('should add and remove extension-lock listener on mount/unmount', () => { function TestComponent() { useOnExtensionLockOut() return null } const { unmount } = render() expect(addEventListenerSpy).toHaveBeenCalledWith( HANDLER_EVENTS.extensionLock, expect.any(Function) ) unmount() expect(removeEventListenerSpy).toHaveBeenCalledWith( HANDLER_EVENTS.extensionLock, expect.any(Function) ) }) it('should call navigate with correct params on extension-lock event', () => { let handler addEventListenerSpy.mockImplementation((event, cb) => { if (event === HANDLER_EVENTS.extensionLock) { handler = cb } }) function TestComponent() { useOnExtensionLockOut() return null } render() handler() expect(mockNavigate).toHaveBeenCalledWith('welcome', { state: NAVIGATION_ROUTES.SCREEN_LOCKED }) }) }) ================================================ FILE: src/app/App/hooks/useRedirect.js ================================================ import { useEffect, useState } from 'react' import { useUserData } from '@tetherto/pearpass-lib-vault' import { NAVIGATION_ROUTES } from '../../../constants/navigation' import { useRouter } from '../../../context/RouterContext' import { logger } from '../../../utils/logger' /** * @returns {Object} An object containing: * @property {boolean} isLoading - Indicates if the user data is currently loading. */ export const useRedirect = () => { const [isLoading, setIsLoading] = useState(true) const { navigate } = useRouter() const { refetch: refetchUser } = useUserData() useEffect(() => { ;(async () => { try { setIsLoading(true) const userData = await refetchUser() if (userData?.masterPasswordStatus?.isLocked) { navigate('welcome', { state: NAVIGATION_ROUTES.SCREEN_LOCKED }) return } if (!userData?.hasPasswordSet) { navigate('intro') return } navigate('welcome', { state: userData?.hasPasswordSet ? NAVIGATION_ROUTES.MASTER_PASSWORD : NAVIGATION_ROUTES.CREATE_MASTER_PASSWORD }) } catch (error) { logger.error('Error fetching user data:', error) } finally { setIsLoading(false) } })() }, []) return { isLoading } } ================================================ FILE: src/app/App/hooks/useRedirect.test.js ================================================ import { renderHook, waitFor } from '@testing-library/react' import { useUserData } from '@tetherto/pearpass-lib-vault' import { useRedirect } from './useRedirect' import { useRouter } from '../../../context/RouterContext' // Mock dependencies jest.mock('@tetherto/pearpass-lib-vault') jest.mock('../../../context/RouterContext') jest.mock('../../../utils/logger', () => ({ error: jest.fn() })) jest.mock('../../../constants/localStorage', () => ({ LOCAL_STORAGE_KEYS: { TOU_ACCEPTED: 'TOU_ACCEPTED' } })) const mockNavigate = jest.fn() const mockRefetchUser = jest.fn() describe('useRedirect', () => { beforeEach(() => { jest.clearAllMocks() useRouter.mockReturnValue({ navigate: mockNavigate }) useUserData.mockReturnValue({ isLoading: false, refetch: mockRefetchUser }) // Mock localStorage Object.defineProperty(window, 'localStorage', { value: { getItem: jest.fn(), setItem: jest.fn(), clear: jest.fn() }, writable: true }) }) it('should return isLoading as true when user data is loading', () => { useUserData.mockReturnValue({ isLoading: true, refetch: mockRefetchUser }) const { result } = renderHook(() => useRedirect()) expect(result.current.isLoading).toBe(true) }) it('should navigate to "welcome" with masterPassword state if password is set and ToU is accepted', async () => { mockRefetchUser.mockResolvedValue({ hasPasswordSet: true }) localStorage.getItem.mockReturnValue('true') renderHook(() => useRedirect()) await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('welcome', { state: 'masterPassword' }) }) }) it('should navigate to "intro" if password is not set', async () => { mockRefetchUser.mockResolvedValue({ hasPasswordSet: false }) localStorage.getItem.mockReturnValue('true') // ToU accepted doesn't matter here renderHook(() => useRedirect()) await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('intro') }) }) it('should navigate to "intro" if user data is null', async () => { mockRefetchUser.mockResolvedValue(null) renderHook(() => useRedirect()) await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('intro') }) }) }) ================================================ FILE: src/app/App/index.js ================================================ import { useState, useCallback } from 'react' import { useTheme } from '@tetherto/pearpass-lib-ui-kit' import { html } from 'htm/react' import { appConfig } from './appConfig' import { useInactivity } from './hooks/useInactivity' import { useOnExtensionExit } from './hooks/useOnExtensionExit' import { useOnExtensionLockOut } from './hooks/useOnExtensionLockOut' import { useRedirect } from './hooks/useRedirect' import { TitleBar } from '../../components/TitleBar' import { AppHeaderContainer } from '../../containers/AppHeaderContainer' import { useRouter } from '../../context/RouterContext' import { usePearUpdate } from '../../hooks/usePearUpdate' import { useSimulatedLoading } from '../../hooks/useSimulatedLoading' import { Routes } from '../Routes' import { ContentFrame, WindowBackground } from './styles' import { isV2 } from '../../utils/designVersion' export const App = () => { const { theme } = useTheme() const { currentPage } = useRouter() usePearUpdate() const isSimulatedLoading = useSimulatedLoading() const [isLoadingPageComplete, setIsLoadingPageComplete] = useState(false) useInactivity() const { isLoading: isDataLoading } = useRedirect() useOnExtensionExit() useOnExtensionLockOut() const handleLoadingComplete = useCallback(() => { setIsLoadingPageComplete(true) }, []) const showLoadingPage = isV2() ? isDataLoading || !isLoadingPageComplete : !isSimulatedLoading && (isDataLoading || !isLoadingPageComplete) if (isV2()) { const useLogoTitleBar = appConfig.headerWithLogo.includes(currentPage) return html` <${WindowBackground} $backgroundColor=${theme.colors.colorBackground}> ${useLogoTitleBar ? html`<${TitleBar} />` : html`<${AppHeaderContainer} />`} <${ContentFrame} $backgroundColor=${theme.colors.colorBackground} $borderColor=${theme.colors.colorBorderPrimary} > <${Routes} isSplashScreenShown=${false} isDataLoading=${showLoadingPage} onLoadingComplete=${handleLoadingComplete} /> ` } return html` <${Routes} isSplashScreenShown=${isSimulatedLoading} isDataLoading=${showLoadingPage} onLoadingComplete=${handleLoadingComplete} /> ` } ================================================ FILE: src/app/App/styles.js ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' import styled from 'styled-components' import { isV2 } from '../../utils/designVersion' export const AppWrapper = styled.div` height: 100%; ` export const WindowBackground = styled.div` ${isV2() ? ` position: fixed; inset: 0; width: 100vw; height: 100vh; overflow: hidden; ` : ` height: 100%; width: 100%; `} background-color: ${({ $backgroundColor }) => $backgroundColor || 'transparent'}; display: flex; flex-direction: column; ` export const ContentFrame = styled.div` ${isV2() ? ` position: relative; z-index: 0; flex: 1 1 0; min-height: 0; min-width: 0; margin: ${rawTokens.spacing8}px; ` : ` flex: 1 1 auto; `} border-radius: ${isV2() ? process.platform === 'darwin' ? `${rawTokens.radius8}px ${rawTokens.radius8}px ${rawTokens.radius20}px ${rawTokens.radius20}px` : `${rawTokens.radius8}px` : '16px'}; border: ${({ $borderColor }) => isV2() ? `1px solid ${$borderColor || 'transparent'}` : 'none'}; overflow: auto; background-color: ${({ $backgroundColor }) => $backgroundColor || 'transparent'}; ` ================================================ FILE: src/app/Routes/index.js ================================================ import { AUTHENTICATOR_ENABLED } from '@tetherto/pearpass-lib-constants' import { OtpRefreshProvider, RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { LayoutWithSidebar } from '../../containers/LayoutWithSidebar' import { RecordDetails } from '../../containers/RecordDetails' import { useRouter } from '../../context/RouterContext' import { AuthenticatorView } from '../../pages/AuthenticatorView' import { InitialPage } from '../../pages/InitialPage' import { Intro } from '../../pages/Intro' import { IntroV2 } from '../../pages/Intro/IntroV2' import { LoadingPage } from '../../pages/LoadingPage' import { LoadingPageV2 } from '../../pages/LoadingPage/LoadingPageV2' import { MainView } from '../../pages/MainView' import { MainViewV2 } from '../../pages/MainView/MainViewV2' import { SettingsView } from '../../pages/SettingsView' import { SettingsViewV2 } from '../../pages/SettingsViewV2/SettingsViewV2' import { WelcomePage } from '../../pages/WelcomePage' import { isV2 } from '../../utils/designVersion' /** * @param {Object} props * @param {boolean} props.isSplashScreenShown - Shows InitialPage (splash screen) * @param {boolean} props.isDataLoading - Shows LoadingPage (with progress bar) * @param {() => void} [props.onLoadingComplete] - Callback when LoadingPage finishes * @returns {import('react').ReactNode} */ export const Routes = ({ isSplashScreenShown, isDataLoading, onLoadingComplete }) => { const { currentPage, data } = useRouter() // Show InitialPage during initial splash if (isSplashScreenShown) { if (isV2()) { return html` <${LoadingPageV2} progress=${0} /> ` } return html` <${InitialPage} /> ` } // Show LoadingPage with progress bar during data loading if (isDataLoading || currentPage === 'loading') { return html` <${LoadingPage} onLoadingComplete=${onLoadingComplete} /> ` } if (currentPage === 'intro') { if (isV2()) { return html` <${IntroV2} /> ` } return html` <${Intro} /> ` } if (currentPage === 'welcome') { return html` <${WelcomePage} /> ` } if (currentPage === 'settings') { if (isV2()) { return } else { return } } if (currentPage === 'vault') { const isAuthenticator = AUTHENTICATOR_ENABLED && data?.recordType === RECORD_TYPES.OTP const VersionBasedMainView = isV2() ? MainViewV2 : MainView return html` <${OtpRefreshProvider}> <${LayoutWithSidebar} mainView=${isAuthenticator ? html`<${AuthenticatorView} />` : html`<${VersionBasedMainView} />`} sideView=${html`<${RecordDetails} />`} isSideViewOpen=${!!data?.recordId} /> ` } } ================================================ FILE: src/components/AlertBox/index.tsx ================================================ import { useRef, useEffect, useState } from 'react' import { IconWrapper, Container, Message } from './styles' import { ErrorIcon, YellowErrorIcon } from '../../lib-react-components' export enum AlertBoxType { WARNING = 'warning', ERROR = 'error', } interface Props { message: string type?: AlertBoxType testId?: string } /** * @param {Object} props * @param {string} props.message * @param {('warning'|'error')} [props.type='warning'] * @param {string} [props.testId] * @returns {JSX.Element} */ export const AlertBox = ({ message, type = AlertBoxType.WARNING, testId }: Props): React.ReactElement => { const messageRef = useRef(null) const [isMultiLine, setIsMultiLine] = useState(false) useEffect(() => { if (messageRef.current) { const lineHeight = parseFloat( getComputedStyle(messageRef.current).lineHeight ) const height = messageRef.current.offsetHeight setIsMultiLine(height > lineHeight * 1.2) } }, [message]) return ( {type === AlertBoxType.WARNING ? : } {message} ) } ================================================ FILE: src/components/AlertBox/styles.ts ================================================ import styled from 'styled-components' import { AlertBoxType } from '.' interface ContainerProps { $isMultiLine?: boolean type?: AlertBoxType } export const Container = styled.div` display: flex; width: 100%; padding: 10px; align-items: ${({ $isMultiLine }) => $isMultiLine ? 'flex-start' : 'center'}; gap: 8px; border-radius: 10px; border: 1px solid ${({ theme, type }) => type === 'warning' ? theme.colors.errorYellow.mode1 : theme.colors.errorRed.mode1}; background: linear-gradient( 0deg, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.8) 100% ), ${({ theme, type }) => type === 'warning' ? theme.colors.errorYellow.mode1 : theme.colors.errorRed.mode1}; ` export const Message = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; font-weight: 500; line-height: 1.4; ` export const IconWrapper = styled.div` flex-shrink: 0; ` ================================================ FILE: src/components/AppHeaderV2/AppHeaderV2.styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = ( colors: ThemeColors, ) => ({ root: { display: 'flex' as const, flexDirection: 'row' as const, alignItems: 'center' as const, padding: '12px 16px', width: '100%', gap: `${rawTokens.spacing16}px`, marginBottom: 0, boxSizing: 'border-box' as const }, flexSpacer: { flex: '1 1 0', minWidth: 0 }, searchWrap: { display: "flex", justifyContent: "center", width: '100%', }, search:{ maxWidth: '400px', width: '100%', }, searchField: { display: 'flex' as const, flexDirection: 'row' as const, alignItems: 'center' as const, gap: `${rawTokens.spacing8}px`, width: '100%', paddingBlock: `${rawTokens.spacing10}px`, paddingInline: `${rawTokens.spacing12}px`, borderRadius: 9999, borderWidth: 1, borderStyle: 'solid' as const, borderColor: colors.colorBorderSearchField, backgroundColor: colors.colorSurfaceSearchField, boxSizing: 'border-box' as const }, actions: { display: 'flex' as const, flexDirection: 'row' as const, alignItems: 'center' as const, justifyContent: 'flex-end' as const, gap: `${rawTokens.spacing8}px` } }) ================================================ FILE: src/components/AppHeaderV2/AppHeaderV2.test.js ================================================ import React from 'react' import '@testing-library/jest-dom' import { render, screen, fireEvent } from '@testing-library/react' import { AppHeaderV2, AppHeaderAddItemTrigger } from './AppHeaderV2' jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (s) => s } }) })) jest.mock('./AppHeaderV2.styles', () => ({ createStyles: () => ({ root: {}, searchWrap: {}, search: {}, actions: {} }) })) const mockTheme = { colors: {} } jest.mock('@tetherto/pearpass-lib-ui-kit', () => ({ useTheme: () => ({ theme: mockTheme }), SearchField: ({ value, onChangeText, placeholderText, testID }) => ( onChangeText(e.target.value)} /> ), Button: ({ children, onClick, 'data-testid': dataTestId }) => ( ) })) jest.mock('@tetherto/pearpass-lib-ui-kit/icons', () => ({ Add: () => null, ImportOutlined: () => null })) describe('AppHeaderV2', () => { const defaultProps = { searchValue: '', onSearchChange: jest.fn(), onImportClick: jest.fn(), addItemControl: add } beforeEach(() => { jest.clearAllMocks() }) it('renders search, import, and addItemControl', () => { render() expect(screen.getByTestId('main-search-input')).toBeInTheDocument() expect(screen.getByTestId('main-import-button')).toBeInTheDocument() expect(screen.getByTestId('add-control')).toBeInTheDocument() expect( screen.getByPlaceholderText('Search in All Items') ).toBeInTheDocument() expect(screen.getByText('Import')).toBeInTheDocument() }) it('uses custom test ids when provided', () => { render( ) expect(screen.getByTestId('custom-search')).toBeInTheDocument() expect(screen.getByTestId('custom-import')).toBeInTheDocument() }) it('calls onSearchChange when search value changes', () => { render() fireEvent.change(screen.getByTestId('main-search-input'), { target: { value: 'next' } }) expect(defaultProps.onSearchChange).toHaveBeenCalledWith('next') }) it('calls onImportClick when import is pressed', () => { render() fireEvent.click(screen.getByTestId('main-import-button')) expect(defaultProps.onImportClick).toHaveBeenCalledTimes(1) }) }) describe('AppHeaderAddItemTrigger', () => { beforeEach(() => { jest.clearAllMocks() }) it('renders Add Item with default test id', () => { render() expect(screen.getByTestId('main-plus-button')).toBeInTheDocument() expect(screen.getByText('Add Item')).toBeInTheDocument() }) it('uses custom test id when provided', () => { render() expect(screen.getByTestId('plus-custom')).toBeInTheDocument() }) }) ================================================ FILE: src/components/AppHeaderV2/AppHeaderV2.tsx ================================================ import React, { type ReactNode } from 'react' import { Button, useTheme, SearchField } from '@tetherto/pearpass-lib-ui-kit' import { Add, ImportOutlined } from '@tetherto/pearpass-lib-ui-kit/icons' import { createStyles } from './AppHeaderV2.styles' import { useTranslation } from '../../hooks/useTranslation' export type AppHeaderV2Props = { searchValue: string onSearchChange: (value: string) => void onImportClick: () => void addItemControl: ReactNode searchTestId?: string importTestId?: string } export const AppHeaderV2 = ({ searchValue, onSearchChange, onImportClick, addItemControl, searchTestId = 'main-search-input', importTestId = 'main-import-button', }: AppHeaderV2Props) => { const { t } = useTranslation() const { theme } = useTheme() const styles = createStyles(theme.colors) const { root, searchWrap, search, actions } = styles return (
{addItemControl}
) } type AppHeaderAddItemTriggerProps = { testId?: string } export const AppHeaderAddItemTrigger = ({ testId = 'main-plus-button' }: AppHeaderAddItemTriggerProps) => { const { t } = useTranslation() return ( ) } ================================================ FILE: src/components/AppHeaderV2/index.ts ================================================ export { AppHeaderV2, AppHeaderAddItemTrigger, type AppHeaderV2Props, } from './AppHeaderV2' ================================================ FILE: src/components/BackgroundWithGradient.tsx ================================================ import React, { useState, useEffect, ReactNode, CSSProperties } from 'react' import styled from 'styled-components' import { useTheme } from '@tetherto/pearpass-lib-ui-kit' interface ContainerProps { $backgroundColor: string } const Container = styled.div` position: relative; width: 100%; height: 100%; background-color: ${(props) => props.$backgroundColor}; overflow: hidden; display: flex; flex-direction: column; ` const SVGBackground = styled.svg` position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; ` interface BackgroundWithGradientProps { children?: ReactNode style?: CSSProperties backgroundColor?: string gradientColors?: string[] } export const BackgroundWithGradient: React.FC = ({ children, style, backgroundColor: customBg, gradientColors: customGradientColors }) => { const { theme } = useTheme() const backgroundColor = customBg || theme.colors.colorSurfacePrimary const gradientColors = customGradientColors || ['#2A3317', backgroundColor] const [dimensions, setDimensions] = useState({ width: typeof window !== 'undefined' ? window.innerWidth : 0, height: typeof window !== 'undefined' ? window.innerHeight : 0 }) useEffect(() => { const handleResize = () => { setDimensions({ width: window.innerWidth, height: window.innerHeight }) } window.addEventListener('resize', handleResize) return () => window.removeEventListener('resize', handleResize) }, []) const radius = Math.max(dimensions.width, dimensions.height) * 0.4 const cx = dimensions.width / 2 const cy = dimensions.height * 0.45 return ( {children} ) } ================================================ FILE: src/components/BadgeTextItem/index.js ================================================ import { html } from 'htm/react' import { BadgeContainer, BadgeText, BadgeCount } from './styles' /** * @param {{ * count: number, * word: string, * isNumberVisible?: boolean * testId?: string * }} props */ export const BadgeTextItem = ({ count, word, isNumberVisible = true, testId }) => html`<${BadgeContainer} data-testid=${testId}> ${isNumberVisible ? html`<${BadgeCount}>#${count}` : null} <${BadgeText}>${word} ` ================================================ FILE: src/components/BadgeTextItem/index.test.js ================================================ import React from 'react' import { render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import { BadgeTextItem } from './index' jest.mock('./styles', () => ({ BadgeContainer: ({ children }) => (
{children}
), BadgeText: ({ children, title }) => ( {children} ), BadgeCount: ({ children }) => ( {children} ) })) describe('BadgeTextItem', () => { test('renders word and count by default', () => { render() expect(screen.getByTestId('badge-text')).toHaveTextContent('alpha') expect(screen.getByTestId('badge-count')).toHaveTextContent('#3') }) test('hides count when isNumberVisible is false', () => { render() expect(screen.getByTestId('badge-text')).toHaveTextContent('beta') expect(screen.queryByTestId('badge-count')).toBeNull() }) }) ================================================ FILE: src/components/BadgeTextItem/styles.js ================================================ import styled from 'styled-components' export const BadgeContainer = styled.div` display: flex; flex-direction: row; justify-content: center; align-items: center; background-color: ${({ theme }) => theme.colors.grey500?.mode1}; padding: 13.5px 10px; width: 105px; border-radius: 10px; gap: 5px; font-family: Inter; font-style: normal; font-size: 12px; ` export const BadgeText = styled.span` font-weight: 500; color: ${({ theme }) => theme.colors.grey100.mode1}; max-width: 70%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; ` export const BadgeCount = styled.span` font-weight: 400; color: ${({ theme }) => theme.colors.grey100.mode1}; ` ================================================ FILE: src/components/BannerBox/index.js ================================================ import { html } from 'htm/react' import { CloseButtonWrapper, Container, HighlightedDescription, Message, Title } from './styles' import { ButtonPrimary, ButtonRoundIcon, XIcon } from '../../lib-react-components' /** * @param {Object} props * @param {Function} props.onClose * @param {boolean} props.isVisible * @param {string} props.href * @param {string} props.title * @param {string} props.message * @param {string} props.highlightedDescription * @param {string} props.buttonText * @param {string} props.testId * @returns {null|Object} */ export const BannerBox = ({ onClose, isVisible, href, title, message, highlightedDescription, buttonText }) => { if (!isVisible) return null return html` <${Container} data-testid="bannerbox-container"> <${Title}>${title} <${Message}> ${message} <${HighlightedDescription}> ${highlightedDescription}
<${ButtonPrimary} onClick=${onClose} testId="bannerbox-button-download"> ${buttonText} <${CloseButtonWrapper}> <${ButtonRoundIcon} testId="bannerbox-button-close" startIcon=${XIcon} onClick=${onClose} /> ` } ================================================ FILE: src/components/BannerBox/styles.js ================================================ import styled from 'styled-components' export const Container = styled.div` position: absolute; display: flex; bottom: 20px; left: 50%; transform: translateX(-50%); gap: 10px; width: min(90%, 507px); flex-direction: column; align-items: flex-start; padding: 10px; border-radius: 10px; border: 1px solid ${({ theme }) => theme.colors.primary400.mode1}; background-color: ${({ theme }) => theme.colors.grey400.mode1}; ` export const Title = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-style: normal; font-weight: 700; line-height: normal; ` export const Message = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; ` export const HighlightedDescription = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 700; line-height: normal; ` export const CloseButtonWrapper = styled.div` display: flex; top: -15px; right: -15px; position: absolute; ` ================================================ FILE: src/components/ButtonPlusCreateNew/index.js ================================================ import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' import { html } from 'htm/react' import { Button } from './styles' import { PlusIcon, XIcon } from '../../lib-react-components' /** * @param {{ * isOpen: boolean * testId?: string * }} props */ export const ButtonPlusCreateNew = ({ isOpen, testId }) => html` <${Button} data-testid=${testId}> <${isOpen ? XIcon : PlusIcon} color=${colors.black.mode1} /> ` ================================================ FILE: src/components/ButtonPlusCreateNew/index.test.js ================================================ import React from 'react' import { render } from '@testing-library/react' import { ButtonPlusCreateNew } from './index' jest.mock('../../lib-react-components', () => ({ PlusIcon: () => 'PlusIcon', XIcon: () => 'XIcon' })) jest.mock('@tetherto/pearpass-lib-ui-theme-provider', () => ({ colors: { black: { mode1: '#000000' } } })) jest.mock('./styles', () => ({ Button: ({ children }) =>
{children}
})) describe('ButtonPlusCreateNew', () => { it('renders PlusIcon when isOpen is false', () => { const { container, getByTestId } = render( ) expect(getByTestId('button').textContent).toBe('PlusIcon') expect(container).toMatchSnapshot() }) it('renders XIcon when isOpen is true', () => { const { container, getByTestId } = render( ) expect(getByTestId('button').textContent).toBe('XIcon') expect(container).toMatchSnapshot() }) it('passes the correct color prop to icons', () => { const { container } = render() expect(container).toMatchSnapshot() }) it('uses the Button component from styles', () => { const { getByTestId } = render() expect(getByTestId('button')).toBeTruthy() }) }) ================================================ FILE: src/components/ButtonPlusCreateNew/styles.js ================================================ import styled from 'styled-components' export const Button = styled.button` display: flex; position: relative; width: 30px; height: 30px; padding: 5px; align-items: center; justify-content: center; flex-shrink: 0; border-radius: 15px; background: ${({ theme }) => theme.colors.primary400.mode1}; border: none; cursor: pointer; ` ================================================ FILE: src/components/CardSingleSetting/index.js ================================================ import { html } from 'htm/react' import { Container, Description, Header, Title } from './styles' /** * @param {{ * title: string * description?: string * additionalHeaderContent?: import('react').ReactNode * children: import('react').ReactNode * }} props */ export const CardSingleSetting = ({ title, description, children, additionalHeaderContent, testId }) => html` <${Container} data-testid=${testId}> <${Header}> <${Title}>${title} ${additionalHeaderContent && additionalHeaderContent} ${description && html` <${Description}>${description} `}
${children} ` ================================================ FILE: src/components/CardSingleSetting/index.test.js ================================================ import React from 'react' import { render } from '@testing-library/react' import { CardSingleSetting } from './index' import '@testing-library/jest-dom' jest.mock('./styles', () => ({ Container: 'div', Content: 'div', Header: 'div', Title: 'h3' })) describe('CardSingleSetting', () => { it('renders with title', () => { const { container, getByText } = render( ) expect(getByText('Test Title')).toBeInTheDocument() expect(container).toMatchSnapshot() }) it('renders children correctly', () => { const { getByText } = render(
Child Content
) expect(getByText('Child Content')).toBeInTheDocument() }) it('renders with the correct structure', () => { const { container } = render(
Child Content
) const header = container.querySelector('div > div:first-child') const content = container.querySelector('div > div:nth-child(2)') expect(header).toBeInTheDocument() expect(content).toBeInTheDocument() }) }) ================================================ FILE: src/components/CardSingleSetting/styles.js ================================================ import styled from 'styled-components' export const Container = styled.div` padding: 17px 20px; border-radius: 10px; border: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; display: flex; flex-direction: column; gap: 15px; ` export const Header = styled.div` padding-bottom: 15px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid ${({ theme }) => theme.colors.white.mode1}; ` export const Title = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-style: normal; font-weight: 700; ` export const Description = styled.p` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-style: normal; font-weight: 400; line-height: normal; ` ================================================ FILE: src/components/CopyButton/index.tsx ================================================ import React from 'react' import { useCopyToClipboard } from '../../hooks/useCopyToClipboard.electron' import { useToast } from '../../context/ToastContext' import { CopyIcon } from '../../lib-react-components' import { useTranslation } from '../../hooks/useTranslation' interface CopyButtonProps { value?: string testId?: string text?: string color?: string } const CopyButton = ({ value, testId, text, color }: CopyButtonProps): React.ReactElement => { const { t } = useTranslation() const { setToast } = useToast() const { copyToClipboard, isCopyToClipboardDisabled } = useCopyToClipboard({ onCopy: () => { setToast({ message: t('Copied to clipboard'), icon: CopyIcon }) } }) const handleCopy = () => { if (value) { copyToClipboard(value) } } if (isCopyToClipboardDisabled) { return <> } return (
{text && ( {text} )}
) } export { CopyButton } ================================================ FILE: src/components/CreateCustomField/index.js ================================================ import { useState } from 'react' import { useLingui } from '@lingui/react' import { html } from 'htm/react' import { ArrowIconWrapper, DropDown, Label, Wrapper } from './styles' import { useOutsideClick } from '../../hooks/useOutsideClick' import { ArrowDownIcon, ArrowUpIcon, CommonFileIcon, PlusIcon, ButtonFilter } from '../../lib-react-components' const OPTIONS = [ // { // name: 'Email', // type: 'email', // icon: EmailIcon // }, // { // name: 'Picture', // type: 'picture', // icon: ImageIcon // }, { name: 'Comment', type: 'note', icon: CommonFileIcon } // { // name: 'Pin code', // type: 'pinCode', // icon: NineDotsIcon // }, // { // name: 'Date', // type: 'date', // icon: CalendarIcon // }, // { // name: 'Website', // type: 'website', // icon: WorldIcon // }, // { // name: 'Phone number', // type: 'phoneNumber', // icon: PhoneIcon // } ] /** * @param {{ * onCreateCustom: (type: string) => void, * testId?: string, * dataId?: string * }} props */ export const CreateCustomField = ({ onCreateCustom, testId, dataId }) => { const { i18n } = useLingui() const [isOpen, setIsOpen] = useState(false) const wrapperRef = useOutsideClick({ onOutsideClick: () => { setIsOpen(false) } }) const handleSelect = (type) => { onCreateCustom(type) setIsOpen(false) } return html` <${Wrapper} ref=${wrapperRef} data-id=${dataId}> <${Label} data-testid=${testId} onClick=${() => setIsOpen(!isOpen)}> <${PlusIcon} size="21" />
${i18n._('Create Custom')}
<${ArrowIconWrapper}> <${isOpen ? ArrowUpIcon : ArrowDownIcon} size="21" /> ${isOpen && html`<${DropDown}> ${OPTIONS.map( (option) => html` <${ButtonFilter} testId=${`createcustomfield-option-${option.type}`} variant="secondary" startIcon=${option.icon} onClick=${() => handleSelect(option.type)} > ${option.name} ` )} `} ` } ================================================ FILE: src/components/CreateCustomField/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { CreateCustomField } from './index' import '@testing-library/jest-dom' jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (str) => str } }) })) jest.mock('../../hooks/useOutsideClick', () => ({ useOutsideClick: jest.fn() })) describe('CreateCustomField component', () => { const mockOnCreateCustom = jest.fn() beforeEach(() => { jest.clearAllMocks() }) test('renders with correct initial state', () => { const { container } = render( ) expect(container).toMatchSnapshot() }) test('opens dropdown when clicked', () => { const { getByText } = render( ) fireEvent.click(getByText('Create Custom')) expect(getByText('Comment')).toBeInTheDocument() }) test('calls onCreateCustom when an option is selected', () => { const { getByText } = render( ) fireEvent.click(getByText('Create Custom')) fireEvent.click(getByText('Comment')) expect(mockOnCreateCustom).toHaveBeenCalledWith('note') }) test('closes dropdown after selecting an option', () => { const { getByText, queryByText } = render( ) fireEvent.click(getByText('Create Custom')) fireEvent.click(getByText('Comment')) expect(queryByText('Comment')).not.toBeInTheDocument() }) test('toggles dropdown visibility when clicked multiple times', () => { const { getByText, queryByText } = render( ) fireEvent.click(getByText('Create Custom')) expect(getByText('Comment')).toBeInTheDocument() fireEvent.click(getByText('Create Custom')) expect(queryByText('Comment')).not.toBeInTheDocument() }) }) ================================================ FILE: src/components/CreateCustomField/styles.js ================================================ import styled from 'styled-components' export const Wrapper = styled.div` border-radius: 10px; background: ${({ theme }) => theme.colors.grey400.mode1}; border: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; padding: 10px; z-index: 5; ` export const Label = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-weight: 400; display: flex; align-items: center; gap: 10px; cursor: pointer; width: 100%; ` export const ArrowIconWrapper = styled.div` display: flex; margin-left: auto; ` export const DropDown = styled.div` display: flex; flex-wrap: wrap; gap: 17px; padding: 10px 10px 0 10px; border-top: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; margin-top: 10px; ` ================================================ FILE: src/components/CreateNewCategoryPopupContent/index.js ================================================ import { html } from 'htm/react' import { MenuItem, MenuList } from './styles' import { RECORD_COLOR_BY_TYPE } from '../../constants/recordColorByType' import { RECORD_ICON_BY_TYPE } from '../../constants/recordIconByType' /** * @param {{ * menuItems: Array<{ name: string; type: string; }>, * onClick: (item: { name: string; type: string; }) => void, * }} */ export const CreateNewCategoryPopupContent = ({ menuItems, onClick }) => { const handleMenuItemClick = (e, item) => { e.stopPropagation() onClick(item) } return html` <${MenuList}> ${menuItems?.map((item) => { const Icon = RECORD_ICON_BY_TYPE?.[item.type] return html`<${MenuItem} data-testid=${`createcategory-popup-${item.type}`} color=${RECORD_COLOR_BY_TYPE?.[item.type]} key=${item.type} onClick=${(e) => handleMenuItemClick(e, item)} > ${Icon && html`<${Icon} size="24" fill=${true} color=${RECORD_COLOR_BY_TYPE?.[item.type]} />`} ${item.name} ` })} ` } ================================================ FILE: src/components/CreateNewCategoryPopupContent/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { CreateNewCategoryPopupContent } from './index' import '@testing-library/jest-dom' describe('CreateNewCategoryPopupContent', () => { const mockMenuItems = [ { type: 'note', name: 'Note' }, { type: 'email', name: 'Email' } ] const mockOnClick = jest.fn() beforeEach(() => { jest.clearAllMocks() }) test('renders menu items correctly', () => { const { getByText, container } = render( ) expect(getByText('Note')).toBeInTheDocument() expect(getByText('Email')).toBeInTheDocument() expect(container).toMatchSnapshot() }) it('calls onClick with correct item when menu item is clicked', () => { const { getByText } = render( ) const noteMenuItem = getByText('Note') fireEvent.click(noteMenuItem) expect(mockOnClick).toHaveBeenCalledWith(mockMenuItems[0]) }) }) ================================================ FILE: src/components/CreateNewCategoryPopupContent/styles.js ================================================ import styled from 'styled-components' export const MenuList = styled.div` display: flex; font-family: 'Inter'; position: absolute; flex-direction: column; width: 200px; align-items: flex-start; overflow: hidden; ` export const MenuItem = styled.div` display: flex; width: 100%; padding: 5px 9px; gap: 12px; align-self: stretch; color: ${({ theme }) => theme.colors.white.mode1}; background: ${({ theme }) => theme.colors.grey400.mode1}; border: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; border-bottom: none; align-items: center; cursor: pointer; position: relative; &:first-child { border-top-left-radius: 10px; border-top-right-radius: 10px; } &:last-child { border-bottom-left-radius: 10px; border-bottom-right-radius: 10px; border-bottom: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; } &:hover { border-color: ${({ color }) => color}; } &:hover + div { border-top-color: ${({ color }) => color}; } ` ================================================ FILE: src/components/DropdownSwapVault/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import '@testing-library/jest-dom' jest.mock( '../../containers/Modal/CreateOrEditVaultModalContentV2/CreateOrEditVaultModalContentV2', () => ({ CreateOrEditVaultModalContentV2: () => null }) ) jest.mock('@tetherto/pearpass-lib-vault', () => ({ useVault: () => ({ refetch: jest.fn(), isVaultProtected: jest.fn() }) })) jest.mock('../../context/LoadingContext', () => ({ useLoadingContext: () => ({ setIsLoading: jest.fn() }) })) jest.mock('../../context/ModalContext', () => ({ useModal: () => ({ setModal: jest.fn(), closeModal: jest.fn() }) })) jest.mock('../../hooks/useTranslation', () => ({ useTranslation: () => ({ t: (key) => key }) })) import { DropdownSwapVault } from './index' describe('DropdownSwapVault component', () => { const mockVaults = [ { id: 'vault2', name: 'vault2' }, { id: 'vault3', name: 'vault3' } ] const mockSelectedVault = { id: 'vault1', name: 'vault1' } test('renders with selected vault even when vaults array is empty', () => { const { getAllByText } = render( ) const elements = getAllByText('vault1') expect(elements).toHaveLength(1) }) test('renders with selected vault', () => { const { getAllByText } = render( ) const elements = getAllByText('vault1') expect(elements).toHaveLength(1) }) test('displays all vault options when open', () => { const { getByText, getAllByText } = render( ) fireEvent.click(getByText('vault1')) const vaultOptions = getAllByText(/vault[12]/) expect(vaultOptions).toHaveLength(2) }) }) ================================================ FILE: src/components/DropdownSwapVault/index.tsx ================================================ import React, { useEffect, useState } from 'react' import { html } from 'htm/react' import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' import { useVault, type Vault } from '@tetherto/pearpass-lib-vault' import { HeaderContainer, CreateVaultButton, Dropdown, DropdownItem, DropdownItemLabel, HeaderLabel, HeaderLeft, HeaderRight, Wrapper } from './styles' import { CreateVaultModalContent } from '../../containers/Modal/CreateVaultModalContent' import { CreateOrEditVaultModalContentV2 } from '../../containers/Modal/CreateOrEditVaultModalContentV2/CreateOrEditVaultModalContentV2' import { VaultPasswordFormModalContent } from '../../containers/Modal/VaultPasswordFormModalContent' import { useModal } from '../../context/ModalContext' import { useTranslation } from '../../hooks/useTranslation' import { isV2 } from '../../utils/designVersion' import { LockCircleIcon, LockIcon, SmallArrowIcon } from '../../lib-react-components' import { logger } from '../../utils/logger' interface DropdownSwapVaultProps { vaults?: Vault[] selectedVault?: Vault } export const DropdownSwapVault = ({ vaults, selectedVault }: DropdownSwapVaultProps) => { const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) const { closeModal, setModal } = useModal() const { isVaultProtected, refetch: refetchVault } = useVault() const [protectedVaultById, setProtectedVaultById] = useState>({}) useEffect(() => { if (!isOpen || !vaults?.length) { return } let isCancelled = false const loadProtected = async () => { const results = await Promise.all( vaults.map(async (vault) => { try { const isProtected = await isVaultProtected(vault.id) return [vault.id, !!isProtected] } catch { return [vault.id, false] } }) ) if (isCancelled) { return } setProtectedVaultById(Object.fromEntries(results)) } loadProtected() return () => { isCancelled = true } }, [isOpen, isVaultProtected, vaults]) const handleVaultUnlock = async ({ vault, password }: { vault: Vault password: string }) => { if (!vault.id) { return } try { await refetchVault(vault.id, { password }) closeModal() } catch (error) { logger.error('DropdownSwapVault', error) throw error } } const onVaultSelect = async (vault: Vault) => { const cached = protectedVaultById[vault.id] const isProtected = cached ?? (await isVaultProtected(vault.id)) if (cached === undefined) { setProtectedVaultById((prev) => ({ ...prev, [vault.id]: isProtected })) } if (isProtected) { setModal( html`<${VaultPasswordFormModalContent} onSubmit=${async (password: string) => handleVaultUnlock({ vault, password })} vault=${vault} />` ) } else { await refetchVault(vault.id) } setIsOpen(false) } const handleCreateNewVault = () => { setIsOpen(false) const CreateContent = isV2() ? CreateOrEditVaultModalContentV2 : CreateVaultModalContent setModal( html`<${CreateContent} onClose=${closeModal} onSuccess=${closeModal} />` ) } if (!selectedVault?.id) { return null } return ( setIsOpen(!isOpen)} > {selectedVault?.name} {vaults?.map((vault, index) => ( onVaultSelect(vault)} > {vault.name} {protectedVaultById[vault.id] ? ( ) : null} ))} {t('Create New Vault')} ) } ================================================ FILE: src/components/DropdownSwapVault/styles.ts ================================================ import styled from 'styled-components' import { BASE_TRANSITION_DURATION } from '../../constants/transitions' const ROW_VERTICAL_PADDING = 9 const ROW_HORIZONTAL_PADDING = 10 const ROW_MIN_HEIGHT = 42 export const Wrapper = styled.div` width: 100%; background: ${({ theme }) => theme.colors.black.mode1}; border-radius: 10px; ` const BaseRow = styled.div` width: 100%; display: flex; align-items: center; justify-content: space-between; padding: ${ROW_VERTICAL_PADDING}px ${ROW_HORIZONTAL_PADDING}px; min-height: ${ROW_MIN_HEIGHT}px; border-radius: 10px; box-sizing: border-box; ` export const HeaderContainer = styled(BaseRow)<{ isOpen: boolean }>` border: 1px solid ${({ theme, isOpen }) => isOpen ? theme.colors.primary400.mode1 : 'transparent'}; cursor: pointer; user-select: none; ` export const HeaderLeft = styled.div` display: flex; align-items: center; gap: 8px; min-width: 0; ` export const HeaderLabel = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; font-weight: 700; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; ` export const HeaderRight = styled.div<{ isOpen: boolean }>` display: flex; align-items: center; & svg { transform: ${({ isOpen }) => (isOpen ? 'rotate(0deg)' : 'rotate(-90deg)')}; transition: transform ${BASE_TRANSITION_DURATION}ms ease; } ` export const Dropdown = styled.div<{ isOpen: boolean }>` width: 100%; display: flex; flex-direction: column; gap: 10px; padding: ${({ isOpen }) => (isOpen ? '10px' : '0')}; max-height: ${({ isOpen }) => (isOpen ? '260px' : '0')}; opacity: ${({ isOpen }) => (isOpen ? 1 : 0)}; overflow-x: hidden; overflow-y: ${({ isOpen }) => (isOpen ? 'auto' : 'hidden')}; pointer-events: ${({ isOpen }) => (isOpen ? 'auto' : 'none')}; transition: max-height ${BASE_TRANSITION_DURATION}ms ease, opacity ${BASE_TRANSITION_DURATION}ms ease, padding ${BASE_TRANSITION_DURATION}ms ease; ` export const DropdownItem = styled(BaseRow)<{ isOpen?: boolean delayMs?: number }>` border: 1px solid transparent; background: ${({ theme }) => theme.colors.grey500.mode1}; color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; font-weight: 700; cursor: pointer; opacity: ${({ isOpen }) => (isOpen ? 1 : 0)}; transform: ${({ isOpen }) => (isOpen ? 'translateY(0)' : 'translateY(-6px)')}; will-change: transform, opacity; transition: opacity ${BASE_TRANSITION_DURATION}ms ease, transform ${BASE_TRANSITION_DURATION}ms ease; transition-delay: ${({ isOpen, delayMs = 0 }) => isOpen ? `${delayMs}ms` : '0ms'}; &:hover { border-color: ${({ theme }) => theme.colors.primary400.mode1}; } & svg { flex-shrink: 0; } ` export const DropdownItemLabel = styled.span` flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; ` export const CreateVaultButton = styled(DropdownItem)` justify-content: flex-start; color: ${({ theme }) => theme.colors.primary400.mode1}; ` ================================================ FILE: src/components/EditFolderPopupContent/index.js ================================================ import { useMemo } from 'react' import { useLingui } from '@lingui/react' import { useFolders } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { MenuItem, MenuList } from './styles' import { ConfirmationModalContent } from '../../containers/Modal/ConfirmationModalContent' import { CreateFolderModalContent } from '../../containers/Modal/CreateFolderModalContent' import { CreateFolderModalContentV2 } from '../../containers/Modal/CreateFolderModalContentV2/CreateFolderModalContentV2' import { DeleteFolderModalContentV2 } from '../../containers/Modal/DeleteFolderModalContentV2/DeleteFolderModalContentV2' import { useModal } from '../../context/ModalContext' import { DeleteIcon, FolderIcon } from '../../lib-react-components' import { isV2 } from '../../utils/designVersion' /** * * @param {{ * name: string * }} props * @returns */ export const EditFolderPopupContent = ({ name }) => { const { i18n } = useLingui() const { deleteFolder, data: folderData } = useFolders() const { setModal, closeModal } = useModal() const menuItems = useMemo( () => [ { name: i18n._('Delete'), type: 'delete', icon: DeleteIcon, onClick: () => { if (isV2()) { const count = folderData?.customFolders?.[name]?.records?.length ?? 0 if (count === 1) { deleteFolder(name) closeModal() } else { setModal( ) } } else { setModal( html`<${ConfirmationModalContent} primaryAction=${() => { deleteFolder(name) closeModal() }} secondaryAction=${closeModal} title=${i18n._('Are you sure you want to delete this folder?')} text=${i18n._( 'This action will permanently delete the folder and all items contained within it. Are you sure you want to proceed?' )} />` ) } } }, { name: i18n._('Rename'), type: 'rename', icon: FolderIcon, onClick: () => isV2() ? setModal( ) : setModal( html`<${CreateFolderModalContent} initialValues=${{ title: name }} />` ) } ], [closeModal, deleteFolder, folderData, i18n, name, setModal] ) const handleMenuItemClick = (e, item) => { e.stopPropagation() item.onClick() } return html` <${MenuList}> ${menuItems?.map((item) => { const Icon = item.icon return html`<${MenuItem} data-testid=${`folder-menuitem-${item.type}`} key=${item.type} onClick=${(e) => handleMenuItemClick(e, item)} > ${Icon && html`<${Icon} size="24" />`} ${item.name} ` })} ` } ================================================ FILE: src/components/EditFolderPopupContent/styles.js ================================================ import styled from 'styled-components' export const MenuList = styled.div` display: flex; font-family: 'Inter'; position: absolute; flex-direction: column; width: 200px; align-items: flex-start; overflow: hidden; ` export const MenuItem = styled.div` display: flex; width: 100%; padding: 5px 9px; align-items: center; gap: 12px; align-self: stretch; color: ${({ theme }) => theme.colors.white.mode1}; background: ${({ theme }) => theme.colors.grey400.mode1}; border: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; border-bottom: none; cursor: pointer; position: relative; &:first-child { border-top-left-radius: 10px; border-top-right-radius: 10px; } &:last-child { border-bottom-left-radius: 10px; border-bottom-right-radius: 10px; border-bottom: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; } &:hover { border-color: ${({ color }) => color}; } &:hover + div { border-top-color: ${({ color }) => color}; } ` ================================================ FILE: src/components/EmptyCollectionView/index.js ================================================ import { RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { CollectionsContainer, CollectionsTitle, CollectionsWrapper } from './styles' import { RECORD_ICON_BY_TYPE } from '../../constants/recordIconByType' import { useRouter } from '../../context/RouterContext' import { useCreateOrEditRecord } from '../../hooks/useCreateOrEditRecord' import { useTranslation } from '../../hooks/useTranslation' import { ButtonCreate } from '../../lib-react-components' /** * @param {{ * selectedFolder?: string * isFavoritesView?: boolean * isSearchActive?: boolean * }} props */ export const EmptyCollectionView = ({ selectedFolder, isFavoritesView, isSearchActive }) => { const { data } = useRouter() const { t } = useTranslation() const { handleCreateOrEditRecord } = useCreateOrEditRecord() const createCollectionOptions = [ { text: t('Create a login'), type: RECORD_TYPES.LOGIN }, { text: t('Create an identity'), type: RECORD_TYPES.IDENTITY }, { text: t('Create a credit card'), type: RECORD_TYPES.CREDIT_CARD }, { text: t('Save a Wi-fi'), type: RECORD_TYPES.WIFI_PASSWORD }, { text: t('Save a Recovery phrase'), type: RECORD_TYPES.PASS_PHRASE }, { text: t('Create a note'), type: RECORD_TYPES.NOTE }, { text: t('Create a custom element'), type: RECORD_TYPES.CUSTOM } ] return html` <${CollectionsWrapper} data-testid="empty-collection-view" $isSearchActive=${isSearchActive} > <${CollectionsContainer}> <${CollectionsTitle}> ${isSearchActive ? t('No result found.') : t('This collection is empty.')} ${!isSearchActive && html`

${t('Create a new element or pass to another collection')}

`} ${!isSearchActive && createCollectionOptions .filter( (option) => data.recordType === 'all' || option.type === data.recordType ) .map( (option) => html` <${ButtonCreate} key=${option.type} testId=${`emptycollection-button-create-${option.type}`} startIcon=${RECORD_ICON_BY_TYPE[option.type]} onClick=${() => handleCreateOrEditRecord({ recordType: option.type, selectedFolder, isFavorite: isFavoritesView ? true : undefined })} > ${option.text} ` )} ` } ================================================ FILE: src/components/EmptyCollectionView/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { EmptyCollectionView } from './index' import '@testing-library/jest-dom' jest.mock('../../context/RouterContext', () => ({ useRouter: () => ({ data: { recordType: 'all' } }) })) const mockHandleCreateOrEditRecord = jest.fn() jest.mock('../../hooks/useCreateOrEditRecord', () => ({ useCreateOrEditRecord: () => ({ handleCreateOrEditRecord: mockHandleCreateOrEditRecord }) })) jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (str) => str } }) })) describe('EmptyCollectionView component', () => { const renderComponent = () => render( ) test('renders empty collection message', () => { const { container, getByText } = renderComponent() expect(getByText('This collection is empty.')).toBeInTheDocument() expect(container).toMatchSnapshot() }) test('displays all create options when recordType is "all"', () => { const { getByText } = renderComponent() expect(getByText('Create a login')).toBeInTheDocument() expect(getByText('Create an identity')).toBeInTheDocument() expect(getByText('Create a credit card')).toBeInTheDocument() expect(getByText('Create a note')).toBeInTheDocument() expect(getByText('Create a custom element')).toBeInTheDocument() }) test('buttons are clickable and trigger handleCreateOrEditRecord', () => { const { getByText } = renderComponent() const loginButton = getByText('Create a login') fireEvent.click(loginButton) expect(mockHandleCreateOrEditRecord).toHaveBeenCalledWith({ recordType: 'login', selectedFolder: 'test-folder', isFavorite: true }) }) test('displays help text', () => { const { getByText } = renderComponent() expect( getByText('Create a new element or pass to another collection') ).toBeInTheDocument() }) describe('when isSearchActive is true', () => { const renderSearchComponent = () => render( ) test('renders "No result found." message', () => { const { getByText } = renderSearchComponent() expect(getByText('No result found.')).toBeInTheDocument() }) test('does not render empty collection message', () => { const { queryByText } = renderSearchComponent() expect(queryByText('This collection is empty.')).not.toBeInTheDocument() }) test('does not render help text', () => { const { queryByText } = renderSearchComponent() expect( queryByText('Create a new element or pass to another collection') ).not.toBeInTheDocument() }) test('does not render create buttons', () => { const { queryByText } = renderSearchComponent() expect(queryByText('Create a login')).not.toBeInTheDocument() expect(queryByText('Create an identity')).not.toBeInTheDocument() expect(queryByText('Create a credit card')).not.toBeInTheDocument() expect(queryByText('Create a note')).not.toBeInTheDocument() expect(queryByText('Create a custom element')).not.toBeInTheDocument() }) }) }) ================================================ FILE: src/components/EmptyCollectionView/styles.js ================================================ import styled from 'styled-components' export const CollectionsWrapper = styled.div` display: flex; width: 100%; height: 100%; justify-content: center; align-items: ${({ $isSearchActive }) => $isSearchActive ? 'flex-start' : 'center'}; padding-top: ${({ $isSearchActive }) => ($isSearchActive ? '20%' : '0')}; ` export const CollectionsContainer = styled.div` display: flex; flex-direction: column; width: 300px; gap: 10px; ` export const CollectionsTitle = styled.div` display: flex; flex-direction: column; gap: 5px; margin-bottom: 20px; color: ${({ theme }) => theme.colors.white.mode1}; text-align: center; font-family: 'Inter'; font-size: 12px; font-weight: 600; & span { font-weight: 600; } & p { font-weight: 400; } ` ================================================ FILE: src/components/FileDropArea/index.js ================================================ import { useRef, useState } from 'react' import { html } from 'htm/react' import { DropAreaWrapper, HiddenInput, Label } from './styles' /** * @param {{ * label: import('react').ReactNode, * onFileDrop: (files: File[]) => void, * accepts: string, * }} props */ export const FileDropArea = ({ label, onFileDrop, accepts }) => { const fileInputRef = useRef(null) const [isDragging, setIsDragging] = useState(false) const handleAreaClick = () => { fileInputRef.current?.click() } const handleFileChange = (event) => { const files = event.target.files onFileDrop?.(files) } const handleDragOver = (event) => { event.preventDefault() setIsDragging(true) } const handleDragLeave = () => { setIsDragging(false) } const handleDrop = (event) => { event.preventDefault() const files = Array.from(event.dataTransfer.files) onFileDrop?.(files) } return html` <${DropAreaWrapper} onClick=${handleAreaClick} onDragOver=${handleDragOver} onDragLeave=${handleDragLeave} onDrop=${handleDrop} isDragging=${isDragging} > <${Label}> ${label} <${HiddenInput} ref=${fileInputRef} type="file" accept=${accepts} onChange=${handleFileChange} /> ` } ================================================ FILE: src/components/FileDropArea/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { FileDropArea } from './index' import '@testing-library/jest-dom' describe('FileDropArea component', () => { const mockOnFileDrop = jest.fn() const testLabel = 'Drop files here' const renderComponent = () => render( ) beforeEach(() => { jest.clearAllMocks() }) test('renders the label correctly', () => { const { getByText, container } = renderComponent() expect(getByText(testLabel)).toBeInTheDocument() expect(container).toMatchSnapshot() }) test('triggers file input click when area is clicked', () => { const { getByText, container } = renderComponent() const dropArea = getByText(testLabel) const fileInput = container.querySelector('input[type="file"]') const mockClick = jest.spyOn(fileInput, 'click') fireEvent.click(dropArea) expect(mockClick).toHaveBeenCalled() }) test('calls onFileDrop when files are selected via input', () => { const { container } = renderComponent() const fileInput = container.querySelector('input[type="file"]') const testFiles = [ new File(['test content'], 'test.txt', { type: 'text/plain' }) ] Object.defineProperty(fileInput, 'files', { value: testFiles }) fireEvent.change(fileInput) expect(mockOnFileDrop).toHaveBeenCalledWith(testFiles) }) test('handles file drop correctly', () => { const { getByText } = renderComponent() const dropArea = getByText(testLabel) const testFiles = [ new File(['test content'], 'test.txt', { type: 'text/plain' }) ] fireEvent.drop(dropArea, { dataTransfer: { files: testFiles } }) expect(mockOnFileDrop).toHaveBeenCalledWith(testFiles) }) }) ================================================ FILE: src/components/FileDropArea/styles.js ================================================ import styled from 'styled-components' export const DropAreaWrapper = styled.div` display: flex; justify-content: center; align-items: center; min-height: 83px; border-radius: 10px; width: 100%; border: 1px dashed ${({ theme }) => theme.colors.grey100.mode1}; background: ${({ theme }) => theme.colors.grey400.mode1}; opacity: ${({ isDragging }) => (isDragging ? 0.5 : 1)}; ` export const Label = styled.div` color: ${({ theme }) => theme.colors.grey100.mode1}; font-family: 'Inter'; font-size: 14px; font-weight: 500; ` export const HiddenInput = styled.input` display: none; ` ================================================ FILE: src/components/FileUploadContent/index.js ================================================ import { useRef, useState } from 'react' import { MAX_FILE_SIZE_MB, MAX_FILE_SIZE_BYTES } from '@tetherto/pearpass-lib-constants' import { html } from 'htm/react' import { ContentWrapper, FileSizeWarning, HiddenInput } from './styles' import { useTranslation } from '../../hooks/useTranslation' import { ButtonSecondary } from '../../lib-react-components' import { YellowErrorIcon } from '../../lib-react-components' import { FileDropArea } from '../FileDropArea' export const FileUploadContent = ({ accepts, isTypeImage, handleFileChange }) => { const { t } = useTranslation() const [isFileSizeWarning, setIsFileSizeWarning] = useState(false) const fileInputRef = useRef(null) const handleBrowseClick = () => { fileInputRef.current?.click() } const onFileChange = (files) => { const file = files?.[0] if (!file) return if (file.size > MAX_FILE_SIZE_BYTES) { setIsFileSizeWarning(true) return } if (isFileSizeWarning) { setIsFileSizeWarning(false) } handleFileChange(files) } return html` <${ContentWrapper}> <${FileDropArea} onFileDrop=${onFileChange} accepts=${accepts} label=${isTypeImage ? t('Drop picture here...') : t('Drop file here...')} /> ${isFileSizeWarning ? html` <${FileSizeWarning}> <${YellowErrorIcon} size="18" /> ${t( `Your picture is too large. Please upload one that’s ${MAX_FILE_SIZE_MB} MB or smaller.` )} ` : html`<${FileSizeWarning}> ${t(`Maximum file size: ${MAX_FILE_SIZE_MB} MB.`)} `} <${ButtonSecondary} onClick=${handleBrowseClick}> ${t('Browse folders')} <${HiddenInput} ref=${fileInputRef} type="file" accept=${accepts} onChange=${(event) => onFileChange(event?.target?.files)} />` } ================================================ FILE: src/components/FileUploadContent/styles.js ================================================ import styled from 'styled-components' export const ContentWrapper = styled.div` width: 100%; ` export const HiddenInput = styled.input` display: none; ` export const FileSizeWarning = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; display: flex; align-items: center; gap: 6px; ` ================================================ FILE: src/components/FolderDropdown/FolderDropdownV2.tsx ================================================ import { useMemo } from 'react' import { Button, ContextMenu, MultiSlotInput, NavbarListItem, SelectField, rawTokens, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { Close, CreateNewFolder, Folder, KeyboardArrowBottom } from '@tetherto/pearpass-lib-ui-kit/icons' import { useFolders } from '@tetherto/pearpass-lib-vault' import { CreateFolderModalContentV2 } from '../../containers/Modal/CreateFolderModalContentV2/CreateFolderModalContentV2' import { useModal } from '../../context/ModalContext' import { useTranslation } from '../../hooks/useTranslation' import { sortByName } from '../../utils/sortByName' type FolderDropdownV2Props = { selectedFolder?: string onFolderSelect: (name: string) => void } export const FolderDropdownV2 = ({ selectedFolder, onFolderSelect }: FolderDropdownV2Props) => { const { t } = useTranslation() const { theme } = useTheme() const { setModal, closeModal } = useModal() const { data: folders } = useFolders() const folderOptions = useMemo(() => { return sortByName( Object.values( (folders?.customFolders ?? {}) as Record ) ).map((f) => f.name) }, [folders]) const handleCreateFolder = () => { setModal( { onFolderSelect(folderName) }} /> ) } return ( {selectedFolder && (
} /> } > {folderOptions.map((name) => ( } iconSize={16} label={name} selected={selectedFolder === name} onClick={() => onFolderSelect(name)} testID={`createoredit-folder-option-v2-${name}`} /> ))} } iconSize={16} label={t('Add New Folder')} onClick={handleCreateFolder} testID='createoredit-folder-create-v2' /> ) } ================================================ FILE: src/components/FolderDropdown/index.js ================================================ import React, { useEffect } from 'react' import { useFolders } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { CreateFolderModalContent } from '../../containers/Modal/CreateFolderModalContent' import { CreateFolderModalContentV2 } from '../../containers/Modal/CreateFolderModalContentV2/CreateFolderModalContentV2' import { useModal } from '../../context/ModalContext' import { useTranslation } from '../../hooks/useTranslation' import { PlusIcon, StarIcon } from '../../lib-react-components' import { isV2 } from '../../utils/designVersion' import { FAVORITES_FOLDER_ID } from '../../utils/isFavorite' import { sortByName } from '../../utils/sortByName' import { MenuDropdown } from '../MenuDropdown' import { DropDownItem } from '../MenuDropdown/styles' const NO_FOLDER = 'no-folder' /** * @param {{ * selectedFolder?: { * name: string; * icon?: React.ReactNode; * }, * onFolderSelect: (folder: { * name: string; * icon?: React.ReactNode; * }) => void * testId?: string * }} props */ export const FolderDropdown = ({ selectedFolder, onFolderSelect, testId }) => { const { data: folders } = useFolders() const { t } = useTranslation() const { setModal, closeModal } = useModal() const customFolders = React.useMemo(() => { const mappedFolders = sortByName( Object.values(folders?.customFolders ?? {}) ).map((folder) => ({ name: folder.name })) if (selectedFolder) { mappedFolders.unshift({ name: t('No Folder'), type: NO_FOLDER }) } return mappedFolders }, [folders]) const isFavorite = selectedFolder === FAVORITES_FOLDER_ID const name = isFavorite ? t('Favorite') : selectedFolder const icon = isFavorite ? StarIcon : undefined const handleCreateNewFolder = () => { isV2() ? setModal( closeModal()} onCreate={(folderData) => onFolderSelect({ name: folderData?.folder }) } /> ) : setModal(html` <${CreateFolderModalContent} onCreate=${(folderData) => onFolderSelect({ name: folderData?.folder })} /> `) } const handleFolderSelect = (folder) => { onFolderSelect(folder.type === NO_FOLDER ? undefined : folder) } useEffect(() => { if (!selectedFolder) { return } const existingFolders = Object.values(folders?.customFolders ?? {}) const exists = existingFolders.some( (folder) => folder.name === selectedFolder ) if (!exists) { onFolderSelect(undefined) } }, [folders, onFolderSelect, selectedFolder]) const CreteNewFolderComponent = html` <${DropDownItem} data-testid="menudropdown-create-new" onClick=${() => handleCreateNewFolder()} > <${PlusIcon} size="24" /> ${t('Create new')} ` return html` <${MenuDropdown} testId=${testId} selectedItem=${{ name, icon }} onItemSelect=${handleFolderSelect} items=${customFolders} bottomComponent=${CreteNewFolderComponent} /> ` } ================================================ FILE: src/components/FolderDropdown/index.test.js ================================================ import React from 'react' import { useLingui } from '@lingui/react' import { render, screen, fireEvent } from '@testing-library/react' import { useFolders } from '@tetherto/pearpass-lib-vault' import { FolderDropdown } from './index' import '@testing-library/jest-dom' const mockSetModal = jest.fn() jest.mock( '../../containers/Modal/CreateFolderModalContentV2/CreateFolderModalContentV2', () => ({ CreateFolderModalContentV2: function MockCreateFolderModalContentV2() { return null } }) ) jest.mock('@tetherto/pearpass-lib-vault', () => ({ useFolders: jest.fn() })) jest.mock('@lingui/react', () => ({ useLingui: jest.fn() })) jest.mock('../../context/ModalContext', () => ({ useModal: jest.fn(() => ({ setModal: mockSetModal, closeModal: jest.fn() })) })) jest.mock('../MenuDropdown', () => ({ MenuDropdown: ({ selectedItem, onItemSelect, items, bottomComponent }) => (
{selectedItem.name}
{items.map((item, index) => ( ))}
{bottomComponent}
) })) jest.mock('../MenuDropdown/styles', () => ({ DropDownItem: ({ children, ...rest }) => (
{children}
) })) describe('FolderDropdown', () => { const mockOnFolderSelect = jest.fn() const mockFolders = { customFolders: { folder1: { name: 'Personal' }, folder2: { name: 'Work' }, folder3: { name: 'Finance' } } } beforeEach(() => { useFolders.mockReturnValue({ data: mockFolders }) useLingui.mockReturnValue({ i18n: { _: (text) => text } }) mockOnFolderSelect.mockClear() mockSetModal.mockClear() }) test('renders with correct custom folders', () => { render( ) expect(screen.getByTestId('selected-item')).toHaveTextContent('Personal') expect(screen.getByTestId('item-Personal')).toBeInTheDocument() expect(screen.getByTestId('item-Work')).toBeInTheDocument() expect(screen.getByTestId('item-Finance')).toBeInTheDocument() // Bottom "Create new" component is rendered expect(screen.getByTestId('bottom-component')).toBeInTheDocument() }) test('handles favorites folder correctly', () => { render( ) expect(screen.getByTestId('selected-item')).toHaveTextContent('Favorite') }) test('calls onFolderSelect when folder is selected', () => { render( ) fireEvent.click(screen.getByTestId('item-Work')) expect(mockOnFolderSelect).toHaveBeenCalledWith({ name: 'Work' }) }) test('handles empty folders gracefully', () => { useFolders.mockReturnValue({ data: null }) render( ) expect(screen.getByTestId('items')).toBeInTheDocument() expect(screen.queryByTestId('item-Personal')).not.toBeInTheDocument() }) test('handles undefined selectedFolder', () => { render() expect(screen.getByTestId('selected-item')).toBeInTheDocument() expect(screen.getByTestId('selected-item').textContent).toBe('') }) test('clears selectedFolder when it no longer exists', () => { useFolders.mockReturnValue({ data: { customFolders: { folder1: { name: 'Other' } } } }) render( ) expect(mockOnFolderSelect).toHaveBeenCalledWith(undefined) }) test('opens create-folder modal and selects newly created folder', () => { render( ) const createButton = screen.getByTestId('menudropdown-create-new') fireEvent.click(createButton) expect(mockSetModal).toHaveBeenCalledTimes(1) const modalElement = mockSetModal.mock.calls[0][0] expect(typeof modalElement.props.onCreate).toBe('function') mockOnFolderSelect.mockClear() // Simulate onCreate being called with an object that contains folder field modalElement.props.onCreate({ folder: 'NewFolder' }) expect(mockOnFolderSelect).toHaveBeenCalledTimes(1) expect(mockOnFolderSelect).toHaveBeenCalledWith({ name: 'NewFolder' }) }) }) ================================================ FILE: src/components/FolderDropdown/styles.js ================================================ import styled, { css } from 'styled-components' export const Label = styled.div.withConfig({ shouldForwardProp: (prop) => !['isHidden'].includes(prop) })` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-weight: 500; display: flex; align-items: center; gap: 16px; cursor: pointer; white-space: nowrap; ${({ isHidden }) => { if (isHidden) { return css` opacity: 0; pointer-events: none; padding: 5px; ` } }} ` export const MainWrapper = styled.div` position: relative; ` export const Wrapper = styled.div.withConfig({ shouldForwardProp: (prop) => !['isOpen'].includes(prop) })` border-radius: 10px; background: ${({ theme }) => theme.colors.grey400.mode1}; border: 1px solid ${({ theme, isOpen }) => isOpen ? theme.colors.primary400.mode1 : theme.colors.grey100.mode1}; padding: 5px; top: 0; left: 0; position: absolute; z-index: 5; &:hover { border-color: ${({ theme }) => theme.colors.primary400.mode1}; & ${Label} path { stroke: ${({ theme }) => theme.colors.primary400.mode1}; } } ` export const DropDown = styled.div` display: flex; flex-direction: column; gap: 4px; padding: 4px 5px 0 30px; ` export const DropDownItem = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-weight: 500; display: flex; align-items: center; gap: 10px; white-space: nowrap; cursor: pointer; ` export const FolderIconWrapper = styled.div` flex-shrink: 0; ` ================================================ FILE: src/components/FormGroup/index.js ================================================ import { useState } from 'react' import { html } from 'htm/react' import { Collapse, TitleWrapper, Wrapper } from './styles' import { ArrowDownIcon, ArrowUpIcon } from '../../lib-react-components' /** * @param {{ * title?: string, * isCollapse?: boolean * defaultOpenState?: boolean * children: import('react').ReactNode * testId?: string * }} props */ export const FormGroup = ({ title, isCollapse, children, defaultOpenState = true, testId }) => { const [isOpen, setIsOpen] = useState(defaultOpenState) if (!children) { return } return html` <${Wrapper}> ${!!title?.length && isCollapse && html` <${TitleWrapper} data-testid=${testId} onClick=${() => setIsOpen(!isOpen)} > <${isOpen ? ArrowUpIcon : ArrowDownIcon} /> ${title} `} ${isOpen && html` <${Collapse}> ${children} `} ` } ================================================ FILE: src/components/FormGroup/index.test.js ================================================ import React from 'react' import { render, screen, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { FormGroup } from './index' import '@testing-library/jest-dom' jest.mock('../../lib-react-components', () => ({ ArrowDownIcon: () =>
ArrowDown
, ArrowUpIcon: () =>
ArrowUp
})) describe('FormGroup', () => { const mockTitle = 'Test Title' const mockChildren =
Test Children
test('renders with children and title when isCollapse is true', () => { const { container } = render( {mockChildren} ) expect(screen.getByText(mockTitle)).toBeInTheDocument() expect(screen.getByTestId('test-children')).toBeInTheDocument() expect(screen.getByTestId('arrow-up-icon')).toBeInTheDocument() expect(container).toMatchSnapshot() }) test('renders without title section when isCollapse is false', () => { render( {mockChildren} ) expect(screen.queryByText(mockTitle)).not.toBeInTheDocument() expect(screen.getByTestId('test-children')).toBeInTheDocument() }) test('toggles collapse state when title is clicked', () => { render( {mockChildren} ) expect(screen.getByTestId('test-children')).toBeInTheDocument() expect(screen.getByTestId('arrow-up-icon')).toBeInTheDocument() fireEvent.click(screen.getByText(mockTitle)) expect(screen.queryByTestId('test-children')).not.toBeInTheDocument() expect(screen.queryByTestId('arrow-up-icon')).not.toBeInTheDocument() expect(screen.getByTestId('arrow-down-icon')).toBeInTheDocument() fireEvent.click(screen.getByText(mockTitle)) expect(screen.getByTestId('test-children')).toBeInTheDocument() expect(screen.getByTestId('arrow-up-icon')).toBeInTheDocument() }) test('does not render when children is not provided', () => { const { container } = render( ) expect(container.firstChild).toBeNull() }) test('renders without title when title is empty string', () => { render( {mockChildren} ) expect(screen.queryByTestId('arrow-up-icon')).not.toBeInTheDocument() expect(screen.getByTestId('test-children')).toBeInTheDocument() }) test('renders without title when title is undefined', () => { render({mockChildren}) expect(screen.queryByTestId('arrow-up-icon')).not.toBeInTheDocument() expect(screen.getByTestId('test-children')).toBeInTheDocument() }) }) ================================================ FILE: src/components/FormGroup/styles.js ================================================ import styled from 'styled-components' export const Wrapper = styled.div` width: 100%; display: flex; flex-direction: column; gap: 10px; ` export const TitleWrapper = styled.div` display: inline-flex; align-items: center; gap: 6px; color: ${({ theme }) => theme.colors.grey100.mode1}; font-family: 'Inter'; font-size: 12px; font-weight: 400; cursor: pointer; align-self: flex-start; ` export const Collapse = styled.div` width: 100%; ` ================================================ FILE: src/components/FormModalHeaderWrapper/index.js ================================================ import { html } from 'htm/react' import { Buttons, FormModalHeaderWrapperComponent, Main } from './styles' /** * @param {{ * children: import('react').ReactNode, * buttons: import('react').ReactNode * }} props */ export const FormModalHeaderWrapper = ({ children, buttons }) => html` <${FormModalHeaderWrapperComponent}> <${Main}> ${children} <${Buttons}> ${buttons} ` ================================================ FILE: src/components/FormModalHeaderWrapper/index.test.js ================================================ import React from 'react' import { render, screen } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { FormModalHeaderWrapper } from './index' import '@testing-library/jest-dom' describe('FormModalHeaderWrapper', () => { const mockChildren =
Test Children
const mockButtons =
Test Buttons
test('renders children and buttons correctly', () => { const { container } = render( ) expect(screen.getByTestId('test-children')).toBeInTheDocument() expect(screen.getByTestId('test-buttons')).toBeInTheDocument() expect(container).toMatchSnapshot() }) test('renders without children', () => { const { container } = render( ) expect(screen.queryByTestId('test-children')).not.toBeInTheDocument() expect(screen.getByTestId('test-buttons')).toBeInTheDocument() expect(container).toMatchSnapshot() }) test('renders without buttons', () => { const { container } = render( ) expect(screen.getByTestId('test-children')).toBeInTheDocument() expect(screen.queryByTestId('test-buttons')).not.toBeInTheDocument() expect(container).toMatchSnapshot() }) test('renders with empty content', () => { const { container } = render( ) expect(container).toMatchSnapshot() }) }) ================================================ FILE: src/components/FormModalHeaderWrapper/styles.js ================================================ import styled from 'styled-components' export const FormModalHeaderWrapperComponent = styled.div` display: flex; justify-content: space-between; align-items: center; ` export const Main = styled.div` display: flex; ` export const Buttons = styled.div` display: flex; align-items: center; gap: 10px; ` ================================================ FILE: src/components/FormWrapper/index.js ================================================ import styled from 'styled-components' export const FormWrapper = styled.div` display: flex; flex-direction: column; gap: 15px; ` ================================================ FILE: src/components/FormWrapper/index.test.js ================================================ import React from 'react' import { render } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { FormWrapper } from './index' import '@testing-library/jest-dom' describe('FormWrapper', () => { test('renders correctly', () => { const { container } = render( ) expect(container).toMatchSnapshot() }) test('renders with children', () => { const { getByText } = render(
Test Child
) expect(getByText('Test Child')).toBeInTheDocument() }) test('renders multiple children in correct order', () => { const { container } = render(
First Child
Second Child
Third Child
) expect(container).toMatchSnapshot() }) }) ================================================ FILE: src/components/ImportDataOption/index.js ================================================ import { html } from 'htm/react' import { AcceptedTypes, Container, Title } from './styles' import { UploadFilesModalContent } from '../../containers/Modal/UploadImageModalContent' import { useModal } from '../../context/ModalContext' /** * @param {Object} props * @param {string} props.title * @param {string[]} props.accepts * * @param {(files: FileList) => void} [props.onFilesSelected] */ export const ImportDataOption = ({ title, accepts, onFilesSelected, testId }) => { const { setModal } = useModal() const handleClick = () => { setModal( html`<${UploadFilesModalContent} type=${'file'} closeOnChange=${false} accepts=${accepts.join(',')} onFilesSelected=${onFilesSelected} />` ) } return html` <${Container} data-testid=${testId} onClick=${handleClick}> <${Title}>${title} <${AcceptedTypes}>${accepts.join(', ')} ` } ================================================ FILE: src/components/ImportDataOption/styles.js ================================================ import styled from 'styled-components' export const Container = styled.div` display: flex; width: 150px; height: 100px; padding: 14px 2px; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; border-radius: 10px; border: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; ` export const Title = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-style: normal; font-weight: 700; line-height: normal; ` export const AcceptedTypes = styled.span` color: ${({ theme }) => theme.colors.grey100.mode1}; font-family: 'Inter'; font-size: 12px; font-style: normal; font-weight: 400; line-height: normal; ` ================================================ FILE: src/components/InitialPageWrapper/index.js ================================================ import { html } from 'htm/react' import { Background, BottomGradient, ContentWrapper, LeftSpotlightWrapper, LogoContainer, PageContent, PearPass } from './styles' import { LogoLock } from '../../svgs/LogoLock' /** * @param {{ * children: import('react').ReactNode * isAuthScreen: boolean * }} props */ export const InitialPageWrapper = ({ children, isAuthScreen = false }) => html` <${Background} isAuthScreen=${isAuthScreen}> <${LeftSpotlightWrapper} isAuthScreen=${isAuthScreen} /> <${PageContent} isAuthScreen=${isAuthScreen}> <${LogoContainer}> <${LogoLock} width="42" height="57" /> <${PearPass}>${window.electronAPI?.productName ?? 'PearPass'} <${ContentWrapper}> ${children} <${BottomGradient} isAuthScreen=${isAuthScreen} /> ` ================================================ FILE: src/components/InitialPageWrapper/index.test.js ================================================ import React from 'react' import { render, screen } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { InitialPageWrapper } from './index' import '@testing-library/jest-dom' describe('InitialPageWrapper', () => { const mockChildren =
Test Children
test('renders children correctly', () => { const { container } = render( ) expect(screen.getByTestId('test-children')).toBeInTheDocument() expect(container).toMatchSnapshot() }) test('renders without children', () => { const { container } = render( ) expect(screen.queryByTestId('test-children')).not.toBeInTheDocument() expect(container).toMatchSnapshot() }) }) ================================================ FILE: src/components/InitialPageWrapper/styles.js ================================================ import styled from 'styled-components' export const Background = styled.div` position: relative; background-color: ${({ theme, isAuthScreen }) => isAuthScreen ? theme.colors.grey500.mode1 : theme.colors.black.mode1}; width: 100%; height: 100%; overflow: hidden; ` export const LogoContainer = styled.div` position: relative; display: flex; align-items: center; gap: 15px; z-index: 10; text-align: start; height: 55px; width: 311px; ` export const PearPass = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Humble Nostalgia'; font-size: 68px; font-style: normal; font-weight: 400; line-height: normal; height: 55px; ` export const PageContent = styled.div` position: relative; color: white; width: 100%; height: 100%; padding-top: 42px; padding: 42px 110px 0 110px; display: flex; flex-direction: column; align-items: ${({ isAuthScreen }) => isAuthScreen ? 'center' : 'flex-start'}; ` export const ContentWrapper = styled.div` flex: 1; z-index: 2; width: 100%; display: flex; align-items: center; justify-content: center; ` export const LeftSpotlightWrapper = styled.div` display: ${({ isAuthScreen }) => (isAuthScreen ? 'none' : 'block')}; position: absolute; left: -5%; bottom: 0; width: 25%; height: 100%; flex-shrink: 0; border-radius: 1194.565px; opacity: 0.3; background: #b0d944; filter: blur(250px); ` export const MiddleSmallSpotlightWrapper = styled.div` position: absolute; right: 0; top: 50%; transform: translateY(-50%); ` export const RightSpotlightWrapper = styled.div` width: 40%; height: 40%; position: absolute; right: 0; ` export const BottomGradient = styled.div` display: ${({ isAuthScreen }) => (isAuthScreen ? 'block' : 'none')}; z-index: 1; transform: translate(-50%, 95%); opacity: 0.7; bottom: 0; left: 50%; width: 85%; height: 25%; position: absolute; border-radius: 100%; background: #b0d944; filter: blur(90px); ` ================================================ FILE: src/components/InputFieldNote/index.js ================================================ import { useLingui } from '@lingui/react' import { html } from 'htm/react' import { CommonFileIcon, InputField } from '../../lib-react-components' /** * @param {{ * value?: string, * onChange?: (e?: string) => void, * icon?: import('react').FC, * label?: string, * error?: string, * additionalItems?: import('react').ReactNode, * placeholder?: string, * isDisabled?: boolean, * onClick?: () => void, * type?: 'text' | 'password' | 'url', * variant?: 'default' | 'outline', * testId?: string * }} props */ // UI displays this as "Comment" export const InputFieldNote = (props) => { const { i18n } = useLingui() return html`<${InputField} testId=${props.testId} label=${i18n._('Comment')} placeholder=${i18n._('Add comment')} variant="outline" icon=${CommonFileIcon} ...${props} />` } ================================================ FILE: src/components/InputFieldNote/index.test.js ================================================ import React from 'react' import { render, screen, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { InputFieldNote } from './index' import '@testing-library/jest-dom' jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (str) => str } }), I18nProvider: ({ children }) => children })) describe('InputFieldNote', () => { test('renders with default props', () => { const { container } = render( ) expect(container).toMatchSnapshot() }) test('renders with custom value', () => { const { container } = render( ) expect(container).toMatchSnapshot() expect(screen.getByDisplayValue('Test note')).toBeInTheDocument() }) test('renders with custom icon', () => { const CustomIcon = () =>
Custom Icon
render( ) expect(screen.getByTestId('custom-icon')).toBeInTheDocument() }) test('renders with error message', () => { render( ) expect(screen.getByText('Error message')).toBeInTheDocument() }) test('calls onChange when input changes', () => { const handleChange = jest.fn() render( ) const input = screen.getByPlaceholderText('Add comment') fireEvent.change(input, { target: { value: 'New note' } }) expect(handleChange).toHaveBeenCalled() }) test('renders with disabled state', () => { render( ) expect(screen.getByPlaceholderText('Add comment')).toHaveAttribute( 'readonly' ) }) test('renders with custom variant', () => { const { container } = render( ) expect(container).toMatchSnapshot() }) }) ================================================ FILE: src/components/InputPearpassPassword/index.js ================================================ import { useState } from 'react' import { html } from 'htm/react' import { AdditionalItems, IconWrapper, Input, InputAreaWrapper, InputWrapper, MainWrapper, NoticeWrapper } from './styles' import { ButtonRoundIcon, EyeClosedIcon, EyeIcon, LockCircleIcon, NoticeText } from '../../lib-react-components' /** * @param {{ * value: string, * olaceholder?: string, * onChange: (value: string) => void, * isDisabled: boolean, * error: string, * }} props */ export const InputPearpassPassword = ({ value, placeholder, onChange, isDisabled, error, isFilled = false }) => { const [isPasswordVisible] = useState(false) const handleChange = (e) => { if (isDisabled) { return } onChange?.(e.target.value) } return html` <${InputWrapper} isFilled=${isFilled}> <${IconWrapper}> <${LockCircleIcon} size="24" /> <${MainWrapper}> <${InputAreaWrapper}> <${Input} placeholder=${placeholder} value=${value} onChange=${handleChange} disabled=${isDisabled} type=${isPasswordVisible ? 'text' : 'password'} /> ${!!error?.length && html` <${NoticeWrapper}> <${NoticeText} text=${error} type="error" /> `} <${AdditionalItems}> <${ButtonRoundIcon} startIcon=${isPasswordVisible ? EyeClosedIcon : EyeIcon} /> ` } ================================================ FILE: src/components/InputPearpassPassword/styles.js ================================================ import styled from 'styled-components' export const InputWrapper = styled.div` display: flex; align-items: center; gap: 10px; width: 100%; position: relative; border-radius: 10px; border: 1px solid; border-color: ${({ theme }) => theme.colors.grey100.mode1}; margin-top: 0; padding: 5px 10px; &:hover, &:focus-within { border-color: ${({ theme }) => theme.colors.primary400.mode1}; } background: ${({ isFilled }) => isFilled ? 'linear-gradient(180deg, rgba(255, 255, 255, 0.00) -8.33%, #A8E408 115.48%)' : 'linear-gradient(180deg, rgba(255, 255, 255, 0) -8.33%, rgba(153, 153, 153, 0.31) 189.29%)'}; box-shadow: 4px 4px 12px 0px rgba(216, 216, 216, 0.49), 0px 0.5px 4px 0px rgba(255, 255, 255, 0.4) inset; backdrop-filter: blur(7px); ` export const MainWrapper = styled.div` flex: 1; display: flex; flex-direction: column; ` export const IconWrapper = styled.div` display: flex; flex-shrink: 0; align-items: center; align-self: 'stretch'; ` export const InputAreaWrapper = styled.div` flex: 1; position: relative; overflow-x: auto; white-space: nowrap; display: flex; align-items: center; ` export const Input = styled.input` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-weight: 700; pointer-events: ${({ disabled }) => (disabled ? 'none' : 'auto')}; width: 100%; &::placeholder { color: ${({ theme }) => theme.colors.grey100.mode1}; } ` export const NoticeWrapper = styled.div` margin-top: 2px; ` export const AdditionalItems = styled.div` display: flex; justify-content: flex-end; align-items: center; gap: 10px; align-self: center; ` ================================================ FILE: src/components/InputSearch/index.js ================================================ import { useLingui } from '@lingui/react' import { html } from 'htm/react' import { Container, IconWrapper, Input, QuantityWrapper } from './styles' import { LockCircleIcon } from '../../lib-react-components' /** * @param {{ * value: string * onChange: (event: import('react').ChangeEvent) => void * quantity?: number * testId?: string * }} props */ export const InputSearch = ({ value, onChange, quantity, testId }) => { const { i18n } = useLingui() return html` <${Container}> <${IconWrapper}> <${LockCircleIcon} /> <${Input} data-testid=${testId} placeholder=${i18n._('Search...')} value=${value} onChange=${onChange} /> <${QuantityWrapper}>${value?.length ? quantity : null} ` } ================================================ FILE: src/components/InputSearch/index.test.js ================================================ import React from 'react' import { render, screen, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { InputSearch } from './index' import '@testing-library/jest-dom' jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (str) => str } }), I18nProvider: ({ children }) => children })) jest.mock('../../lib-react-components', () => ({ LockCircleIcon: () =>
LockCircleIcon
})) describe('InputSearch', () => { test('renders with default props', () => { const { container } = render( ) expect(container).toMatchSnapshot() }) test('renders with custom value', () => { const { container } = render( ) expect(container).toMatchSnapshot() expect(screen.getByDisplayValue('Test search')).toBeInTheDocument() }) test('displays quantity when value is not empty', () => { render( ) expect(screen.getByText('5')).toBeInTheDocument() }) test('does not display quantity when value is empty', () => { const { container } = render( ) expect(container.textContent).not.toContain('5') }) test('calls onChange when input changes', () => { const handleChange = jest.fn() render( ) const input = screen.getByPlaceholderText('Search...') fireEvent.change(input, { target: { value: 'New search' } }) expect(handleChange).toHaveBeenCalled() }) test('renders lock circle icon', () => { render( ) expect(screen.getByTestId('lock-circle-icon')).toBeInTheDocument() }) test('has correct placeholder text', () => { render( ) expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument() }) }) ================================================ FILE: src/components/InputSearch/styles.js ================================================ import styled from 'styled-components' export const Container = styled.div` width: 100%; display: flex; gap: 10px; align-items: center; border-radius: 15px; padding: 6px 10px; background: ${({ theme }) => theme.colors.black.mode1}; color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; ` export const IconWrapper = styled.label` display: flex; flex-grow: 0; ` export const Input = styled.input` all: unset; display: flex; width: 100%; flex: 1; color: ${({ theme }) => theme.colors.grey200.mode1}; align-self: stretch; font-family: 'Inter'; font-style: normal; font-weight: 500; ` export const QuantityWrapper = styled.div` flex-grow: 0; color: ${({ theme }) => theme.colors.white.mode1}; font-weight: 200; ` ================================================ FILE: src/components/ListItem/index.js ================================================ import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' import { html } from 'htm/react' import { SelectedListItemIconContainer, ListItemActions, ListItemContainer, ListItemDate, ListItemDescription, ListItemInfo, ListItemName } from './styles' import { BrushIcon, CheckIcon, DeleteIcon, LockCircleIcon, ShareIcon } from '../../lib-react-components' export const ListItem = ({ itemName, itemDateText, onClick, onShareClick, onEditClick, onDeleteClick, isSelected, testId, editTestId, deleteTestId }) => html` <${ListItemContainer} isSelected=${isSelected} onClick=${onClick} data-testid=${testId} > <${ListItemInfo}> ${isSelected ? html` <${SelectedListItemIconContainer}> <${CheckIcon} size="24" color=${colors.black.mode1} /> ` : html`<${LockCircleIcon} size="24" />`} <${ListItemDescription}> <${ListItemName}>${itemName} ${itemDateText && html`<${ListItemDate}> ${itemDateText}`} <${ListItemActions}> ${onShareClick && html` <${ShareIcon} /> `} ${onEditClick && html` <${BrushIcon} />`} ${onDeleteClick && html`<${DeleteIcon} />`} ` ================================================ FILE: src/components/ListItem/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { ListItem } from './index' import '@testing-library/jest-dom' jest.mock('../../lib-react-components', () => ({ BrushIcon: () => , DeleteIcon: () => , LockCircleIcon: () => , ShareIcon: () => })) describe('Item Component', () => { const dummyItem = { name: 'vault-123', createdAt: 'Created 13/06/2025' } test('renders Item component correctly and matches snapshot', () => { const { asFragment } = render( {}} onShareClick={() => {}} onEditClick={() => {}} onDeleteClick={() => {}} /> ) expect(asFragment()).toMatchSnapshot() }) test('calls onClick when Item container is clicked', () => { const onClick = jest.fn() const { container } = render( {}} onEditClick={() => {}} onDeleteClick={() => {}} /> ) fireEvent.click(container.firstChild) expect(onClick).toHaveBeenCalled() }) test('calls onShareClick when share icon is clicked', () => { const onShareClick = jest.fn() const { container } = render( {}} onShareClick={onShareClick} onEditClick={() => {}} onDeleteClick={() => {}} /> ) const shareIcon = container.querySelector('[data-testid="share-icon"]') expect(shareIcon).toBeInTheDocument() fireEvent.click(shareIcon.parentElement) expect(onShareClick).toHaveBeenCalled() }) test('calls onEditClick when brush icon is clicked', () => { const onEditClick = jest.fn() const { container } = render( {}} onShareClick={() => {}} onEditClick={onEditClick} onDeleteClick={() => {}} /> ) const brushIcon = container.querySelector('[data-testid="brush-icon"]') expect(brushIcon).toBeInTheDocument() fireEvent.click(brushIcon.parentElement) expect(onEditClick).toHaveBeenCalled() }) test('matches snapshot when action icons are clicked', () => { const { asFragment, container } = render( {}} onShareClick={() => {}} onEditClick={() => {}} onDeleteClick={() => {}} /> ) expect(asFragment()).toMatchSnapshot('initial state') const shareIcon = container.querySelector('[data-testid="share-icon"]') const brushIcon = container.querySelector('[data-testid="brush-icon"]') const deleteIcon = container.querySelector('[data-testid="delete-icon"]') fireEvent.click(shareIcon.parentElement) fireEvent.click(brushIcon.parentElement) fireEvent.click(deleteIcon.parentElement) expect(asFragment()).toMatchSnapshot('after action icons clicked') }) }) ================================================ FILE: src/components/ListItem/styles.js ================================================ import styled from 'styled-components' export const ListItemContainer = styled.div` display: flex; width: 100%; padding: 5px 10px; align-items: center; justify-content: space-between; border-radius: 10px; background-color: ${({ isSelected }) => isSelected ? 'rgba(134, 170, 172, 0.2)' : 'transparent'}; border: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; cursor: pointer; &:hover { border-color: ${({ theme }) => theme.colors.primary400.mode1}; } ` export const ListItemInfo = styled.div` display: flex; gap: 10px; align-items: center; ` export const ListItemDescription = styled.div` display: flex; flex-direction: column; justify-content: center; align-items: flex-start; gap: 4px; ` export const ListItemName = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-style: normal; font-weight: 400; ` export const ListItemDate = styled.p` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 10px; font-style: normal; font-weight: 300; ` export const ListItemActions = styled.div` display: flex; gap: 12px; align-items: center; ` export const SelectedListItemIconContainer = styled.div` display: flex; width: 30px; height: 30px; padding: 5px; justify-content: center; align-items: center; gap: 10px; flex-shrink: 0; border-radius: 10px; background: ${({ theme }) => theme.colors.primary400.mode1}; ` ================================================ FILE: src/components/LoadingOverlay/index.js ================================================ import styled from 'styled-components' export const LoadingOverlay = styled.div` position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 4000; cursor: wait; ` ================================================ FILE: src/components/LoadingOverlay/index.test.js ================================================ import React from 'react' import { render } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { LoadingOverlay } from './index' import '@testing-library/jest-dom' describe('LoadingOverlay', () => { test('renders correctly', () => { const { container } = render( ) expect(container).toMatchSnapshot() }) test('passes props correctly', () => { const { getByTestId } = render( ) const overlay = getByTestId('loading-overlay') expect(overlay).toHaveAttribute('id', 'test-id') expect(overlay).toHaveClass('test-class') }) }) ================================================ FILE: src/components/MenuDropdown/MenuDropdownItem/index.js ================================================ import { html } from 'htm/react' import { FolderIcon } from '../../../lib-react-components' import { DropDownItem } from '../styles' /** * @param {{ * onClick: () => void, * item: {name: string, icon?: import('react').ReactNode}, * testId?: string * }} props */ export const MenuDropdownItem = ({ item, onClick, testId }) => html` <${DropDownItem} onClick=${() => onClick?.()} data-testid=${testId}> <${item.icon ?? FolderIcon} size="24" color=${item.color ?? undefined} /> ${item.name} ` ================================================ FILE: src/components/MenuDropdown/MenuDropdownItem/index.test.js ================================================ import React from 'react' import { render, screen } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { MenuDropdownItem } from './index' import '@testing-library/jest-dom' describe('MenuDropdownItem', () => { const mockOnClick = jest.fn() beforeEach(() => { mockOnClick.mockClear() }) test('renders correctly with name', () => { const { container } = render( ) expect(screen.getByText('Test Item')).toBeInTheDocument() expect(container).toMatchSnapshot() }) test('calls onClick when clicked', async () => { render( ) const item = screen.getByText('Test Item') item.click() expect(mockOnClick).toHaveBeenCalledTimes(1) }) test('renders with custom icon when provided', () => { const CustomIcon = () =>
Custom
render( ) expect(screen.getByTestId('custom-icon')).toBeInTheDocument() }) test('renders with default FolderIcon when no icon is provided', () => { render( ) expect(screen.getByText('Test Item')).toBeInTheDocument() }) test('renders with custom color when provided', () => { const { container } = render( ) expect(container).toMatchSnapshot() }) test('does not throw when onClick is not provided', () => { expect(() => { render( ) }).not.toThrow() }) }) ================================================ FILE: src/components/MenuDropdown/MenuDropdownLabel/index.js ================================================ import { useLingui } from '@lingui/react' import { html } from 'htm/react' import { ArrowDownIcon, ArrowUpIcon } from '../../../lib-react-components' import { MenuDropdownItem } from '../MenuDropdownItem' import { Label } from '../styles' /** * @param {{ * isHidden: boolean, * selectedItem?: {name: string, icon?: import('react').ReactNode}, * isOpen: boolean, * setIsOpen?: (isOpen: boolean) => void, * testId?: string * }} props */ export const MenuDropdownLabel = ({ isHidden, selectedItem, isOpen, setIsOpen, testId }) => { const { i18n } = useLingui() return html` <${Label} isHidden=${isHidden} onClick=${() => setIsOpen?.(!isOpen)} data-testid=${testId} > <${isOpen ? ArrowUpIcon : ArrowDownIcon} size="24" /> ${selectedItem?.name ? html` <${MenuDropdownItem} testId=${`menudropdown-item-${selectedItem.name}`} item=${selectedItem} /> ` : i18n._('No folder')} ` } ================================================ FILE: src/components/MenuDropdown/MenuDropdownLabel/index.test.js ================================================ import React from 'react' import { render, screen } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { MenuDropdownLabel } from './index' import '@testing-library/jest-dom' jest.mock('../../../lib-react-components', () => ({ ArrowDownIcon: () =>
, ArrowUpIcon: () =>
})) jest.mock('../MenuDropdownItem', () => ({ MenuDropdownItem: ({ item }) => (
{item.name}
) })) jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (str) => str } }) })) describe('MenuDropdownLabel', () => { const mockSetIsOpen = jest.fn() beforeEach(() => { mockSetIsOpen.mockClear() }) const renderComponent = (props) => render( ) test('renders ArrowDownIcon when closed', () => { const { container } = renderComponent({ isOpen: false }) expect(screen.getByTestId('arrow-down-icon')).toBeInTheDocument() expect(screen.queryByTestId('arrow-up-icon')).not.toBeInTheDocument() expect(container).toMatchSnapshot() }) test('renders ArrowUpIcon when open', () => { renderComponent({ isOpen: true }) expect(screen.getByTestId('arrow-up-icon')).toBeInTheDocument() expect(screen.queryByTestId('arrow-down-icon')).not.toBeInTheDocument() }) test('renders "No folder" when no selectedItem is provided', () => { renderComponent() expect(screen.getByText('No folder')).toBeInTheDocument() }) test('renders MenuDropdownItem when selectedItem is provided', () => { const selectedItem = { name: 'Test Folder' } renderComponent({ selectedItem }) expect(screen.getByTestId('menu-dropdown-item')).toBeInTheDocument() expect(screen.getByText('Test Folder')).toBeInTheDocument() }) test('calls setIsOpen when clicked', () => { renderComponent({ isOpen: false }) screen.getByText('No folder').click() expect(mockSetIsOpen).toHaveBeenCalledWith(true) }) test('toggles isOpen value when clicked', () => { renderComponent({ isOpen: true }) screen.getByText('No folder').click() expect(mockSetIsOpen).toHaveBeenCalledWith(false) }) test('does not call setIsOpen when it is not provided', () => { renderComponent({ setIsOpen: undefined }) expect(() => { screen.getByText('No folder').click() }).not.toThrow() }) }) ================================================ FILE: src/components/MenuDropdown/index.js ================================================ import React, { useState } from 'react' import { html } from 'htm/react' import { MenuDropdownItem } from './MenuDropdownItem' import { MenuDropdownLabel } from './MenuDropdownLabel' import { DropDown, MainWrapper, Wrapper } from './styles' import { useOutsideClick } from '../../hooks/useOutsideClick' /** * @param {{ * selectedItem?: {name: string, icon?: import('react').ReactNode}, * onItemSelect: (item: {name: string, icon?: import('react').ReactNode}) => void, * items: Array<{name: string, icon?: import('react').ReactNode}>, * bottomComponent?: import('react').ReactNode, * testId?: string * }} props */ export const MenuDropdown = ({ selectedItem, onItemSelect, items, bottomComponent, testId }) => { const [isOpen, setIsOpen] = useState(false) const currentItems = React.useMemo( () => items.filter((item) => item?.name !== selectedItem?.name), [items, selectedItem] ) const wrapperRef = useOutsideClick({ onOutsideClick: () => { setIsOpen(false) } }) const handleFolderSelect = (item) => { onItemSelect(item) setIsOpen(false) } return html` <${MainWrapper} data-testid=${testId} ref=${wrapperRef}> <${MenuDropdownLabel} isHidden selectedItem=${selectedItem} isOpen=${isOpen} testId="menudropdown-defaultlabel-hidden" /> <${Wrapper} isOpen=${isOpen}> <${MenuDropdownLabel} selectedItem=${selectedItem} isOpen=${isOpen} setIsOpen=${setIsOpen} testId=${`menudropdown-defaultlabel-${selectedItem?.name || 'No folder'}`} /> ${isOpen && html`<${DropDown}> ${currentItems.map( (item) => html` <${MenuDropdownItem} key=${item.name} testId=${`menudropdown-item-${item.name}`} item=${item} onClick=${() => handleFolderSelect(item)} /> ` )} ${bottomComponent && bottomComponent} `} ` } ================================================ FILE: src/components/MenuDropdown/index.test.js ================================================ import React from 'react' import { render, screen, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { MenuDropdown } from './index' import '@testing-library/jest-dom' jest.mock('./MenuDropdownItem', () => ({ MenuDropdownItem: ({ item, onClick }) => (
{item.name}
) })) jest.mock('./MenuDropdownLabel', () => ({ MenuDropdownLabel: ({ selectedItem, isOpen, setIsOpen }) => (
setIsOpen && setIsOpen(!isOpen)} > {selectedItem ? selectedItem.name : 'No selected item'} {isOpen ? 'Open' : 'Closed'}
) })) jest.mock('../../hooks/useOutsideClick', () => ({ useOutsideClick: jest.fn() })) describe('MenuDropdown', () => { const mockOnItemSelect = jest.fn() const mockItems = [{ name: 'Item 1' }, { name: 'Item 2' }, { name: 'Item 3' }] beforeEach(() => { mockOnItemSelect.mockClear() }) const renderComponent = (props = {}) => render( ) test('renders both dropdown labels', () => { const { container } = renderComponent() const labels = screen.getAllByTestId('menu-dropdown-label') expect(labels).toHaveLength(2) expect(container).toMatchSnapshot() }) test('does not show dropdown items when closed', () => { renderComponent() expect(screen.queryAllByTestId('menu-dropdown-item')).toHaveLength(0) }) test('shows dropdown items when open', () => { renderComponent() const visibleLabel = screen.getAllByTestId('menu-dropdown-label')[1] fireEvent.click(visibleLabel) expect(screen.getAllByTestId('menu-dropdown-item')).toHaveLength(3) }) test('filters out selected item from dropdown options', () => { const selectedItem = mockItems[0] renderComponent({ selectedItem }) const visibleLabel = screen.getAllByTestId('menu-dropdown-label')[1] fireEvent.click(visibleLabel) const shownItems = screen.getAllByTestId('menu-dropdown-item') expect(shownItems).toHaveLength(2) expect(shownItems[0]).toHaveTextContent('Item 2') expect(shownItems[1]).toHaveTextContent('Item 3') }) test('calls onItemSelect when an item is clicked', () => { renderComponent() const visibleLabel = screen.getAllByTestId('menu-dropdown-label')[1] fireEvent.click(visibleLabel) const items = screen.getAllByTestId('menu-dropdown-item') fireEvent.click(items[1]) expect(mockOnItemSelect).toHaveBeenCalledWith(mockItems[1]) }) test('closes dropdown after item selection', () => { renderComponent() const visibleLabel = screen.getAllByTestId('menu-dropdown-label')[1] fireEvent.click(visibleLabel) const items = screen.getAllByTestId('menu-dropdown-item') fireEvent.click(items[0]) expect(screen.queryAllByTestId('menu-dropdown-item')).toHaveLength(0) }) test('renders bottomComponent when provided', () => { const bottom =
Bottom
renderComponent({ bottomComponent: bottom }) const visibleLabel = screen.getAllByTestId('menu-dropdown-label')[1] fireEvent.click(visibleLabel) expect(screen.getByTestId('bottom-component')).toBeInTheDocument() expect(screen.getByTestId('bottom-component')).toHaveTextContent('Bottom') }) }) ================================================ FILE: src/components/MenuDropdown/styles.js ================================================ import styled, { css } from 'styled-components' export const Label = styled.div.withConfig({ shouldForwardProp: (prop) => !['isHidden'].includes(prop) })` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-weight: 500; display: flex; align-items: center; gap: 7px; cursor: pointer; white-space: nowrap; ${({ isHidden }) => { if (isHidden) { return css` opacity: 0; pointer-events: none; padding: 4px 10px; ` } }} ` export const MainWrapper = styled.div` position: relative; ` export const Wrapper = styled.div.withConfig({ shouldForwardProp: (prop) => !['isOpen'].includes(prop) })` border-radius: 10px; background: ${({ theme }) => theme.colors.grey400.mode1}; border: 1px solid ${({ theme, isOpen }) => isOpen ? theme.colors.primary400.mode1 : theme.colors.grey100.mode1}; padding: 4px 10px; top: 0; left: 0; position: absolute; z-index: ${({ isOpen }) => (isOpen ? 6 : 5)}; &:hover { border-color: ${({ theme }) => theme.colors.primary400.mode1}; & ${Label} path { stroke: ${({ theme }) => theme.colors.primary400.mode1}; } } ` export const DropDown = styled.div` display: flex; flex-direction: column; gap: 4px; padding: 4px 5px 0 30px; ` export const DropDownItem = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-weight: 500; display: flex; align-items: center; gap: 10px; white-space: nowrap; cursor: pointer; ` export const FolderIconWrapper = styled.div` flex-shrink: 0; ` ================================================ FILE: src/components/NoticeContainer/index.js ================================================ import { html } from 'htm/react' import { Container } from './styles' import { YellowErrorIcon } from '../../lib-react-components' export const NoticeContainer = ({ text }) => html` <${Container}> <${YellowErrorIcon} size="19" /> ${text} ` ================================================ FILE: src/components/NoticeContainer/styles.js ================================================ import styled from 'styled-components' export const Container = styled.div` display: inline-flex; justify-content: center; padding: 10px; align-items: center; gap: 8px; border-radius: 10px; border: 1px solid #bd8610; background: linear-gradient( 0deg, #bd8610 -14.1%, rgba(70, 70, 70, 0.05) 125.68% ); box-shadow: 4px 4px 12px 0px rgba(216, 216, 216, 0.5), 0px 0.5px 4px 0px rgba(255, 255, 255, 0.25) inset; backdrop-filter: blur(7px); color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-style: normal; font-weight: 500; line-height: normal; ` ================================================ FILE: src/components/OnboardingShell/index.tsx ================================================ import React from 'react' import { useTheme } from '@tetherto/pearpass-lib-ui-kit' import { BackgroundWithGradient } from '../BackgroundWithGradient' import { ContentWrapper, Panel, ShellViewport, SolidBackground, Stage } from './styles' interface OnboardingShellProps { background: 'gradient' | 'solid' children: React.ReactNode } export const OnboardingShell = ({ background, children }: OnboardingShellProps): React.ReactElement => { const { theme } = useTheme() const content = ( {children} ) if (background === 'gradient') { return ( {content} ) } return ( {content} ) } ================================================ FILE: src/components/OnboardingShell/styles.ts ================================================ import styled from 'styled-components' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' interface SolidBackgroundProps { $backgroundColor: string } export const ShellViewport = styled.div` display: flex; width: 100%; height: 100%; min-height: 100%; box-sizing: border-box; overflow: hidden; & > * { flex: 1 1 auto; min-height: 0; } ` export const SolidBackground = styled.div` width: 100%; height: 100%; background-color: ${({ $backgroundColor }) => $backgroundColor}; ` export const Stage = styled.div` width: 100%; height: 100%; ` export const Panel = styled.div<{ $isSolid?: boolean }>` position: relative; width: 100%; height: 100%; overflow: hidden; background: transparent; box-shadow: ${({ $isSolid }) => $isSolid ? 'none' : `inset 0 1px 0 rgba(255, 255, 255, 0.03), 0 20px 60px rgba(0, 0, 0, 0.28)`}; &::after { content: ''; display: ${({ $isSolid }) => ($isSolid ? 'none' : 'block')}; position: absolute; inset: 0; pointer-events: none; background: linear-gradient( 180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0) 18% ); } ` export const ContentWrapper = styled.div` position: relative; display: flex; width: 100%; height: 100%; align-items: center; justify-content: center; padding: 27px 32px; text-align: center; @media (max-height: 820px) { padding: 27px ${rawTokens.spacing24}px; } @media (max-width: 1100px) { padding: 27px ${rawTokens.spacing24}px; } ` export const SharedVideo = styled.video` width: 282px; height: 282px; flex: 0 0 auto; object-fit: contain; z-index: 10; mix-blend-mode: screen; background: transparent; isolation: isolate; filter: saturate(1.05) brightness(1.02); ` ================================================ FILE: src/components/OtpCodeField/index.test.js ================================================ import React from 'react' import { render, screen, fireEvent } from '@testing-library/react' import '@testing-library/jest-dom' import { OtpCodeField } from './index' const mockGenerateNext = jest.fn() const mockUseOtp = jest.fn() jest.mock('@tetherto/pearpass-lib-vault', () => ({ useOtp: (...args) => mockUseOtp(...args), formatOtpCode: (code) => { if (!code) return '' const mid = Math.ceil(code.length / 2) return code.slice(0, mid) + ' ' + code.slice(mid) }, OTP_TYPE: { TOTP: 'TOTP', HOTP: 'HOTP' } })) jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (msg) => msg } }) })) jest.mock('../CopyButton', () => ({ CopyButton: ({ value, testId }) => ( ) })) jest.mock('../../lib-react-components', () => ({ InputField: ({ label, value, additionalItems, belowInputContent, testId }) => (
{label} {value}
{additionalItems}
{belowInputContent && (
{belowInputContent}
)}
), LockIcon: () => LockIcon })) jest.mock('../TimerBar', () => ({ TimerBar: ({ timeRemaining }) => (
{timeRemaining}s
) })) const useOtp = mockUseOtp describe('OtpCodeField', () => { beforeEach(() => { jest.clearAllMocks() }) test('renders TOTP code with progress bar', () => { useOtp.mockReturnValue({ code: '123456', timeRemaining: 20, type: 'TOTP', period: 30, generateNext: null, isLoading: false }) render( ) expect(screen.getByTestId('otp-label')).toHaveTextContent( 'Authenticator Token' ) expect(screen.getByTestId('otp-value')).toHaveTextContent('123 456') expect(screen.getByTestId('otp-progress-bar')).toHaveTextContent('20s') expect(screen.getByTestId('otp-copy-button')).toBeInTheDocument() }) test('renders HOTP code with Next Code button', () => { useOtp.mockReturnValue({ code: '111222', timeRemaining: null, type: 'HOTP', period: null, generateNext: mockGenerateNext, isLoading: false }) render( ) expect(screen.getByTestId('otp-value')).toHaveTextContent('111 222') expect(screen.getByTestId('otp-next-code-button')).toHaveTextContent( 'Next Code' ) expect(screen.queryByTestId('otp-progress-bar')).not.toBeInTheDocument() }) test('HOTP Next Code button calls generateNext', () => { useOtp.mockReturnValue({ code: '111222', timeRemaining: null, type: 'HOTP', period: null, generateNext: mockGenerateNext, isLoading: false }) render( ) fireEvent.click(screen.getByTestId('otp-next-code-button')) expect(mockGenerateNext).toHaveBeenCalledTimes(1) }) test('formats codes with odd digit count correctly', () => { useOtp.mockReturnValue({ code: '1234567', timeRemaining: 20, type: 'TOTP', period: 30, generateNext: null, isLoading: false }) render( ) // 7 digits: mid = 4, so "1234 567" expect(screen.getByTestId('otp-value')).toHaveTextContent('1234 567') }) }) ================================================ FILE: src/components/OtpCodeField/index.ts ================================================ import { useLingui } from '@lingui/react' import { html } from 'htm/react' import { useOtp, formatOtpCode, OTP_TYPE } from '@tetherto/pearpass-lib-vault' import { InputField, LockIcon } from '../../lib-react-components' import { CopyButton } from '../CopyButton' import { TimerBar } from '../TimerBar' import type { OtpPublic } from '@tetherto/pearpass-lib-vault/src/types' interface OtpCodeFieldProps { recordId: string otpPublic: OtpPublic testId?: string } export const OtpCodeField = ({ recordId, otpPublic, testId }: OtpCodeFieldProps) => { const { i18n } = useLingui() const { code, timeRemaining, type, period, generateNext, isLoading } = useOtp( { recordId, otpPublic } ) const formattedCode = formatOtpCode(code) const isTOTP = type === OTP_TYPE.TOTP const hasTimeData = isTOTP && timeRemaining !== null const timerBar = isTOTP ? html`
<${TimerBar} timeRemaining=${timeRemaining} period=${period} />
` : null return html` <${InputField} testId=${testId || 'otp-code-field'} label=${i18n._('Authenticator Token')} value=${formattedCode} variant="outline" icon=${LockIcon} isDisabled belowInputContent=${timerBar} additionalItems=${html` ${type === OTP_TYPE.HOTP && generateNext && html` `} <${CopyButton} value=${code} testId="otp-copy-button" /> `} /> ` } ================================================ FILE: src/components/OtpCodeField/utils.ts ================================================ import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' export const getTimerColor = (expiring: boolean): string => expiring ? colors.errorRed.mode1 : colors.primary400.mode1 ================================================ FILE: src/components/OtpCodeFieldV2/index.test.tsx ================================================ import React from 'react' import '@testing-library/jest-dom' import { fireEvent, render, screen } from '@testing-library/react' const mockGenerateNext = jest.fn() const mockUseOtp = jest.fn() const mockCopyToClipboard = jest.fn() jest.mock('@tetherto/pearpass-lib-vault', () => ({ useOtp: (...args: unknown[]) => mockUseOtp(...args), formatOtpCode: (code: string | null) => { if (!code) return '' const mid = Math.ceil(code.length / 2) return code.slice(0, mid) + ' ' + code.slice(mid) }, OTP_TYPE: { TOTP: 'TOTP', HOTP: 'HOTP' }, useTimerAnimation: (timeRemaining: number | null, period: number) => ({ noTransition: false, expiring: timeRemaining !== null && timeRemaining <= 5, targetTime: timeRemaining ?? period }) })) jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (msg: string) => msg } }) })) jest.mock('../../hooks/useCopyToClipboard.electron', () => ({ useCopyToClipboard: () => ({ copyToClipboard: mockCopyToClipboard }) })) jest.mock('@tetherto/pearpass-lib-ui-kit', () => ({ Button: ({ children, onClick, disabled, 'data-testid': testId, 'aria-label': ariaLabel, iconBefore }: { children?: React.ReactNode onClick?: () => void disabled?: boolean 'data-testid'?: string 'aria-label'?: string iconBefore?: React.ReactNode }) => ( ), Text: ({ children, color }: { children?: React.ReactNode color?: string }) => {children}, useTheme: () => ({ theme: { colors: { colorTextPrimary: '#fff', colorTextSecondary: '#aaa', colorTextDestructive: '#f00', colorPrimary: '#0f0', colorBorderPrimary: '#333', colorSurfacePrimary: '#000' } } }), rawTokens: { spacing2: 2, spacing8: 8, spacing12: 12, radius8: 8 } })) jest.mock('@tetherto/pearpass-lib-ui-kit/icons', () => ({ ContentCopy: () => })) import { OtpCodeFieldV2 } from './index' const useOtp = mockUseOtp describe('OtpCodeFieldV2', () => { beforeEach(() => { jest.clearAllMocks() }) test('renders TOTP code with timer and copy button', () => { useOtp.mockReturnValue({ code: '123456', timeRemaining: 20, type: 'TOTP', period: 30, generateNext: null, isLoading: false }) render( [0]['otpPublic'] } /> ) expect(screen.getByText('Authenticator Token')).toBeInTheDocument() expect(screen.getByText('123 456')).toBeInTheDocument() expect(screen.getByText('20s')).toBeInTheDocument() expect(screen.getByTestId('otp-code-field-v2-copy')).toBeInTheDocument() expect( screen.queryByTestId('otp-code-field-v2-next-code') ).not.toBeInTheDocument() }) test('copy button calls copyToClipboard with the code', () => { useOtp.mockReturnValue({ code: '123456', timeRemaining: 20, type: 'TOTP', period: 30, generateNext: null, isLoading: false }) render( [0]['otpPublic'] } /> ) fireEvent.click(screen.getByTestId('otp-code-field-v2-copy')) expect(mockCopyToClipboard).toHaveBeenCalledWith('123456') }) test('renders HOTP code with Next Code button (no timer)', () => { useOtp.mockReturnValue({ code: '111222', timeRemaining: null, type: 'HOTP', period: null, generateNext: mockGenerateNext, isLoading: false }) render( [0]['otpPublic'] } /> ) expect(screen.getByText('111 222')).toBeInTheDocument() expect( screen.getByTestId('otp-code-field-v2-next-code').textContent ).toContain('Next Code') expect(screen.queryByText(/s$/)).not.toBeInTheDocument() }) test('HOTP Next Code button calls generateNext', () => { useOtp.mockReturnValue({ code: '111222', timeRemaining: null, type: 'HOTP', period: null, generateNext: mockGenerateNext, isLoading: false }) render( [0]['otpPublic'] } /> ) fireEvent.click(screen.getByTestId('otp-code-field-v2-next-code')) expect(mockGenerateNext).toHaveBeenCalledTimes(1) }) }) ================================================ FILE: src/components/OtpCodeFieldV2/index.tsx ================================================ import React from 'react' import { useLingui } from '@lingui/react' import { Button, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { ContentCopy } from '@tetherto/pearpass-lib-ui-kit/icons' import { OTP_TYPE, formatOtpCode, useOtp, useTimerAnimation } from '@tetherto/pearpass-lib-vault' import type { OtpPublic } from '@tetherto/pearpass-lib-vault/src/types' import { useCopyToClipboard } from '../../hooks/useCopyToClipboard.electron' import { createStyles } from './styles' const TIMER_ANIMATION_DURATION = 1000 interface OtpCodeFieldV2Props { recordId: string otpPublic: OtpPublic isGrouped?: boolean testID?: string } export const OtpCodeFieldV2 = ({ recordId, otpPublic, isGrouped = false, testID }: OtpCodeFieldV2Props) => { const { i18n } = useLingui() const { theme } = useTheme() const styles = createStyles(theme.colors) const { copyToClipboard } = useCopyToClipboard() const { code, timeRemaining, type, period, generateNext, isLoading } = useOtp( { recordId, otpPublic } ) const formattedCode = formatOtpCode(code) const isTOTP = type === OTP_TYPE.TOTP const isHOTP = type === OTP_TYPE.HOTP const { noTransition, expiring, targetTime } = useTimerAnimation( timeRemaining, period ?? 30 ) const progress = timeRemaining !== null && period ? (targetTime / period) * 100 : 0 const timerColor = expiring ? theme.colors.colorTextDestructive : theme.colors.colorPrimary const cardStyle = isGrouped ? styles.cardGrouped : styles.card return (
{i18n._('Authenticator Token')} {formattedCode || ''}
{isTOTP && (
{timeRemaining !== null ? `${timeRemaining}s` : ''}
)} {isHOTP && generateNext && ( )}
) } ================================================ FILE: src/components/OtpCodeFieldV2/styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = (colors: ThemeColors) => ({ card: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing12}px`, padding: `${rawTokens.spacing12}px`, borderWidth: '1px', borderStyle: 'solid' as const, borderColor: colors.colorBorderPrimary, borderRadius: `${rawTokens.radius8}px`, backgroundColor: colors.colorSurfacePrimary, boxSizing: 'border-box' as const }, cardGrouped: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing12}px`, padding: `${rawTokens.spacing12}px`, backgroundColor: 'transparent', borderBottom: `1px solid ${colors.colorBorderPrimary}`, boxSizing: 'border-box' as const }, topRow: { display: 'flex' as const, flexDirection: 'row' as const, alignItems: 'center' as const, gap: `${rawTokens.spacing8}px` }, innerColumn: { flex: 1, minWidth: 0, display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing2}px` }, timerRow: { display: 'flex' as const, flexDirection: 'row' as const, alignItems: 'center' as const, gap: `${rawTokens.spacing8}px` }, timerTrack: { flex: 1, height: 6, borderRadius: `${rawTokens.radius8}px`, backgroundColor: colors.colorBorderPrimary, overflow: 'hidden' as const }, timerFill: { height: '100%', borderRadius: `${rawTokens.radius8}px` }, timerLabel: { minWidth: 28, textAlign: 'right' as const } }) ================================================ FILE: src/components/Overlay/index.js ================================================ import { useRef } from 'react' import { useTheme } from '@tetherto/pearpass-lib-ui-kit' import { html } from 'htm/react' import { OverlayComponent } from './styles' import { BASE_TRANSITION_DURATION } from '../../constants/transitions' import { useAnimatedVisibility } from '../../hooks/useAnimatedVisibility' /** * @param {{ * isOpen: boolean * onClick: () => void * type: 'default' | 'blur' * }} props */ export const Overlay = ({ isOpen, onClick, type = 'default' }) => { const { theme } = useTheme() const nodeRef = useRef(null) const { isShown, isRendered } = useAnimatedVisibility({ isOpen: isOpen, transitionDuration: BASE_TRANSITION_DURATION, nodeRef, propertyName: 'opacity' }) if (!isRendered) { return null } return html` <${OverlayComponent} ref=${nodeRef} type=${type} isShown=${isShown} scrim=${theme.colors.colorScrim} onClick=${() => onClick?.()} /> ` } ================================================ FILE: src/components/Overlay/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { Overlay } from './index' import '@testing-library/jest-dom' jest.mock('../../hooks/useAnimatedVisibility', () => ({ useAnimatedVisibility: jest.fn().mockImplementation(({ isOpen }) => ({ isShown: isOpen, isRendered: isOpen })) })) describe('Overlay', () => { const mockOnClick = jest.fn() beforeEach(() => { mockOnClick.mockClear() jest.clearAllMocks() }) const renderComponent = (props = {}) => render( ) test('does not render when isRendered is false', () => { require('../../hooks/useAnimatedVisibility').useAnimatedVisibility.mockReturnValue( { isShown: false, isRendered: false } ) const { container } = renderComponent() expect(container).toBeEmptyDOMElement() expect(container).toMatchSnapshot() }) test('renders when isRendered is true', async () => { require('../../hooks/useAnimatedVisibility').useAnimatedVisibility.mockReturnValue( { isShown: true, isRendered: true } ) const { container } = renderComponent({ isOpen: true }) expect(container.querySelector('div[type="default"]')).toBeInTheDocument() }) test('calls onClick when clicked', () => { require('../../hooks/useAnimatedVisibility').useAnimatedVisibility.mockReturnValue( { isShown: true, isRendered: true } ) const { container } = renderComponent({ isOpen: true }) fireEvent.click(container.querySelector('div[type="default"]')) expect(mockOnClick).toHaveBeenCalledTimes(1) }) test('passes type prop to OverlayComponent', () => { require('../../hooks/useAnimatedVisibility').useAnimatedVisibility.mockReturnValue( { isShown: true, isRendered: true } ) const { container } = renderComponent({ isOpen: true, type: 'blur' }) expect(container.querySelector('div[type="blur"]')).toBeInTheDocument() }) test('uses default type when not provided', () => { require('../../hooks/useAnimatedVisibility').useAnimatedVisibility.mockReturnValue( { isShown: true, isRendered: true } ) const { container } = renderComponent({ isOpen: true }) expect(container.querySelector('div[type="default"]')).toBeInTheDocument() }) }) ================================================ FILE: src/components/Overlay/styles.js ================================================ import styled from 'styled-components' import { BASE_TRANSITION_DURATION } from '../../constants/transitions' const SCRIM_BLUR = 'rgba(0, 0, 0, 0.5)' export const OverlayComponent = styled.div.withConfig({ shouldForwardProp: (prop) => !['isShown', 'scrim'].includes(prop) })` position: fixed; top: 0; left: 0; width: 100%; height: 100%; opacity: ${({ isShown }) => (isShown ? 1 : 0)}; transition: opacity ${BASE_TRANSITION_DURATION}ms ease-in-out; background: ${({ type, scrim }) => { if (type === 'default') { return scrim } if (type === 'blur') { return SCRIM_BLUR } }}; backdrop-filter: ${({ type }) => { if (type === 'default') { return 'none' } if (type === 'blur') { return 'blur(10px)' } }}; ` ================================================ FILE: src/components/PasswordFieldStrengthIndicator/index.test.tsx ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import '@testing-library/jest-dom' import { PasswordFieldStrengthIndicator } from './index' import { PassType } from '../../shared/types' // mocks jest.mock('@tetherto/pearpass-lib-ui-kit', () => ({ PasswordField: jest.fn(({ onChange, ...props }) => ( onChange(e)} /> )) })) jest.mock('../../hooks/useTranslation', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) jest.mock('../../utils/getPasswordStrengthInfo', () => ({ getPasswordStrength: jest.fn() })) jest.mock('../../constants/password', () => ({ STRENGTH_MAP: { error: 'vulnerable', warning: 'decent', success: 'strong' } })) describe('PasswordFieldStrengthIndicator', () => { const mockOnChange = jest.fn() const mockOnBlur = jest.fn() const mockSetPasswordType = jest.fn() const baseProps = { label: 'Password', placeholder: 'Enter Password', testID: 'password-input', passwordType: PassType.Password, setPasswordType: mockSetPasswordType, passwordField: { name: 'password', value: '123456', onChange: mockOnChange, onBlur: mockOnBlur, error: '' } } beforeEach(() => { jest.clearAllMocks() }) test('renders input with value', () => { const { getByTestId } = render( ) expect(getByTestId('password-input')).toHaveValue('123456') }) test('calls passwordField.onChange on input change', () => { const { getByTestId } = render( ) fireEvent.change(getByTestId('password-input'), { target: { value: 'newpass' } }) expect(mockOnChange).toHaveBeenCalledWith('newpass') }) test('resets passwordType when not Password', () => { const { getByTestId } = render( ) fireEvent.change(getByTestId('password-input'), { target: { value: 'newpass' } }) expect(mockSetPasswordType).toHaveBeenCalledWith(PassType.Password) }) test('does not reset passwordType if already Password', () => { const { getByTestId } = render( ) fireEvent.change(getByTestId('password-input'), { target: { value: 'newpass' } }) expect(mockSetPasswordType).not.toHaveBeenCalled() }) test('passes error when provided', () => { const { getByTestId } = render( ) const input = getByTestId('password-input') expect(input).toBeInTheDocument() }) test('computes password strength and maps indicator', () => { const { getPasswordStrength } = require('../../utils/getPasswordStrengthInfo') const { PasswordField } = require('@tetherto/pearpass-lib-ui-kit') getPasswordStrength.mockReturnValue({ strengthType: 'success' }) render() expect(getPasswordStrength).toHaveBeenCalledWith('123456', PassType.Password) expect(PasswordField).toHaveBeenLastCalledWith( expect.objectContaining({ passwordIndicator: 'strong' }), undefined ) }) test('handles undefined password strength', () => { const { getPasswordStrength } = require('../../utils/getPasswordStrengthInfo') getPasswordStrength.mockReturnValue(undefined) render() expect(getPasswordStrength).toHaveBeenCalled() }) }) ================================================ FILE: src/components/PasswordFieldStrengthIndicator/index.tsx ================================================ import React, { useMemo } from 'react' import { PasswordField, PasswordIndicatorVariant } from '@tetherto/pearpass-lib-ui-kit' import { PassType } from '../../shared/types' import { getPasswordStrength } from '../../utils/getPasswordStrengthInfo' import { STRENGTH_MAP } from '../../constants/password' import { useTranslation } from '../../hooks/useTranslation' interface IPasswordField { onChange: (value: string) => void onBlur: (event: React.FocusEvent) => void name: string value: string error: string } interface IPasswordFieldStrengthIndicatorProps { label?: string placeholder?: string passwordField: IPasswordField testID: string passwordType: PassType setPasswordType: React.Dispatch> } export const PasswordFieldStrengthIndicator = ({ label = 'Password', placeholder = 'Enter Password', passwordType, setPasswordType, passwordField, testID }: IPasswordFieldStrengthIndicatorProps) => { const { t } = useTranslation() const passwordStrength = useMemo(() => { return getPasswordStrength(passwordField.value, passwordType) }, [passwordField.value, passwordType]) const passwordIndicator: PasswordIndicatorVariant | undefined = passwordStrength ? STRENGTH_MAP[passwordStrength.strengthType] : undefined return ( { passwordField.onChange(e.target.value) if (passwordType !== PassType.Password) { setPasswordType(PassType.Password) } }} error={passwordField.error || undefined} testID={testID} passwordIndicator={passwordIndicator} /> ) } ================================================ FILE: src/components/PopupMenu/index.js ================================================ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { html } from 'htm/react' import { MenuCard, MenuTrigger, MenuWrapper, TRANSITION_DURATION } from './styles' import { getHorizontal } from './utils/getHorizontal' import { getVertical } from './utils/getVertical' import { useOutsideClick } from '../../hooks/useOutsideClick' import { toSentenceCase } from '../../utils/toSentenceCase' /** * @param {{ * isOpen: boolean, * setIsOpen: (isOpen: boolean) => void, * content: import('react').ReactNode, * children: import('react').ReactNode, * direction: 'top' | 'bottom' | 'left' | 'right' | 'topRight' | 'topLeft' | 'bottomRight' | 'bottomLeft' * displayOnHover?: boolean * testId?: string * }} props */ export const PopupMenu = ({ isOpen, setIsOpen, children, content, direction = 'bottomLeft', displayOnHover = false, testId }) => { const boxRef = useRef(null) const closeTimeoutRef = useRef(null) const [shouldRender, setShouldRender] = useState(false) const handleClose = useCallback(() => { if (displayOnHover) { closeTimeoutRef.current = setTimeout(() => { setIsOpen(false) }, 100) } else { setIsOpen(false) } }, [setIsOpen, displayOnHover]) const handleOpen = useCallback(() => { if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current) closeTimeoutRef.current = null } setIsOpen(true) }, [setIsOpen]) const wrapperRef = useOutsideClick({ onOutsideClick: () => { handleClose() } }) const handleToggle = useCallback(() => { setIsOpen(!isOpen) }, [isOpen, setIsOpen]) const { newDirection, newPositions } = useMemo(() => { const { right = 0, left = 0, top = 0, bottom = 0 } = boxRef.current?.getBoundingClientRect() || {} const width = boxRef.current?.children[0]?.getBoundingClientRect().width ?? 0 const height = boxRef.current?.children[0]?.getBoundingClientRect().height ?? 0 const screenWidth = window.innerWidth const screenHeight = window.innerHeight const positionToSet = { horizontal: getHorizontal(direction), vertical: getVertical(direction) } const rightPosition = screenWidth - right const leftPosition = left const topPosition = top const bottomPosition = screenHeight - bottom const newPositions = { right: rightPosition - (positionToSet.horizontal === 'right' ? width : 0), left: leftPosition - (positionToSet.horizontal === 'left' ? width : 0), top: topPosition - (positionToSet.vertical === 'top' ? height : 0), bottom: bottomPosition - (positionToSet.vertical === 'bottom' ? height : 0), width: width, height: height } if (newPositions.top < 0) { positionToSet.vertical = 'bottom' } if (newPositions.bottom < 0) { positionToSet.vertical = 'top' } if (newPositions.left < 0) { positionToSet.horizontal = 'right' } if (newPositions.right < 0) { positionToSet.horizontal = 'left' } return { newDirection: `${positionToSet.vertical}${ positionToSet.vertical ? toSentenceCase(positionToSet.horizontal) : positionToSet.horizontal }`, newPositions: newPositions } }, [boxRef, direction, shouldRender]) const contentOrigin = useMemo(() => { if (!wrapperRef.current) { return { top: 0, left: 0 } } const { top = 0, bottom = 0, left = 0, right = 0, width = 0, height = 0 } = wrapperRef.current.getBoundingClientRect() || {} switch (newDirection) { case 'top': return { top: top, left: left + width / 2 } case 'bottom': return { top: bottom, left: left + width / 2 } case 'left': return { top: top + height / 2, left: left } case 'right': return { top: top + height / 2, left: right } case 'topRight': return { top: top, left: left } case 'topLeft': return { top: top, left: right } case 'bottomRight': return { top: bottom, left: left } case 'bottomLeft': return { top: bottom, left: right } default: return { top: 0, left: 0 } } }, [newDirection, wrapperRef, isOpen, shouldRender]) const getScrollableAncestors = (element) => { const scrollableAncestors = [] let parent = element.parentElement while (parent) { const overflowX = window.getComputedStyle(parent).overflowX const overflowY = window.getComputedStyle(parent).overflowY if ( ['auto', 'scroll'].includes(overflowX) || ['auto', 'scroll'].includes(overflowY) ) { scrollableAncestors.push(parent) } parent = parent.parentElement } return scrollableAncestors } useEffect(() => { if (!wrapperRef.current) { return } const scrollableAncestors = getScrollableAncestors(wrapperRef.current) if (isOpen) { window.addEventListener('scroll', handleClose) window.addEventListener('resize', handleClose) scrollableAncestors.forEach((ancestor) => { ancestor.addEventListener('scroll', handleClose) }) } return () => { window.removeEventListener('scroll', handleClose) window.removeEventListener('resize', handleClose) scrollableAncestors.forEach((ancestor) => { ancestor.removeEventListener('scroll', handleClose) }) } }, [wrapperRef, isOpen, handleClose]) useEffect(() => { if (isOpen) { setShouldRender(true) } else { const timer = setTimeout(() => { setShouldRender(false) }, TRANSITION_DURATION) return () => { clearTimeout(timer) } } }, [isOpen]) useEffect( () => () => { if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current) } }, [] ) return html` <${MenuWrapper} ref=${wrapperRef} onMouseEnter=${displayOnHover ? handleOpen : undefined} onMouseLeave=${displayOnHover ? handleClose : undefined} > <${MenuTrigger} data-testid=${testId} onClick=${!displayOnHover && handleToggle} > ${children} <${MenuCard} ref=${boxRef} direction=${newDirection} top=${contentOrigin.top} left=${contentOrigin.left} isOpen=${isOpen} shouldRender=${shouldRender} height=${newPositions.height} width=${newPositions.width} > ${content} ` } ================================================ FILE: src/components/PopupMenu/index.test.js ================================================ import React, { useState } from 'react' import { render, fireEvent, waitFor, act } from '@testing-library/react' import { PopupMenu } from './index' import { TRANSITION_DURATION } from './styles' import '@testing-library/jest-dom' const PopupMenuWrapper = ({ children, content, direction = 'bottomLeft', initialOpen = false }) => { const [isOpen, setIsOpen] = useState(initialOpen) return ( {children} ) } describe('PopupMenu Component', () => { beforeEach(() => { jest.useFakeTimers() }) afterEach(() => { jest.runOnlyPendingTimers() jest.useRealTimers() }) test('toggles open state when trigger is clicked (by opacity)', async () => { const { getByText, container } = render( Menu Content
}> ) const menuContentInitial = getByText('Menu Content') const menuCardInitial = menuContentInitial.parentElement const computedStyle = window.getComputedStyle(menuCardInitial) expect(computedStyle.opacity).toBe('0') fireEvent.click(getByText('Toggle Menu')) await waitFor(() => { const menuContentOpen = getByText('Menu Content') const menuCardOpen = menuContentOpen.parentElement const computedStyleOpen = window.getComputedStyle(menuCardOpen) expect(computedStyleOpen.opacity).toBe('1') }) fireEvent.click(getByText('Toggle Menu')) act(() => { jest.advanceTimersByTime(TRANSITION_DURATION) }) await waitFor(() => { const menuContentClosed = getByText('Menu Content') const menuCardClosed = menuContentClosed.parentElement const computedStyleClosed = window.getComputedStyle(menuCardClosed) expect(computedStyleClosed.opacity).toBe('0') }) expect(container).toMatchSnapshot() }) test('closes menu when clicking outside', async () => { const { getByText } = render(
Outside Element
Menu Content
}>
) fireEvent.click(getByText('Toggle Menu')) await waitFor(() => { const menuContent = getByText('Menu Content') const menuCard = menuContent.parentElement expect(window.getComputedStyle(menuCard).opacity).toBe('1') }) fireEvent.mouseDown(getByText('Outside Element')) act(() => { jest.advanceTimersByTime(TRANSITION_DURATION) }) await waitFor(() => { const menuContent = getByText('Menu Content') const menuCard = menuContent.parentElement expect(window.getComputedStyle(menuCard).opacity).toBe('0') }) }) test('removes content from the DOM after transition completes', async () => { const { getByText } = render( Menu Content
} initialOpen={true}> ) const menuContent = getByText('Menu Content') const menuCard = menuContent.parentElement expect(window.getComputedStyle(menuCard).visibility).toBe('visible') fireEvent.click(getByText('Toggle Menu')) expect(window.getComputedStyle(menuCard).opacity).toBe('0') expect(window.getComputedStyle(menuCard).visibility).toBe('visible') act(() => { jest.advanceTimersByTime(TRANSITION_DURATION) }) await waitFor(() => { expect(window.getComputedStyle(menuCard).visibility).toBe('hidden') }) }) test('handles window resize by closing the menu', async () => { const { getByText } = render( Menu Content}> ) fireEvent.click(getByText('Toggle Menu')) await waitFor(() => { const menuContent = getByText('Menu Content') const menuCard = menuContent.parentElement expect(window.getComputedStyle(menuCard).opacity).toBe('1') }) fireEvent(window, new Event('resize')) act(() => { jest.advanceTimersByTime(TRANSITION_DURATION) }) await waitFor(() => { const menuContent = getByText('Menu Content') const menuCard = menuContent.parentElement expect(window.getComputedStyle(menuCard).opacity).toBe('0') }) }) }) ================================================ FILE: src/components/PopupMenu/styles.js ================================================ import styled from 'styled-components' export const TRANSITION_DURATION = 250 const TRANSFORM_BY_DIRECTION = { top: 'translate(-100%, calc(-100% - 10px))', right: 'translate(10px, -50%)', bottom: 'translate(-50%, 10px)', left: 'translate(10px, -50%)', topRight: 'translate(0, calc(-100% - 10px))', topLeft: 'translate(-100%, calc(-100% - 10px))', bottomRight: 'translate(0, 10px)', bottomLeft: 'translate(-100%, 10px)' } export const MenuWrapper = styled.div` position: relative; display: inline-block; ` export const MenuCard = styled.div.withConfig({ shouldForwardProp: (prop) => ![ 'direction', 'isOpen', 'top', 'left', 'height', 'width', 'shouldRender' ].includes(prop) })` height: ${({ height }) => height}px; width: ${({ width }) => width}px; position: fixed; z-index: 20000; left: ${({ left }) => left}px; top: ${({ top }) => top}px; opacity: ${({ isOpen }) => (isOpen ? 1 : 0)}; visibility: ${({ shouldRender }) => (shouldRender ? 'visible' : 'hidden')}; pointer-events: ${({ isOpen }) => (isOpen ? 'auto' : 'none')}; transition: opacity ${TRANSITION_DURATION}ms ease-in-out, visibility ${TRANSITION_DURATION}ms ease-in-out; & { transform: ${({ direction }) => TRANSFORM_BY_DIRECTION[direction]}; } @keyframes identifier { from { opacity: 0; } to { opacity: 1; } } ` export const MenuTrigger = styled.div` cursor: pointer; ` ================================================ FILE: src/components/PopupMenu/utils/getHorizontal.js ================================================ /** * @param {'left' | 'right' | 'topRight' | 'topLeft' | 'bottomRight' | 'bottomLeft'} direction * @returns {'left' | 'right' | ''} */ export const getHorizontal = (direction) => { if (direction?.toLowerCase().includes('left')) { return 'left' } if (direction?.toLowerCase().includes('right')) { return 'right' } return '' } ================================================ FILE: src/components/PopupMenu/utils/getHorizontal.test.js ================================================ import { getHorizontal } from './getHorizontal' describe('getHorizontal', () => { it('should return "left" when direction contains "left"', () => { expect(getHorizontal('left')).toBe('left') expect(getHorizontal('topLeft')).toBe('left') expect(getHorizontal('bottomLeft')).toBe('left') }) it('should return "right" when direction contains "right"', () => { expect(getHorizontal('right')).toBe('right') expect(getHorizontal('topRight')).toBe('right') expect(getHorizontal('bottomRight')).toBe('right') }) it('should return empty string when direction does not contain "left" or "right"', () => { expect(getHorizontal('top')).toBe('') expect(getHorizontal('bottom')).toBe('') expect(getHorizontal('')).toBe('') }) it('should handle undefined input', () => { expect(getHorizontal(undefined)).toBe('') }) it('should be case insensitive', () => { expect(getHorizontal('LEFT')).toBe('left') expect(getHorizontal('Right')).toBe('right') expect(getHorizontal('topLEFT')).toBe('left') expect(getHorizontal('bottomRIGHT')).toBe('right') }) }) ================================================ FILE: src/components/PopupMenu/utils/getVertical.js ================================================ /** * @param {'top' | 'bottom' | 'topRight' | 'topLeft' | 'bottomRight' | 'bottomLeft'} direction * @returns {'top' | 'bottom' | ''} */ export const getVertical = (direction) => { if (direction?.includes('top')) { return 'top' } if (direction?.includes('bottom')) { return 'bottom' } return '' } ================================================ FILE: src/components/PopupMenu/utils/getVertical.test.js ================================================ import { getVertical } from './getVertical' describe('getVertical', () => { test('should return "top" when direction includes "top"', () => { expect(getVertical('top')).toBe('top') expect(getVertical('topRight')).toBe('top') expect(getVertical('topLeft')).toBe('top') }) test('should return "bottom" when direction includes "bottom"', () => { expect(getVertical('bottom')).toBe('bottom') expect(getVertical('bottomRight')).toBe('bottom') expect(getVertical('bottomLeft')).toBe('bottom') }) test('should return empty string for other directions', () => { expect(getVertical('left')).toBe('') expect(getVertical('right')).toBe('') expect(getVertical('')).toBe('') expect(getVertical(null)).toBe('') expect(getVertical(undefined)).toBe('') }) }) ================================================ FILE: src/components/RadioSelect/index.js ================================================ import { html } from 'htm/react' import { RadioOption, RadioSelectWrapper, Title } from './styles' import { ButtonRadio } from '../../lib-react-components' /** * @param {{ * title?: string, * options: { label: string, value: string }[], * selectedOption: string, * onChange: (value: string) => void, * optionStyle?: object, * titleStyle?: object, * buttonType?: 'button' | 'submit', * disabled?: boolean * }} props */ export const RadioSelect = ({ title, options, selectedOption, onChange, optionStyle, titleStyle, buttonType = 'button', disabled = false }) => { const handleChange = (value) => { onChange(value) } return html` <${RadioSelectWrapper} data-testid="radioselect-container"> ${title && html`<${Title} style=${titleStyle}>${title}`} ${options.map( (option) => html` <${RadioOption} key=${option.value} onClick=${() => handleChange(option.value)} style=${optionStyle} data-testid="radioselect-${option.value}-${selectedOption === option.value ? 'active' : 'inactive'}" > <${ButtonRadio} type=${buttonType} isActive=${selectedOption === option.value} disabled=${disabled} /> ${option.label} ` )} ` } ================================================ FILE: src/components/RadioSelect/index.test.js ================================================ import React from 'react' import { render, screen, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { RadioSelect } from './index' import '@testing-library/jest-dom' jest.mock('./styles', () => ({ RadioSelectWrapper: ({ children }) => (
{children}
), Title: ({ children }) =>
{children}
, RadioOption: ({ children, onClick }) => (
{children}
) })) jest.mock('../../lib-react-components', () => ({ ButtonRadio: ({ isActive }) => (
{isActive ? 'Active' : 'Inactive'}
) })) describe('RadioSelect', () => { const mockOptions = [ { label: 'Option 1', value: 'option1' }, { label: 'Option 2', value: 'option2' }, { label: 'Option 3', value: 'option3' } ] const mockTitle = 'Select an option' const mockOnChange = jest.fn() beforeEach(() => { mockOnChange.mockClear() }) const renderComponent = (props = {}) => render( ) test('renders the component with title and options', () => { const { container } = renderComponent() expect(screen.getByTestId('title')).toHaveTextContent(mockTitle) expect(screen.getAllByTestId('radio-option')).toHaveLength(3) expect(container).toMatchSnapshot() }) test('correctly marks the selected option', () => { renderComponent({ selectedOption: 'option2' }) const radioButtons = screen.getAllByTestId('button-radio') expect(radioButtons[0]).toHaveAttribute('data-active', 'false') expect(radioButtons[1]).toHaveAttribute('data-active', 'true') expect(radioButtons[2]).toHaveAttribute('data-active', 'false') }) test('calls onChange when an option is clicked', () => { renderComponent() const radioOptions = screen.getAllByTestId('radio-option') fireEvent.click(radioOptions[1]) expect(mockOnChange).toHaveBeenCalledWith('option2') }) test('renders all option labels correctly', () => { renderComponent() const radioOptions = screen.getAllByTestId('radio-option') expect(radioOptions[0]).toHaveTextContent('Option 1') expect(radioOptions[1]).toHaveTextContent('Option 2') expect(radioOptions[2]).toHaveTextContent('Option 3') }) test('handles empty options array', () => { renderComponent({ options: [] }) expect(screen.queryAllByTestId('radio-option')).toHaveLength(0) }) test('maintains selection state between renders', () => { const { rerender } = renderComponent({ selectedOption: 'option1' }) expect(screen.getAllByTestId('button-radio')[0]).toHaveAttribute( 'data-active', 'true' ) rerender( ) expect(screen.getAllByTestId('button-radio')[2]).toHaveAttribute( 'data-active', 'true' ) }) }) ================================================ FILE: src/components/RadioSelect/styles.js ================================================ import styled from 'styled-components' export const RadioSelectWrapper = styled.div` display: flex; flex-direction: column; ` export const Title = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-weight: 500; ` export const RadioOption = styled.div` display: flex; align-items: center; gap: 8px; cursor: pointer; color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; font-weight: 600; margin-top: 10px; & + & { margin-top: 5px; } ` ================================================ FILE: src/components/Record/index.js ================================================ import { useState } from 'react' import { generateAvatarInitials } from '@tetherto/pear-apps-utils-avatar-initials' import { formatOtpCode } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { RECORD_COLOR_BY_TYPE } from '../../constants/recordColorByType' import { useRecordActionItems } from '../../hooks/useRecordActionItems' import { KebabMenuIcon } from '../../lib-react-components' import { CopyButton } from '../CopyButton' import { PopupMenu } from '../PopupMenu' import { RecordActionsPopupContent } from '../RecordActionsPopupContent' import { RecordAvatar } from '../RecordAvatar' import { OtpCodeText, RecordActions, RecordInformation, RecordName, RecordRightSection, RecordWrapper } from './styles' /** * * @param {{ * record: { * id: string * createdAt: number * updatedAt: number * isFavorite: boolean * vaultId: string * folder: string * type: 'note' | 'creditCard' | 'custom' | 'identity' | 'login' * data: { * title: string * [key: string]: any * } * }, * isSelected: boolean, * onClick: () => void * onSelect: () => void, * testId?: string, * dataId?: string, * otpCode?: string | null * }} props */ export const Record = ({ record, isSelected = false, onClick, onSelect, testId, dataId, otpCode, recordType }) => { const [isOpen, setIsOpen] = useState() const folderName = record?.folder const { actions } = useRecordActionItems({ record, recordType, onSelect, excludeTypes: ['edit'], onClose: () => { setIsOpen(false) } }) const handleActionMenuToggle = (e) => { e.stopPropagation() setIsOpen(!isOpen) } const domain = record.type === 'login' ? record?.data?.websites?.[0] : null const formattedOtp = otpCode ? formatOtpCode(otpCode) : null return html` <${RecordWrapper} open=${isOpen} isSelected=${isSelected} onClick=${onClick} data-testid=${testId} data-id=${dataId} > <${RecordInformation}> <${RecordAvatar} websiteDomain=${domain} initials=${generateAvatarInitials(record?.data?.title)} isSelected=${isSelected} isFavorite=${record?.isFavorite} color=${RECORD_COLOR_BY_TYPE[record?.type]} /> <${RecordName}> ${record?.data?.title}

${folderName}

<${RecordRightSection}> ${formattedOtp && html` <${OtpCodeText} data-testid="record-otp-code"> ${formattedOtp} e.stopPropagation()}> <${CopyButton} value=${otpCode} testId="record-otp-copy-button" /> `} ${!isSelected && html` <${RecordActions}> <${PopupMenu} side="right" align="right" isOpen=${isOpen} setIsOpen=${setIsOpen} content=${html` <${RecordActionsPopupContent} menuItems=${actions} /> `} >
<${KebabMenuIcon} />
`} ` } ================================================ FILE: src/components/Record/index.test.js ================================================ import React from 'react' import { render, fireEvent, waitFor } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { Record } from './index' import '@testing-library/jest-dom/' jest.mock('../../hooks/useRecordActionItems', () => ({ useRecordActionItems: () => ({ actions: [ { label: 'Action 1', onClick: jest.fn() }, { label: 'Action 2', onClick: jest.fn() } ] }) })) jest.mock('../RecordActionsPopupContent', () => ({ RecordActionsPopupContent: () => (
RecordActionsPopupContent
) })) jest.mock('../../lib-react-components', () => ({ KebabMenuIcon: () => })) jest.mock('../RecordAvatar', () => ({ RecordAvatar: () =>
RecordAvatar
})) describe('Record Component', () => { const dummyRecord = { id: '1', createdAt: 1630000000000, updatedAt: 1630000000000, isFavorite: false, vaultId: 'vault-1', folder: 'Test Folder', type: 'note', data: { title: 'Test Record Title', avatarSrc: '' } } test('renders Record component correctly when not selected', () => { const { asFragment } = render( {}} onSelect={() => {}} /> ) expect(asFragment()).toMatchSnapshot() }) test('renders Record component correctly when selected', () => { const { asFragment } = render( {}} onSelect={() => {}} /> ) expect(asFragment()).toMatchSnapshot() }) test('toggles action menu when kebab icon is clicked (snapshot test)', async () => { const { container, asFragment } = render( {}} onSelect={() => {}} /> ) expect(asFragment()).toMatchSnapshot('initial state') const kebabIcon = container.querySelector('[data-testid="kebab-icon"]') expect(kebabIcon).toBeInTheDocument() fireEvent.click(kebabIcon) await waitFor(() => { expect( container.querySelector('[data-testid="record-actions-popup-content"]') ).toBeInTheDocument() }) expect(asFragment()).toMatchSnapshot('after toggle') }) }) ================================================ FILE: src/components/Record/styles.js ================================================ import styled, { css } from 'styled-components' export const RecordWrapper = styled.div.withConfig({ shouldForwardProp: (prop) => !['isSelected', 'open'].includes(prop) })` width: 100%; height: auto; display: flex; min-height: 45px; padding: 5px 10px; justify-content: space-between; align-items: center; align-self: stretch; border-radius: 10px; background: transparent; cursor: pointer; ${({ isSelected }) => isSelected && css` background: rgba(134, 170, 172, 0.4); `} ${({ open }) => open && css` background: rgba(134, 170, 172, 0.2); pointer-events: auto; `} &:hover { background: ${({ isSelected }) => !isSelected && 'rgba(134, 170, 172, 0.2)'}; } ` export const RecordInformation = styled.div` display: flex; align-items: center; gap: 10px; ` export const RecordName = styled.div` display: flex; flex-direction: column; justify-content: start; color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-style: normal; font-weight: 400; line-height: normal; & p { color: ${({ theme }) => theme.colors.grey100.mode1}; font-size: 12px; } ` export const RecordRightSection = styled.div` display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; ` export const OtpCodeText = styled.span` font-family: 'Inter'; font-variant-numeric: tabular-nums; font-size: 18px; font-weight: 600; color: ${({ theme }) => theme.colors.white.mode1}; white-space: nowrap; ` export const RecordActions = styled.div` display: flex; align-items: center; ` ================================================ FILE: src/components/RecordActionsPopupContent/index.js ================================================ import { html } from 'htm/react' import { MenuCard, MenuItem } from './styles' import { RECORD_ACTION_ICON_BY_TYPE } from '../../constants/recordActions' /** * @param {{ * menuItems: Array<{ * name: string, * type: string, * click?: () => void, * }>, * variant: 'default' | 'compact', * onClick?: () => void, * }} */ export const RecordActionsPopupContent = ({ variant = 'default', menuItems, onClick }) => html` <${MenuCard} variant=${variant}> ${menuItems.map( (item) => html` <${MenuItem} data-testid=${`recordaction-item-${item.type}`} data-id=${item.dataId} key=${item.type} variant=${variant} onClick=${(e) => { e.stopPropagation() if (item.click) { item.click() return } onClick?.() }} > <${RECORD_ACTION_ICON_BY_TYPE[item.type]} size="24" />

${item.name}

` )} ` ================================================ FILE: src/components/RecordActionsPopupContent/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { RecordActionsPopupContent } from './index' import '@testing-library/jest-dom' jest.mock('../../constants/recordActions', () => ({ RECORD_ACTION_ICON_BY_TYPE: { edit: () => , delete: () => } })) describe('RecordActionsPopupContent Component', () => { const mockMenuItems = [ { name: 'Edit', type: 'edit', click: jest.fn() }, { name: 'Delete', type: 'delete', click: jest.fn() } ] test('renders correctly with default variant', () => { const { asFragment, getByText } = render( ) expect(getByText('Edit')).toBeInTheDocument() expect(getByText('Delete')).toBeInTheDocument() expect(asFragment()).toMatchSnapshot() }) test('renders correctly with compact variant', () => { const { asFragment } = render( ) expect(asFragment()).toMatchSnapshot() }) test('calls item.click when menu item is clicked', () => { const { getByText } = render( ) fireEvent.click(getByText('Edit')) expect(mockMenuItems[0].click).toHaveBeenCalledTimes(1) fireEvent.click(getByText('Delete')) expect(mockMenuItems[1].click).toHaveBeenCalledTimes(1) }) test('calls onClick prop when menu item without click handler is clicked', () => { const onClickMock = jest.fn() const itemsWithoutClick = [ { name: 'Edit', type: 'edit' }, { name: 'Delete', type: 'delete' } ] const { getByText } = render( ) fireEvent.click(getByText('Edit')) expect(onClickMock).toHaveBeenCalledTimes(1) }) }) ================================================ FILE: src/components/RecordActionsPopupContent/styles.js ================================================ import styled from 'styled-components' export const MenuCard = styled.div` position: absolute; font-family: 'Inter'; font-size: ${({ variant }) => (variant === 'default' ? '14px' : '10px')}; align-items: flex-start; display: flex; padding: ${({ variant }) => (variant === 'default' ? '4px 8px' : '5px 5px')}; flex-direction: column; gap: 4px; overflow: hidden; border-radius: 10px; border: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; background: ${({ theme }) => theme.colors.grey400.mode1}; min-width: 150px; ` export const MenuItem = styled.div` display: flex; padding: 8px 0px; align-items: center; gap: 5px; align-self: stretch; word-break: keep-all; white-space: nowrap; cursor: pointer; color: ${({ theme }) => theme.colors.white.mode1}; &:not(:last-child) { border-bottom: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; } ` ================================================ FILE: src/components/RecordAvatar/index.test.js ================================================ import React from 'react' import { render } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { RecordAvatar } from './index' import '@testing-library/jest-dom' jest.mock('../../lib-react-components', () => ({ CheckIcon: (props) => , StarIcon: (props) => })) const mockUseFavicon = jest.fn() jest.mock('@tetherto/pearpass-lib-vault', () => ({ ...jest.requireActual('@tetherto/pearpass-lib-vault'), useFavicon: (params) => mockUseFavicon(params) })) describe('RecordAvatar Component', () => { const defaultProps = { initials: 'AB', color: '#FF5500' } beforeEach(() => { mockUseFavicon.mockReset() mockUseFavicon.mockReturnValue({ faviconSrc: null, isLoading: false, hasError: false }) global.URL.createObjectURL = jest.fn(() => 'blob:test-url') }) test('calls useFavicon with domain', () => { render( ) expect(mockUseFavicon).toHaveBeenCalledWith({ url: 'https://example.com' }) }) test('calls useFavicon with undefined when no websiteDomain', () => { render( ) expect(mockUseFavicon).toHaveBeenCalledWith({ url: undefined }) }) test('renders image when useFavicon returns faviconSrc', () => { mockUseFavicon.mockReturnValue({ faviconSrc: 'blob:test-url', isLoading: false, hasError: false }) const { container } = render( ) const img = container.querySelector('img') expect(img).toBeInTheDocument() expect(img).toHaveAttribute('src', 'blob:test-url') }) test('renders initials fallback if favicon returns null', () => { const { getByText } = render( ) expect(getByText('AB')).toBeInTheDocument() }) test('renders check icon instead of favorite when both isSelected and isFavorite are true', () => { const { getByTestId, queryByTestId } = render( ) expect(getByTestId('check-icon')).toBeInTheDocument() expect(queryByTestId('star-icon')).not.toBeInTheDocument() }) }) ================================================ FILE: src/components/RecordAvatar/index.tsx ================================================ import React from 'react' import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' import { AvatarAlt, AvatarContainer, AvatarImage, AvatarSize, FavoriteIcon, SelectedAvatarContainer } from './styles' import { CheckIcon, StarIcon } from '../../lib-react-components' import { useFavicon } from '@tetherto/pearpass-lib-vault' interface Props { websiteDomain: string initials: string size: AvatarSize isSelected: boolean isFavorite: boolean color: string testId?: string } export const RecordAvatar = (props: Props): React.ReactElement => { const { websiteDomain, initials, size, isSelected, isFavorite, color, testId } = props const { faviconSrc, isLoading } = useFavicon({ url: websiteDomain }) if (isSelected) { return ( ) } const isFaviconLoaded = faviconSrc && !isLoading return ( {isFaviconLoaded && } {!isFaviconLoaded && ( {initials} )} {isFavorite && ( )} ) } ================================================ FILE: src/components/RecordAvatar/styles.ts ================================================ import styled from 'styled-components' export type AvatarSize = 'md' | 'sm' const AVATAR_CONTAINER_SIZE = '30px' interface AvatarContainerProps { size?: AvatarSize } interface AvatarAltProps { color: string size?: AvatarSize } const getAvatarHeight = (size?: AvatarSize): string => { return size === 'sm' ? '21px' : AVATAR_CONTAINER_SIZE } const getAvatarBorderRadius = (size?: AvatarSize): string => { return size === 'sm' ? '7px' : '10px' } const getAvatarFontSize = (size?: AvatarSize): string => { return size === 'sm' ? '12px' : '16px' } export const AvatarContainer = styled.div` position: relative; display: flex; height: ${({ size }) => getAvatarHeight(size)}; aspect-ratio: 1/1; padding: 2px; justify-content: center; align-items: center; border-radius: ${({ size }) => getAvatarBorderRadius(size)}; background: ${({ theme }) => theme.colors.secondary400.mode1}; min-width: 0; flex-shrink: 0; ` export const AvatarAlt = styled.div` color: ${({ color }) => color}; text-align: center; font-family: 'Inter'; font-size: ${({ size }) => getAvatarFontSize(size)}; font-style: normal; font-weight: 700; line-height: normal; ` export const SelectedAvatarContainer = styled.div` display: flex; width: ${AVATAR_CONTAINER_SIZE}; height: ${AVATAR_CONTAINER_SIZE}; padding: 5px; justify-content: center; align-items: center; gap: 10px; flex-shrink: 0; border-radius: 10px; background: ${({ theme }) => theme.colors.primary400.mode1}; ` export const FavoriteIcon = styled.div` position: absolute; right: -6px; bottom: -9px; & > svg { fill: ${({ theme }) => theme.colors.primary400.mode1}; } ` export const AvatarImage = styled.img` min-width: 0; display: flex; object-fit: cover; ` ================================================ FILE: src/components/RecordItemIcon/RecordItemIcon.styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = (colors: ThemeColors, size: number) => ({ wrapper: { width: size, height: size, borderRadius: `${rawTokens.radius8}px`, overflow: 'hidden' as const, backgroundColor: colors.colorSurfaceHover, display: 'flex' as const, justifyContent: 'center' as const, alignItems: 'center' as const, flexShrink: 0 }, image: { width: size, height: size, borderRadius: `${rawTokens.radius8}px`, objectFit: 'contain' as const } }) ================================================ FILE: src/components/RecordItemIcon/RecordItemIcon.tsx ================================================ import React from 'react' import { generateAvatarInitials } from '@tetherto/pear-apps-utils-avatar-initials' import { Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { useFavicon } from '@tetherto/pearpass-lib-vault' import { createStyles } from './RecordItemIcon.styles' import { RECORD_COLOR_BY_TYPE } from '../../constants/recordColorByType' type RecordLike = { type: string data?: { title?: string websites?: string[] } } type RecordItemIconProps = { record: RecordLike size?: number testId?: string } export const RecordItemIcon = ({ record, size = 32, testId }: RecordItemIconProps) => { const { theme } = useTheme() const styles = createStyles(theme.colors, size) const websiteDomain = record.type === 'login' ? record.data?.websites?.[0] : undefined const { faviconSrc, isLoading } = useFavicon({ url: websiteDomain ?? '' }) const showFavicon = !!faviconSrc && !isLoading const initials = generateAvatarInitials(record.data?.title ?? '') const color = RECORD_COLOR_BY_TYPE[record.type as keyof typeof RECORD_COLOR_BY_TYPE] ?? theme.colors.colorTextPrimary return (
{showFavicon ? ( ) : ( = 32 ? 'labelEmphasized' : 'caption'} color={color} > {initials} )}
) } ================================================ FILE: src/components/RecordItemIcon/index.ts ================================================ export { RecordItemIcon } from './RecordItemIcon' ================================================ FILE: src/components/RecordSortActionsPopupContent/index.js ================================================ import { html } from 'htm/react' import { MenuCard, MenuItem } from './styles' import { CheckIcon } from '../../lib-react-components' /** * @param {{ * menuItems: Array, * onClick: (type: 'recent' | 'newToOld' | 'oldToNew') => void, * }} */ export const RecordSortActionsPopupContent = ({ menuItems, onClick, onClose, selectedType }) => { const handleMenuItemClick = (e, type) => { e.stopPropagation() onClick(type) onClose() } return html` <${MenuCard}> ${menuItems.map( (item) => html` <${MenuItem} key=${item.name} data-testid=${`sort-option-${item.type}`} onClick=${(e) => handleMenuItemClick(e, item.type)} >
<${item.icon} size="24" /> ${item.name}
${selectedType === item.type && html`<${CheckIcon} size="24" />`} ` )} ` } ================================================ FILE: src/components/RecordSortActionsPopupContent/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { RecordSortActionsPopupContent } from './index' import '@testing-library/jest-dom' jest.mock('../../lib-react-components', () => ({ CheckIcon: () => })) describe('RecordSortActionsPopupContent Component', () => { const mockMenuItems = [ { name: 'Recent', type: 'recent', icon: () => }, { name: 'Newest First', type: 'newToOld', icon: () => }, { name: 'Oldest First', type: 'oldToNew', icon: () => } ] const mockOnClick = jest.fn() const mockOnClose = jest.fn() beforeEach(() => { jest.clearAllMocks() }) test('renders correctly with all menu items', () => { const { getByText, queryAllByTestId, container } = render( ) expect(getByText('Recent')).toBeInTheDocument() expect(getByText('Newest First')).toBeInTheDocument() expect(getByText('Oldest First')).toBeInTheDocument() expect(queryAllByTestId(/.*-icon/).length).toBe(3) expect(container).toMatchSnapshot() }) test('shows check icon for selected item', () => { const { queryAllByTestId } = render( ) expect(queryAllByTestId('check-icon').length).toBe(1) }) test('calls onClick and onClose when menu item is clicked', () => { const { getByText } = render( ) fireEvent.click(getByText('Recent')) expect(mockOnClick).toHaveBeenCalledWith('recent') expect(mockOnClose).toHaveBeenCalled() }) test('renders correctly with no selected item', () => { const { queryAllByTestId } = render( ) expect(queryAllByTestId('check-icon').length).toBe(0) }) test('renders correctly with empty menu items', () => { const { container } = render( ) expect(container.firstChild).toBeEmptyDOMElement() }) }) ================================================ FILE: src/components/RecordSortActionsPopupContent/styles.js ================================================ import styled from 'styled-components' export const MenuCard = styled.div` position: absolute; font-family: 'Inter'; font-size: 10px; align-items: flex-start; display: flex; padding: 5px; flex-direction: column; gap: 3px; overflow: hidden; border-radius: 10px; border: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; background: ${({ theme }) => theme.colors.grey400.mode1}; ` export const MenuItem = styled.div` display: flex; padding: 4px 0px; align-items: center; justify-content: space-between; gap: 5px; align-self: stretch; word-break: keep-all; white-space: nowrap; cursor: pointer; color: ${({ theme }) => theme.colors.white.mode1}; & > div:first-child { display: flex; align-items: center; gap: 5px; } ` ================================================ FILE: src/components/RecordTypeMenu/index.js ================================================ import { html } from 'htm/react' import { useRecordMenuItems } from '../../hooks/useRecordMenuItems' import { MenuDropdown } from '../MenuDropdown' /** * @param {{ * selectedRecord?: { * name: string; * icon?: React.ReactNode; * }, * onRecordSelect: (record: { * name: string; * icon?: React.ReactNode; * }) => void, * testId?: string * }} props */ export const RecordTypeMenu = ({ selectedRecord, onRecordSelect, testId }) => { const { defaultItems } = useRecordMenuItems() const selectedItem = defaultItems.filter( (item) => item.type === selectedRecord )?.[0] return html` <${MenuDropdown} selectedItem=${selectedItem} onItemSelect=${onRecordSelect} items=${defaultItems} testId=${testId} /> ` } ================================================ FILE: src/components/RecordTypeMenu/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { RecordTypeMenu } from './index' import '@testing-library/jest-dom' jest.mock('../../hooks/useRecordMenuItems', () => ({ useRecordMenuItems: () => ({ defaultItems: [ { name: 'Login', type: 'login', icon: () => }, { name: 'Card', type: 'card', icon: () => }, { name: 'Note', type: 'note', icon: () => } ] }) })) jest.mock('../MenuDropdown', () => ({ MenuDropdown: ({ selectedItem, onItemSelect, items }) => (
{selectedItem?.name}
    {items.map((item) => (
  • onItemSelect(item)} > {item.name} {item.icon && item.icon()}
  • ))}
) })) describe('RecordTypeMenu Component', () => { const mockOnRecordSelect = jest.fn() beforeEach(() => { jest.clearAllMocks() }) test('renders correctly with no selected record', () => { const { getByTestId, container } = render( ) expect(getByTestId('menu-dropdown')).toBeInTheDocument() expect(getByTestId('selected-item')).toBeEmptyDOMElement() expect(container).toMatchSnapshot() }) test('passes the correct selected item when selectedRecord is provided', () => { const { getByTestId } = render( ) expect(getByTestId('selected-item')).toHaveTextContent('Card') }) test('calls onRecordSelect when a menu item is clicked', () => { const { getByTestId } = render( ) fireEvent.click(getByTestId('menu-item-login')) expect(mockOnRecordSelect).toHaveBeenCalledTimes(1) expect(mockOnRecordSelect).toHaveBeenCalledWith({ name: 'Login', type: 'login', icon: expect.any(Function) }) }) test('renders all menu items from useRecordMenuItems hook', () => { const { getByTestId } = render( ) expect(getByTestId('menu-item-login')).toBeInTheDocument() expect(getByTestId('menu-item-card')).toBeInTheDocument() expect(getByTestId('menu-item-note')).toBeInTheDocument() }) test('handles case when selectedRecord does not match any item', () => { const { getByTestId } = render( ) expect(getByTestId('selected-item')).toBeEmptyDOMElement() }) }) ================================================ FILE: src/components/Select/SelectItem/index.js ================================================ import { html } from 'htm/react' import { SelectItemWrapper } from './styles' /** * @param {{ * onClick: () => void, * item: { label: string } * }} props */ export const SelectItem = ({ item, onClick, testId }) => html` <${SelectItemWrapper} data-testid=${testId} onClick=${() => onClick?.()}> ${item.label} ` ================================================ FILE: src/components/Select/SelectItem/index.test.js ================================================ import React from 'react' import { fireEvent, render } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { SelectItem } from './index' import '@testing-library/jest-dom' describe('SelectItem Component', () => { const mockOnClick = jest.fn() beforeEach(() => { jest.clearAllMocks() }) test('renders correctly with item name', () => { const { getByText, container } = render( ) expect(getByText('English')).toBeInTheDocument() expect(container).toMatchSnapshot() }) test('calls onClick when clicked', () => { const { getByText } = render( ) fireEvent.click(getByText('English')) expect(mockOnClick).toHaveBeenCalledTimes(1) }) test('renders correctly with a different item name', () => { const { getByText, container } = render( ) expect(getByText('Spanish')).toBeInTheDocument() expect(container).toMatchSnapshot() }) }) ================================================ FILE: src/components/Select/SelectItem/styles.js ================================================ import styled from 'styled-components' export const SelectItemWrapper = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; padding: 5px 10px; font-weight: 400; line-height: normal; cursor: pointer; white-space: nowrap; ` ================================================ FILE: src/components/Select/SelectLabel/index.js ================================================ import { html } from 'htm/react' import { Label } from './styles' import { ArrowDownIcon, ArrowUpIcon } from '../../../lib-react-components' /** * @param {{ * selectedItem?: { label: string }, * isOpen: boolean, * setIsOpen?: (isOpen: boolean) => void, * placeholder: string * }} props */ export const SelectLabel = ({ selectedItem, isOpen, setIsOpen, placeholder, testId }) => html` <${Label} data-testid=${testId} onClick=${() => setIsOpen?.(!isOpen)}> ${selectedItem?.label || placeholder} <${isOpen ? ArrowUpIcon : ArrowDownIcon} size="24" /> ` ================================================ FILE: src/components/Select/SelectLabel/index.test.js ================================================ import React from 'react' import { fireEvent, render } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { SelectLabel } from './index' import '@testing-library/jest-dom' describe('SelectLabel Component', () => { const mockSetIsOpen = jest.fn() beforeEach(() => { jest.clearAllMocks() }) test('renders correctly with placeholder when no selected item', () => { const { getByText, container } = render( ) expect(getByText('Select an option')).toBeInTheDocument() expect(container).toMatchSnapshot() }) test('renders correctly with selected item', () => { const { getByText, container } = render( ) expect(getByText('English')).toBeInTheDocument() expect(container).toMatchSnapshot() }) test('renders ArrowDownIcon when isOpen is false', () => { const { container } = render( ) expect(container.querySelector('svg')).toBeInTheDocument() expect(container).toMatchSnapshot() }) test('renders ArrowUpIcon when isOpen is true', () => { const { container } = render( ) expect(container.querySelector('svg')).toBeInTheDocument() expect(container).toMatchSnapshot() }) test('calls setIsOpen with the correct value when clicked', () => { const { getByText } = render( ) fireEvent.click(getByText('Select an option')) expect(mockSetIsOpen).toHaveBeenCalledTimes(1) expect(mockSetIsOpen).toHaveBeenCalledWith(true) }) }) ================================================ FILE: src/components/Select/SelectLabel/styles.js ================================================ import styled from 'styled-components' export const Label = styled.div` display: flex; justify-content: space-between; align-items: center; width: 100%; border: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; border-radius: 10px; padding: 7px 10px; color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-weight: 700; line-height: normal; cursor: pointer; white-space: nowrap; ` ================================================ FILE: src/components/Select/index.js ================================================ import { useState } from 'react' import { html } from 'htm/react' import { SelectItem } from './SelectItem' import { SelectLabel } from './SelectLabel' import { SelectWrapper, SelectDropdown } from './styles' import { useOutsideClick } from '../../hooks/useOutsideClick' /** * @param {{ * selectedItem?: { label: string }, * onItemSelect: (item: { label: string, value: string }) => void, * items: Array<{ label: string, value: string }>, * placeholder: string * }} props */ export const Select = ({ selectedItem, onItemSelect, items, placeholder, testId }) => { const [isOpen, setIsOpen] = useState(false) const wrapperRef = useOutsideClick({ onOutsideClick: () => { setIsOpen(false) } }) const handleSelect = (item) => { onItemSelect(item) setIsOpen(false) } return html` <${SelectWrapper} ref=${wrapperRef}> <${SelectLabel} selectedItem=${selectedItem} isOpen=${isOpen} setIsOpen=${setIsOpen} placeholder=${placeholder} testId=${testId} /> ${isOpen && html`<${SelectDropdown}> ${items.map( (item) => html` <${SelectItem} key=${item.label} item=${item} testId=${item.testId} onClick=${() => handleSelect(item)} /> ` )} `} ` } ================================================ FILE: src/components/Select/index.test.js ================================================ import React from 'react' import { fireEvent, render } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { Select } from './index' import '@testing-library/jest-dom' describe('Select Component', () => { const mockOnItemSelect = jest.fn() const items = [ { label: 'English' }, { label: 'Italian' }, { label: 'Spanish' }, { label: 'French' } ] beforeEach(() => { jest.clearAllMocks() }) test('renders correctly with placeholder when no selected item', () => { const { getByText, container } = render( ) expect(getByText('English')).toBeInTheDocument() expect(container).toMatchSnapshot() }) test('opens dropdown when label is clicked', () => { const { getByText, container } = render( ) fireEvent.click(getByText('Select a language')) fireEvent.click(getByText('Italian')) expect(mockOnItemSelect).toHaveBeenCalledTimes(1) expect(mockOnItemSelect).toHaveBeenCalledWith({ label: 'Italian' }) }) test('closes dropdown when an option is selected', () => { const { getByText, queryByText } = render( ) fireEvent.click(getByText('Select a language')) fireEvent.mouseDown(document.body) expect(container.querySelector('ul')).not.toBeInTheDocument() }) }) ================================================ FILE: src/components/Select/styles.js ================================================ import styled from 'styled-components' export const SelectWrapper = styled.div` position: relative; ` export const SelectDropdown = styled.div` margin-top: 1px; border: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; border-radius: 10px; width: 100%; display: flex; flex-direction: column; overflow: hidden; padding: 7px 0; ` ================================================ FILE: src/components/SidebarCategory/index.js ================================================ import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' import { html } from 'htm/react' import { CategoryButton, CategoryDescription, CategoryIconWrapper, CategoryName, CategoryQuantity } from './styles' /** * @param {{ * size: 'default' | 'tight', * isSelected: boolean, * categoryName: string, * quantity: number, * color: string, * icon: import('react').ReactNode, * onClick: () => void, * testId?: string * }} props */ export const SidebarCategory = ({ size = 'default', isSelected = false, categoryName, quantity = 0, color, icon, onClick, testId }) => html` <${CategoryButton} size=${size} color=${color} isSelected=${isSelected} onClick=${onClick} data-testid=${testId} > <${CategoryDescription} size=${size}> <${CategoryIconWrapper} isSelected=${isSelected} color=${color}> <${icon} color=${isSelected ? colors.black.mode1 : color} fill=${true} size="24px" /> <${CategoryName}>${categoryName} <${CategoryQuantity} size=${size}>${quantity} ` ================================================ FILE: src/components/SidebarCategory/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { SidebarCategory } from './index' import '@testing-library/jest-dom' const MockIcon = () =>
Icon
describe('SidebarCategory Component', () => { const defaultProps = { categoryName: 'Test Category', quantity: 5, color: '#ff0000', icon: MockIcon, onClick: jest.fn(), isSelected: false, size: 'default' } beforeEach(() => { jest.clearAllMocks() }) test('renders correctly with default props', () => { const { container, getByText, getByTestId } = render( ) expect( getByText((content) => content.includes('Test Category')) ).toBeInTheDocument() expect(getByText('5')).toBeInTheDocument() expect(getByTestId('mock-icon')).toBeInTheDocument() expect(container).toMatchSnapshot() }) test('calls onClick handler when clicked', () => { const { getByText } = render( {' '} ) fireEvent.click(getByText('Test Category')) expect(defaultProps.onClick).toHaveBeenCalledTimes(1) }) test('renders with zero quantity', () => { const { getByText } = render( {' '} ) expect(getByText('0')).toBeInTheDocument() }) }) ================================================ FILE: src/components/SidebarCategory/styles.js ================================================ import styled, { css } from 'styled-components' export const CategoryButton = styled.button.withConfig({ shouldForwardProp: (prop) => !['size', 'color', 'isSelected'].includes(prop) })` display: flex; font-family: 'Inter'; font-size: 16px; line-height: normal; flex-direction: row; justify-content: space-between; background: ${({ theme }) => theme.colors.grey400.mode1}; border-radius: 10px; border: 1px solid transparent; color: ${({ theme }) => theme.colors.white.mode1}; cursor: pointer; position: relative; &:hover { border-color: ${({ color }) => color}; } ${({ size }) => size === 'default' && css` flex: 1; min-width: 121px; padding: 10px 9px; `} ${({ size }) => size === 'tight' && css` width: 100%; padding: 5px 9px; align-items: center; `} ${({ isSelected, theme, color }) => isSelected && css` background: ${color}; color: ${theme.colors.black.mode1}; `} ` export const CategoryDescription = styled.div` display: flex; gap: 2px; white-space: nowrap; font-weight: 600; width: 100%; text-align: left; ${({ size }) => size === 'default' && css` flex-direction: column; `} ${({ size }) => size === 'tight' && css` align-items: center; flex-direction: row; `} ` export const CategoryIconWrapper = styled.div.withConfig({ shouldForwardProp: (prop) => !['isSelected', 'color'].includes(prop) })` display: flex; ` export const CategoryName = styled.span` overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; ` export const CategoryQuantity = styled.span.withConfig({ shouldForwardProp: (prop) => !['size'].includes(prop) })` font-weight: 300; position: ${({ size }) => (size === 'default' ? 'absolute' : '')}; top: ${({ size }) => (size === 'default' ? '8px' : '')}; right: 9px; ` ================================================ FILE: src/components/SidebarFolder/index.js ================================================ import React, { useState } from 'react' import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' import { html } from 'htm/react' import { FolderIcon, KebabMenuIcon, PlusIcon } from '../../lib-react-components' import { EditFolderPopupContent } from '../EditFolderPopupContent' import { PopupMenu } from '../PopupMenu' import { AddIconWrapper, FolderName, NestedFolder, NestedFoldersContainer, NestedItem } from './styles' /** * @param {{ * isOpen: boolean * onClick: () => void * onAddClick: () => void * isRoot: boolean * name: string * icon: string * isActive: boolean * hasMenu?: boolean * }} props */ export const SidebarFolder = ({ onClick, onAddClick, isRoot, name, icon: Icon, isActive, hasMenu = true }) => { const [isNewPopupMenuOpen, setIsNewPopupMenuOpen] = useState(false) return html` <${React.Fragment}> <${NestedFoldersContainer}> <${NestedItem}> <${NestedFolder} isActive=${isActive} data-testid="sidebar-folder" onClick=${onClick} > ${!isRoot && html` <${Icon ?? FolderIcon} size="24" color=${isActive ? colors.primary400.mode1 : undefined} /> `} <${FolderName}>${name} ${!isRoot && hasMenu && html` <${PopupMenu} side="right" align="right" isOpen=${isNewPopupMenuOpen} setIsOpen=${setIsNewPopupMenuOpen} content=${html` <${EditFolderPopupContent} name=${name} /> `} testId="sidebar-folder-options" > <${KebabMenuIcon} /> `} ${isRoot && html` <${AddIconWrapper} data-testid="sidebarfolder-button-add" onClick=${() => onAddClick()} > <${PlusIcon} color=${colors.primary400.mode1} size="24" /> `} ` } ================================================ FILE: src/components/SidebarFolder/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { SidebarFolder } from './index' import '@testing-library/jest-dom' const MockIcon = () =>
Icon
jest.mock('../PopupMenu', () => ({ PopupMenu: ({ title, children }) => (
{children}
) })) jest.mock('../EditFolderPopupContent', () => ({ EditFolderPopupContent: ({ title, children }) => (
{children}
) })) jest.mock('../../lib-react-components', () => ({ FolderIcon: () =>
, KebabMenuIcon: () =>
, PlusIcon: () =>
})) describe('SidebarFolder Component', () => { const defaultProps = { isOpen: false, onClick: jest.fn(), onDropDown: jest.fn(), onAddClick: jest.fn(), isRoot: false, name: 'Test Folder', icon: MockIcon, isActive: false } beforeEach(() => { jest.clearAllMocks() }) test('renders correctly with default props', () => { const { getByText, getByTestId } = render( ) expect(getByText('Test Folder')).toBeInTheDocument() expect(getByTestId('mock-icon')).toBeInTheDocument() }) test('calls onClick handler when clicked', () => { const { getByTestId } = render( ) const wrapper = getByTestId('sidebar-folder') fireEvent.click(wrapper) expect(defaultProps.onClick).toHaveBeenCalledTimes(1) }) test('renders with custom icon when provided', () => { const { getByTestId } = render( ) expect(getByTestId('mock-icon')).toBeInTheDocument() }) }) ================================================ FILE: src/components/SidebarFolder/styles.js ================================================ import styled from 'styled-components' export const NestedFoldersContainer = styled.div` display: flex; align-items: center; justify-content: space-between; ` export const NestedItem = styled.div` display: flex; align-items: center; gap: 7px; cursor: pointer; flex: 1; min-width: 0; ` export const NestedFolder = styled.div.withConfig({ shouldForwardProp: (prop) => !['isActive'].includes(prop) })` display: flex; flex: 1; color: ${({ theme, isActive }) => isActive ? theme.colors.primary400.mode1 : undefined}; align-items: center; gap: 10px; min-width: 0; ` export const AddIconWrapper = styled.div` cursor: pointer; ` export const FolderName = styled.span` flex: 1; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; ` ================================================ FILE: src/components/SidebarSearch/index.js ================================================ import { useLingui } from '@lingui/react' import { html } from 'htm/react' import { SearchInput, SearchLabelIcon, SidebarSearchContainer } from './styles' import { SearchIcon } from '../../lib-react-components' /** * @param {{ * value: string * onChange: (value: string) => void * testId?: string * }} props */ export const SidebarSearch = ({ value, onChange, testId }) => { const { i18n } = useLingui() const handleSearch = (e) => { onChange(e.target.value) } return html` <${SidebarSearchContainer}> <${SearchLabelIcon}> <${SearchIcon} size="24" /> <${SearchInput} data-testid=${testId} type="search" value=${value} onChange=${handleSearch} placeholder=${i18n._('Search...')} /> ` } ================================================ FILE: src/components/SidebarSearch/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { SidebarSearch } from './index' import '@testing-library/jest-dom' jest.mock('../../lib-react-components', () => ({ SearchIcon: () =>
SearchIcon
})) jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (str) => str } }) })) describe('SidebarSearch Component', () => { const defaultProps = { value: '', onChange: jest.fn() } beforeEach(() => { jest.clearAllMocks() }) test('renders correctly with default props', () => { const { container, getByTestId } = render( ) expect(getByTestId('search-icon')).toBeInTheDocument() expect(container).toMatchSnapshot() }) test('calls onChange handler when input changes', () => { const { getByPlaceholderText } = render( ) const input = getByPlaceholderText('Search...') fireEvent.change(input, { target: { value: 'test search' } }) expect(defaultProps.onChange).toHaveBeenCalledWith('test search') }) test('displays the current value in the input', () => { const props = { ...defaultProps, value: 'current search' } const { getByDisplayValue } = render( ) expect(getByDisplayValue('current search')).toBeInTheDocument() }) test('has the correct placeholder text', () => { const { getByPlaceholderText } = render( ) expect(getByPlaceholderText('Search...')).toBeInTheDocument() }) test('input has search type attribute', () => { const { getByPlaceholderText } = render( ) const input = getByPlaceholderText('Search...') expect(input).toHaveAttribute('type', 'search') }) }) ================================================ FILE: src/components/SidebarSearch/styles.js ================================================ import styled from 'styled-components' export const SidebarSearchContainer = styled.div` height: 29px; font-family: 'Inter'; font-size: 16px; display: flex; align-items: center; padding: 5px 30px; position: relative; ` export const SearchLabelIcon = styled.label` position: absolute; left: 4px; & path { stroke: ${({ theme }) => theme.colors.primary400.mode1}; } ` export const SearchInput = styled.input` border: none; background: transparent; color: ${({ theme }) => theme.colors.white.mode1}; padding: 0; font-family: 'Inter'; font-size: 16px; font-weight: 500; &::placeholder { color: ${({ theme }) => theme.colors.grey200.mode1}; } &:focus { border: none; box-shadow: none; outline: none; } &::-webkit-search-cancel-button { appearance: none; } ` ================================================ FILE: src/components/SwitchWithLabel/index.js ================================================ import { html } from 'htm/react' import { ContentWrapper, Description, Label, Wrapper } from './styles' import { Switch } from '../../lib-react-components' /** * @param {{ * isOn?: boolean, * onChange?: (isOn: boolean) => void * label?: string, * description?: string, * isLabelBold?: boolean * isSwitchFirst?: boolean * stretch?: boolean * disabled?: boolean, * testId?: string * }} props */ export const SwitchWithLabel = ({ isOn, onChange, label, description, isLabelBold, isSwitchFirst = false, stretch = true, disabled = false, testId }) => { const toggleSwitch = () => { if (!disabled) { onChange?.(!isOn) } } return html` <${Wrapper} isSwitchFirst=${isSwitchFirst} stretch=${stretch} onClick=${toggleSwitch} data-testid=${testId} > <${ContentWrapper}> <${Label} isBold=${isLabelBold}> ${label} ${description && html`<${Description}> ${description} `} <${Switch} testId=${`switchwithlabel-switch-${isOn ? 'on' : 'off'}`} isOn=${isOn} /> ` } ================================================ FILE: src/components/SwitchWithLabel/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import '@testing-library/jest-dom' import { SwitchWithLabel } from './index' jest.mock('../../lib-react-components', () => ({ Switch: ({ isOn }) => (
Switch Component
) })) describe('SwitchWithLabel Component', () => { const defaultProps = { isOn: false, onChange: jest.fn(), label: 'Test Label', isLabelBold: false } beforeEach(() => { jest.clearAllMocks() }) test('renders correctly with default props', () => { const { container, getByText, getByTestId } = render( ) expect(getByText('Test Label')).toBeInTheDocument() expect(getByTestId('switch')).toBeInTheDocument() expect(container).toMatchSnapshot() }) test('calls onChange handler when clicked', () => { const { container } = render( ) fireEvent.click(container.firstChild) expect(defaultProps.onChange).toHaveBeenCalledWith(true) }) test('toggles from on to off when clicked', () => { const props = { ...defaultProps, isOn: true } const { container } = render( ) fireEvent.click(container.firstChild) expect(props.onChange).toHaveBeenCalledWith(false) }) test('renders switch with correct isOn state', () => { const props = { ...defaultProps, isOn: true } const { getByTestId } = render( ) const switchComponent = getByTestId('switch') expect(switchComponent).toHaveAttribute('data-is-on', 'true') }) test('does not throw when onChange is not provided', () => { const props = { ...defaultProps, onChange: undefined } const { container } = render( ) expect(() => { fireEvent.click(container.firstChild) }).not.toThrow() }) }) ================================================ FILE: src/components/SwitchWithLabel/styles.js ================================================ import styled from 'styled-components' export const Wrapper = styled.div` width: 100%; display: flex; align-items: center; flex-direction: ${({ isSwitchFirst }) => isSwitchFirst ? 'row-reverse' : 'row'}; justify-content: ${({ stretch, isSwitchFirst }) => stretch ? 'space-between' : isSwitchFirst ? 'flex-end' : 'flex-start'}; gap: 8px; cursor: pointer; ` export const ContentWrapper = styled.div` display: flex; flex-direction: column; align-items: flex-start; ` export const Label = styled.div.withConfig({ shouldForwardProp: (prop) => !['isBold'].includes(prop) })` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; font-weight: ${({ isBold }) => (isBold ? '600' : '400')}; ` export const Description = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-style: normal; font-weight: 300; line-height: normal; ` ================================================ FILE: src/components/TimerBar/index.test.js ================================================ import React from 'react' import { render } from '@testing-library/react' import '@testing-library/jest-dom' import { TimerBar } from './index' const mockUseTimerAnimation = jest.fn() jest.mock('@tetherto/pearpass-lib-vault', () => ({ useTimerAnimation: (...args) => mockUseTimerAnimation(...args) })) jest.mock('../OtpCodeField/utils', () => ({ getTimerColor: (expiring) => (expiring ? '#red' : '#green') })) jest.mock('./styles', () => ({ styles: { wrapper: {}, track: {}, fill: {}, timer: {} } })) describe('TimerBar', () => { beforeEach(() => { jest.clearAllMocks() mockUseTimerAnimation.mockReturnValue({ noTransition: false, expiring: false, targetTime: 20 }) }) test('renders timer text with timeRemaining', () => { const { container } = render() expect(container.querySelector('span')).toHaveTextContent('15s') }) test('sets progress to 0 when timeRemaining is null', () => { mockUseTimerAnimation.mockReturnValue({ noTransition: false, expiring: false, targetTime: 0 }) const { container } = render() const fill = container.querySelectorAll('div')[2] expect(fill.style.width).toBe('0%') }) test('sets progress to 0 when period is 0', () => { mockUseTimerAnimation.mockReturnValue({ noTransition: false, expiring: false, targetTime: 10 }) const { container } = render() const fill = container.querySelectorAll('div')[2] expect(fill.style.width).toBe('0%') }) test('applies noTransition style when set', () => { mockUseTimerAnimation.mockReturnValue({ noTransition: true, expiring: false, targetTime: 30 }) const { container } = render() const fill = container.querySelectorAll('div')[2] expect(fill.style.transition).toBe('none') }) test('applies linear transition when noTransition is false', () => { const { container } = render() const fill = container.querySelectorAll('div')[2] expect(fill.style.transition).toBe('width 1s linear') }) test('passes animated prop to useTimerAnimation', () => { render() expect(mockUseTimerAnimation).toHaveBeenCalledWith(20, 30, false) }) test('defaults animated to true', () => { render() expect(mockUseTimerAnimation).toHaveBeenCalledWith(20, 30, true) }) }) ================================================ FILE: src/components/TimerBar/index.ts ================================================ import { html } from 'htm/react' import { useTimerAnimation } from '@tetherto/pearpass-lib-vault' import { getTimerColor } from '../OtpCodeField/utils' import { styles } from './styles' interface TimerBarProps { timeRemaining: number | null period: number animated?: boolean } export const TimerBar = ({ timeRemaining, period, animated = true }: TimerBarProps) => { const { noTransition, expiring, targetTime } = useTimerAnimation( timeRemaining, period, animated ) const progress = timeRemaining !== null && period ? (targetTime / period) * 100 : 0 const color = getTimerColor(expiring) return html`
${timeRemaining}s
` } ================================================ FILE: src/components/TimerBar/styles.ts ================================================ import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' export const styles = { wrapper: { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 10px 6px', width: '100%' }, track: { flex: 1, height: 6, borderRadius: 20, background: `${colors.grey100.mode1}33`, overflow: 'hidden' }, fill: { height: '100%', borderRadius: 10 }, timer: { fontFamily: 'Inter', fontSize: 12, fontWeight: 500, minWidth: 22, textAlign: 'right' as const } } ================================================ FILE: src/components/TimerCircle/index.test.js ================================================ import React from 'react' import { render } from '@testing-library/react' import '@testing-library/jest-dom' import { TimerCircle } from './index' const mockUseTimerAnimation = jest.fn() jest.mock('@tetherto/pearpass-lib-vault', () => ({ useTimerAnimation: (...args) => mockUseTimerAnimation(...args) })) jest.mock('../OtpCodeField/utils', () => ({ getTimerColor: (expiring) => (expiring ? '#red' : '#green') })) jest.mock('./styles', () => ({ styles: { wrapper: {}, svg: {}, circleBg: {} } })) const CIRCUMFERENCE = 2 * Math.PI * 5.5 describe('TimerCircle', () => { beforeEach(() => { jest.clearAllMocks() mockUseTimerAnimation.mockReturnValue({ noTransition: false, expiring: false, targetTime: 20 }) }) test('renders svg with correct viewBox', () => { const { container } = render() const svg = container.querySelector('svg') expect(svg).toHaveAttribute('viewBox', '0 0 14 14') }) test('renders two circles', () => { const { container } = render() const circles = container.querySelectorAll('circle') expect(circles).toHaveLength(2) }) test('computes dashOffset from targetTime and period', () => { mockUseTimerAnimation.mockReturnValue({ noTransition: false, expiring: false, targetTime: 15 }) const { container } = render() const fillCircle = container.querySelectorAll('circle')[1] const expectedOffset = (1 - 15 / 30) * CIRCUMFERENCE expect(fillCircle).toHaveAttribute( 'stroke-dashoffset', String(expectedOffset) ) }) test('sets dashOffset to 0 when timeRemaining is null', () => { mockUseTimerAnimation.mockReturnValue({ noTransition: false, expiring: false, targetTime: 0 }) const { container } = render( ) const fillCircle = container.querySelectorAll('circle')[1] expect(fillCircle).toHaveAttribute('stroke-dashoffset', '0') }) test('applies noTransition style', () => { mockUseTimerAnimation.mockReturnValue({ noTransition: true, expiring: false, targetTime: 30 }) const { container } = render() const fillCircle = container.querySelectorAll('circle')[1] expect(fillCircle.style.transition).toBe('none') }) test('applies linear transition when noTransition is false', () => { const { container } = render() const fillCircle = container.querySelectorAll('circle')[1] expect(fillCircle.style.transition).toBe('stroke-dashoffset 1s linear') }) test('passes animated prop to useTimerAnimation', () => { render() expect(mockUseTimerAnimation).toHaveBeenCalledWith(20, 30, false) }) test('defaults animated to true', () => { render() expect(mockUseTimerAnimation).toHaveBeenCalledWith(20, 30, true) }) }) ================================================ FILE: src/components/TimerCircle/index.ts ================================================ import { html } from 'htm/react' import { useTimerAnimation } from '@tetherto/pearpass-lib-vault' import { getTimerColor } from '../OtpCodeField/utils' import { styles } from './styles' const SIZE = 14 const RADIUS = 5.5 const STROKE_WIDTH = 1.5 const CENTER = SIZE / 2 const CIRCUMFERENCE = 2 * Math.PI * RADIUS interface TimerCircleProps { timeRemaining: number | null period: number animated?: boolean } export const TimerCircle = ({ timeRemaining, period, animated = true }: TimerCircleProps) => { const { noTransition, expiring, targetTime } = useTimerAnimation( timeRemaining, period, animated ) const dashOffset = timeRemaining !== null ? (1 - targetTime / period) * CIRCUMFERENCE : 0 const color = getTimerColor(expiring) return html`
` } ================================================ FILE: src/components/TimerCircle/styles.ts ================================================ import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' export const styles = { wrapper: { width: 14, height: 14, flexShrink: 0 }, svg: { width: 14, height: 14, transform: 'rotate(-90deg)' }, circleBg: { fill: 'none', stroke: `${colors.grey100.mode1}33`, strokeWidth: 1.5 } } ================================================ FILE: src/components/TitleBar/index.js ================================================ import { useTheme } from '@tetherto/pearpass-lib-ui-kit' import { html } from 'htm/react' import styled from 'styled-components' import { PearpassLogo } from '../../svgs/PearpassLogo' import { isV2 } from '../../utils/designVersion' const BarInner = styled.div` position: relative; display: flex; width: 100%; height: 100%; align-items: center; justify-content: center; padding: 12px 16px; ` const Brand = styled.div` display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 20px; user-select: none; ` export const TitleBar = () => { if (!isV2()) return null if (process.platform !== 'darwin') return null const { theme } = useTheme() const backgroundColor = theme.colors.colorBackground return html`
<${BarInner}> <${Brand}> <${PearpassLogo} />
` } ================================================ FILE: src/components/Toasts/index.js ================================================ import { Snackbar } from '@tetherto/pearpass-lib-ui-kit' import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' import { html } from 'htm/react' import { ToastContainer, ToastStack } from './styles' import { isV2 } from '../../utils/designVersion' /** * @param {{ * toasts: Array.<{ * message: string * icon?: import('react').ElementType * }> * }} props */ export const Toasts = ({ toasts }) => { const v2 = isV2() return html` <${ToastStack}> ${toasts?.map((toast, index) => { const Icon = toast.icon if (v2) { return html` <${Snackbar} key=${index} text=${toast.message} icon=${Icon ? html`<${Icon} />` : undefined} /> ` } return html` <${ToastContainer} key=${index}> ${Icon && html`<${Icon} color=${colors.black.mode1} />`} ${toast.message} ` })} ` } ================================================ FILE: src/components/Toasts/index.test.js ================================================ import React from 'react' import { render } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { Toasts } from './index' import '@testing-library/jest-dom' jest.mock('./styles', () => ({ ToastContainer: ({ children }) => (
{children}
), ToastStack: ({ children }) =>
{children}
})) const mockSnackbar = jest.fn(({ text, icon }) => (
{icon ?
{icon}
: null} {text}
)) jest.mock('@tetherto/pearpass-lib-ui-kit', () => ({ Snackbar: (props) => mockSnackbar(props) })) jest.mock('../../utils/designVersion', () => ({ isV2: () => true })) describe('Toasts Component', () => { const mockIcon = jest.fn(() =>
Icon
) beforeEach(() => { jest.clearAllMocks() }) test('renders correctly with toasts', () => { const toasts = [ { message: 'Success message', icon: mockIcon }, { message: 'Error message', icon: null } ] const { getByTestId, getAllByTestId, getByText, container } = render( ) expect(getByTestId('toast-stack')).toBeInTheDocument() expect(getAllByTestId('snackbar')).toHaveLength(2) expect(getByText('Success message')).toBeInTheDocument() expect(getByText('Error message')).toBeInTheDocument() expect(getByTestId('mock-icon')).toBeInTheDocument() expect(mockSnackbar).toHaveBeenCalledTimes(2) expect(mockSnackbar).toHaveBeenNthCalledWith( 1, expect.objectContaining({ text: 'Success message', icon: expect.anything() }) ) expect(mockSnackbar).toHaveBeenNthCalledWith( 2, expect.objectContaining({ text: 'Error message', icon: undefined }) ) expect(container).toMatchSnapshot() }) test('renders correctly with empty toasts array', () => { const { getByTestId } = render( ) expect(getByTestId('toast-stack')).toBeInTheDocument() expect(mockSnackbar).not.toHaveBeenCalled() }) test('renders correctly with undefined toasts', () => { const { getByTestId } = render( ) expect(getByTestId('toast-stack')).toBeInTheDocument() expect(mockSnackbar).not.toHaveBeenCalled() }) test('passes icon through to Snackbar', () => { const toasts = [{ message: 'Test message', icon: mockIcon }] render( ) expect(mockSnackbar).toHaveBeenCalledWith( expect.objectContaining({ text: 'Test message', icon: expect.anything() }) ) }) }) ================================================ FILE: src/components/Toasts/styles.js ================================================ import styled from 'styled-components' export const ToastStack = styled.div` position: fixed; left: 50%; transform: translateX(-50%); bottom: 23.5px; display: flex; flex-direction: column; gap: 10px; z-index: 1000; ` export const ToastContainer = styled.div` display: flex; padding: 5px 10px; justify-content: center; align-items: center; gap: 7px; border-radius: 10px; background: ${({ theme }) => theme.colors.white.mode1}; box-shadow: 0px 0px 20px 0px rgba(0, 0, 0, 0.25); color: ${({ theme }) => theme.colors.black.mode1}; font-family: 'Inter'; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; ` ================================================ FILE: src/components/WebsiteButton/index.tsx ================================================ import React from 'react' import { OutsideLinkIcon } from '../../lib-react-components/icons/OutsideLinkIcon' interface WebsiteButtonProps { url?: string testId?: string } const WebsiteButton = ({ url, testId }: WebsiteButtonProps): React.ReactElement => { const handleWebsiteClick = () => { if (!url?.length) { return } window.open(url, '_blank') } return (
) } export { WebsiteButton } ================================================ FILE: src/constants/appConstants.js ================================================ export const DEBUG_MODE = false ================================================ FILE: src/constants/feedback.js ================================================ import { TEST_SLACK_WEBHOOK_URL_PATH as _TEST_SLACK_WEBHOOK_URL_PATH, SLACK_WEBHOOK_URL_PATH as _SLACK_WEBHOOK_URL_PATH, TEST_GOOGLE_FORM_KEY as _TEST_GOOGLE_FORM_KEY, GOOGLE_FORM_KEY as _GOOGLE_FORM_KEY, TEST_GOOGLE_FORM_MAPPING_TIMESTAMP, TEST_GOOGLE_FORM_MAPPING_TOPIC, TEST_GOOGLE_FORM_MAPPING_APP, TEST_GOOGLE_FORM_MAPPING_OPERATING_SYSTEM, TEST_GOOGLE_FORM_MAPPING_DEVICE_MODEL, TEST_GOOGLE_FORM_MAPPING_MESSAGE, TEST_GOOGLE_FORM_MAPPING_APP_VERSION, GOOGLE_FORM_MAPPING_TIMESTAMP, GOOGLE_FORM_MAPPING_TOPIC, GOOGLE_FORM_MAPPING_APP, GOOGLE_FORM_MAPPING_OPERATING_SYSTEM, GOOGLE_FORM_MAPPING_DEVICE_MODEL, GOOGLE_FORM_MAPPING_MESSAGE, GOOGLE_FORM_MAPPING_APP_VERSION } from '@tetherto/pearpass-lib-constants' import { isDev } from '../utils/envGetter' export const SLACK_WEBHOOK_URL_PATH = isDev() ? _TEST_SLACK_WEBHOOK_URL_PATH : _SLACK_WEBHOOK_URL_PATH export const GOOGLE_FORM_KEY = isDev() ? _TEST_GOOGLE_FORM_KEY : _GOOGLE_FORM_KEY export const GOOGLE_FORM_MAPPING = isDev() ? { timestamp: TEST_GOOGLE_FORM_MAPPING_TIMESTAMP, topic: TEST_GOOGLE_FORM_MAPPING_TOPIC, app: TEST_GOOGLE_FORM_MAPPING_APP, operatingSystem: TEST_GOOGLE_FORM_MAPPING_OPERATING_SYSTEM, deviceModel: TEST_GOOGLE_FORM_MAPPING_DEVICE_MODEL, message: TEST_GOOGLE_FORM_MAPPING_MESSAGE, appVersion: TEST_GOOGLE_FORM_MAPPING_APP_VERSION } : { timestamp: GOOGLE_FORM_MAPPING_TIMESTAMP, topic: GOOGLE_FORM_MAPPING_TOPIC, app: GOOGLE_FORM_MAPPING_APP, operatingSystem: GOOGLE_FORM_MAPPING_OPERATING_SYSTEM, deviceModel: GOOGLE_FORM_MAPPING_DEVICE_MODEL, message: GOOGLE_FORM_MAPPING_MESSAGE, appVersion: GOOGLE_FORM_MAPPING_APP_VERSION } ================================================ FILE: src/constants/formFields.js ================================================ const ATTACHMENTS_FIELD_KEY = 'attachments' export { ATTACHMENTS_FIELD_KEY } ================================================ FILE: src/constants/layout.ts ================================================ // Shared height for v2 column headers so they line up across columns. export const HEADER_MIN_HEIGHT = 44 // Bottom fade on scroll areas; list also pads by this to keep the last row visible. export const FADE_GRADIENT_HEIGHT = 70 ================================================ FILE: src/constants/localStorage.js ================================================ export const LOCAL_STORAGE_KEYS = { NATIVE_MESSAGING_ENABLED: 'native-messaging-enabled', TOU_ACCEPTED: 'tou-accepted', COPY_TO_CLIPBOARD_DISABLED: 'copy-to-clipboard-disabled', PASSWORD_CHANGE_REMINDER_ENABLED: 'password-change-reminder-enabled', AUTO_LOCK_ENABLED: 'auto-lock-enabled', AUTO_LOCK_TIMEOUT_MS: 'auto-lock-timeout-ms', NM_CLIENT_PUBLIC_KEY: 'nm-client-public-key', EXTENSION_DIALOG_DISMISSED: 'extension-dialog-dismissed' } ================================================ FILE: src/constants/meta.js ================================================ export const META_URL = import.meta.url ================================================ FILE: src/constants/navigation.js ================================================ export const NAVIGATION_ROUTES = { CREATE_MASTER_PASSWORD: 'createMasterPassword', MASTER_PASSWORD: 'masterPassword', VAULTS: 'vaults', LOAD_VAULT: 'loadVault', UPLOAD_BACKUP_FILE: 'uploadBackupFile', VAULT_PASSWORD: 'vaultPassword', NEW_VAULT_CREDENTIALS: 'newVaultCredentials', SCREEN_LOCKED: 'screenLocked' } ================================================ FILE: src/constants/pairing.js ================================================ /** * Pairing states for tracking extension pairing confirmation */ export const PAIRING_STATES = { PENDING: 'PENDING', // getIdentity called, desktop identity key pinned CONFIRMED: 'CONFIRMED' // confirmPairing succeeded, final pairing confirmed } ================================================ FILE: src/constants/password.ts ================================================ import { PasswordIndicatorVariant } from '@tetherto/pearpass-lib-ui-kit' const STRENGTH_MAP: Record = { error: 'vulnerable', warning: 'decent', success: 'strong' } export { STRENGTH_MAP } ================================================ FILE: src/constants/pearpassLinks.js ================================================ export const CHROME_EXTENSION_STORE_LINK = 'https://chromewebstore.google.com/detail/pdeffakfmcdnjjafophphgmddmigpejh' ================================================ FILE: src/constants/recordActions.js ================================================ import { CheckIcon, MoveToIcon, DeleteIcon, StarIcon } from '../lib-react-components' /** * @type {Record} */ export const RECORD_ACTION_ICON_BY_TYPE = { select: CheckIcon, favorite: StarIcon, move: MoveToIcon, delete: DeleteIcon } ================================================ FILE: src/constants/recordColorByType.js ================================================ import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' export const RECORD_COLOR_BY_TYPE = { all: colors.primary400.dark, login: colors.categoryLogin.dark, identity: colors.categoryIdentity.dark, creditCard: colors.categoryCreditCard.dark, note: colors.categoryNote.dark, custom: colors.categoryCustom.dark, password: colors.categoryPassword.dark, wifiPassword: colors.categoryWifiPassword.dark, passPhrase: colors.categoryPassPhrase.dark } ================================================ FILE: src/constants/recordIconByType.js ================================================ import { CreditCardIcon, FullBodyIcon, KeyIcon, LockIcon, NoteIcon, PasswordIcon, UserIcon, PassPhraseIcon } from '../lib-react-components' import { WifiIcon } from '../lib-react-components/icons/WifiIcon' export const RECORD_ICON_BY_TYPE = { all: KeyIcon, login: UserIcon, identity: FullBodyIcon, creditCard: CreditCardIcon, note: NoteIcon, custom: LockIcon, password: PasswordIcon, wifiPassword: WifiIcon, passPhrase: PassPhraseIcon } ================================================ FILE: src/constants/securityErrors.js ================================================ /** * Error codes for security-related operations */ export const SecurityErrorCodes = { // Pairing errors PAIRING_TOKEN_REQUIRED: 'PAIRING_TOKEN_REQUIRED', INVALID_PAIRING_TOKEN: 'INVALID_PAIRING_TOKEN', INVALID_PAIRING_SECRET: 'INVALID_PAIRING_SECRET', CLIENT_PUBLIC_KEY_REQUIRED: 'CLIENT_PUBLIC_KEY_REQUIRED', CLIENT_ALREADY_PAIRED: 'CLIENT_ALREADY_PAIRED', MISSING_CLIENT_PUBLIC_KEY: 'MISSING_CLIENT_PUBLIC_KEY', NO_PENDING_PAIRING: 'NO_PENDING_PAIRING', CLIENT_KEY_MISMATCH: 'CLIENT_KEY_MISMATCH', // Handshake errors NATIVE_MESSAGING_DISABLED: 'NATIVE_MESSAGING_DISABLED', NOT_PAIRED: 'NOT_PAIRED', CLIENT_NOT_PAIRED: 'CLIENT_NOT_PAIRED', MISSING_EPHEMERAL_PUBLIC_KEY: 'MISSING_EPHEMERAL_PUBLIC_KEY', MISSING_SESSION_ID: 'MISSING_SESSION_ID', MISSING_CLIENT_SIGNATURE: 'MISSING_CLIENT_SIGNATURE', SESSION_NOT_FOUND: 'SESSION_NOT_FOUND', IDENTITY_KEYS_UNAVAILABLE: 'IDENTITY_KEYS_UNAVAILABLE', // Validation errors INVALID_CLIENT_PUBLIC_KEY: 'INVALID_CLIENT_PUBLIC_KEY', INVALID_CLIENT_SIGNATURE: 'INVALID_CLIENT_SIGNATURE', INVALID_TRANSCRIPT: 'INVALID_TRANSCRIPT', CLIENT_SIGNATURE_INVALID: 'CLIENT_SIGNATURE_INVALID', // Secure request errors INVALID_SECURE_PAYLOAD: 'INVALID_SECURE_PAYLOAD', CLIENT_NOT_VERIFIED: 'CLIENT_NOT_VERIFIED', DECRYPT_FAILED: 'DECRYPT_FAILED', INVALID_SEQ: 'INVALID_SEQ', REPLAY_DETECTED: 'REPLAY_DETECTED', // Method registry errors UNKNOWN_METHOD: 'UNKNOWN_METHOD', DESKTOP_NOT_AUTHENTICATED: 'DESKTOP_NOT_AUTHENTICATED', // Auto lock errors MISSING_AUTO_LOCK_TIMEOUT_MS: 'MISSING_AUTO_LOCK_TIMEOUT_MS', INVALID_AUTO_LOCK_ENABLED: 'INVALID_AUTO_LOCK_ENABLED' } ================================================ FILE: src/constants/services.js ================================================ export const HANDLER_EVENTS = { extensionLock: 'extension-lock', extensionExit: 'extension-exit' } ================================================ FILE: src/constants/sortOptions.ts ================================================ export const SORT_KEYS = { TITLE_AZ: 'title_az', LAST_UPDATED_NEWEST: 'last_updated_newest', LAST_UPDATED_OLDEST: 'last_updated_oldest', DATE_ADDED_NEWEST: 'date_added_newest', DATE_ADDED_OLDEST: 'date_added_oldest' } as const export type SortKey = (typeof SORT_KEYS)[keyof typeof SORT_KEYS] export const SORT_BY_TYPE: Record< SortKey, { key: string; direction: 'asc' | 'desc' } > = { [SORT_KEYS.TITLE_AZ]: { key: 'data.title', direction: 'asc' }, [SORT_KEYS.LAST_UPDATED_NEWEST]: { key: 'updatedAt', direction: 'desc' }, [SORT_KEYS.LAST_UPDATED_OLDEST]: { key: 'updatedAt', direction: 'asc' }, [SORT_KEYS.DATE_ADDED_NEWEST]: { key: 'createdAt', direction: 'desc' }, [SORT_KEYS.DATE_ADDED_OLDEST]: { key: 'createdAt', direction: 'asc' } } ================================================ FILE: src/constants/timeConstants.js ================================================ export const COPY_FEEDBACK_DISPLAY_TIME = 2000 ================================================ FILE: src/constants/transitions.js ================================================ export const BASE_TRANSITION_DURATION = 300 ================================================ FILE: src/containers/AppHeaderContainer/AppHeaderContainer.test.js ================================================ import React from 'react' import '@testing-library/jest-dom' import { render, screen, fireEvent } from '@testing-library/react' let mockAuthenticatorEnabled = false jest.mock('@tetherto/pearpass-lib-constants', () => ({ get AUTHENTICATOR_ENABLED() { return mockAuthenticatorEnabled } })) jest.mock('../../utils/designVersion', () => ({ isV2: jest.fn() })) const mockNavigate = jest.fn() const mockSetModal = jest.fn() jest.mock('../../context/RouterContext', () => ({ useRouter: jest.fn() })) jest.mock('../../context/ModalContext', () => ({ useModal: () => ({ setModal: mockSetModal }) })) jest.mock('../../hooks/useTranslation', () => ({ useTranslation: () => ({ t: (key) => key }) })) jest.mock('../../hooks/useRecordMenuItems', () => ({ useRecordMenuItems: jest.fn() })) jest.mock('../../hooks/useCreateOrEditRecord', () => ({ useCreateOrEditRecord: jest.fn() })) jest.mock('../../components/PopupMenu', () => ({ PopupMenu: ({ children }) =>
{children}
})) jest.mock('../../components/CreateNewCategoryPopupContent', () => ({ CreateNewCategoryPopupContent: () => (
) })) jest.mock('../../components/AppHeaderV2', () => { const React = require('react') return { AppHeaderV2: jest.fn((props) => React.createElement( 'div', { 'data-testid': 'app-header-v2-mock' }, React.createElement('input', { 'data-testid': 'mock-search', value: props.searchValue, onChange: (e) => props.onSearchChange(e.target.value) }), React.createElement( 'button', { type: 'button', 'data-testid': 'mock-import', onClick: props.onImportClick }, 'Import' ), props.addItemControl ) ), AppHeaderAddItemTrigger: () => React.createElement( 'button', { type: 'button', 'data-testid': 'add-item-trigger' }, 'Add' ) } }) jest.mock('../Modal/ImportItemOrVaultModalContentV2', () => ({ ImportItemOrVaultModalContentV2: () => null })) import { AppHeaderContainer } from './AppHeaderContainer' import { AppHeaderV2 } from '../../components/AppHeaderV2' import { AppHeaderContextProvider } from '../../context/AppHeaderContext' import { useRouter } from '../../context/RouterContext' import { useCreateOrEditRecord } from '../../hooks/useCreateOrEditRecord' import { useRecordMenuItems } from '../../hooks/useRecordMenuItems' import { isV2 } from '../../utils/designVersion' const renderWithHeaderContext = (ui) => render({ui}) describe('AppHeaderContainer', () => { beforeEach(() => { jest.clearAllMocks() mockAuthenticatorEnabled = false isV2.mockReturnValue(true) mockNavigate.mockReset() mockSetModal.mockReset() useRouter.mockReturnValue({ currentPage: 'vault', data: { folder: 'folder-1', recordType: 'login' }, navigate: mockNavigate }) useRecordMenuItems.mockReturnValue({ categoriesItems: [], defaultItems: [], popupItems: [] }) useCreateOrEditRecord.mockReturnValue({ handleCreateOrEditRecord: jest.fn() }) }) it('returns null when design is not v2', () => { isV2.mockReturnValue(false) const { container } = renderWithHeaderContext() expect(container.firstChild).toBeNull() expect(AppHeaderV2).not.toHaveBeenCalled() }) it('returns null when current page is not vault', () => { useRouter.mockReturnValue({ currentPage: 'settings', data: {}, navigate: mockNavigate }) const { container } = renderWithHeaderContext() expect(container.firstChild).toBeNull() expect(AppHeaderV2).not.toHaveBeenCalled() }) it('renders AppHeaderV2 on authenticator vault when AUTHENTICATOR_ENABLED', () => { mockAuthenticatorEnabled = true useRouter.mockReturnValue({ currentPage: 'vault', data: { recordType: 'otp' }, navigate: mockNavigate }) renderWithHeaderContext() expect(screen.getByTestId('app-header-v2-mock')).toBeInTheDocument() expect(AppHeaderV2).toHaveBeenCalled() }) it('renders AppHeaderV2 on vault when v2 and not blocked', () => { renderWithHeaderContext() expect(screen.getByTestId('app-header-v2-mock')).toBeInTheDocument() expect(AppHeaderV2).toHaveBeenCalled() }) it('opens import modal on import', () => { renderWithHeaderContext() fireEvent.click(screen.getByTestId('mock-import')) expect(mockSetModal).toHaveBeenCalledTimes(1) }) it('wires search to header context state', () => { renderWithHeaderContext() fireEvent.change(screen.getByTestId('mock-search'), { target: { value: 'find-me' } }) expect(screen.getByTestId('mock-search')).toHaveValue('find-me') }) }) ================================================ FILE: src/containers/AppHeaderContainer/AppHeaderContainer.tsx ================================================ import React from 'react' import { AUTHENTICATOR_ENABLED } from '@tetherto/pearpass-lib-constants' import { ContextMenu, NavbarListItem, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { AccountCircleOutlined, AssignmentInd, CreditCard, FormatQuote, GridView, Key, Note, QrCode, WiFi } from '@tetherto/pearpass-lib-ui-kit/icons' import { RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { AppHeaderAddItemTrigger, AppHeaderV2 } from '../../components/AppHeaderV2' import { useModal } from '../../context/ModalContext' import { useRouter } from '../../context/RouterContext' import { useAppHeaderContext } from '../../context/AppHeaderContext' import { useCreateOrEditRecord } from '../../hooks/useCreateOrEditRecord' import { useTranslation } from '../../hooks/useTranslation' import { isV2 } from '../../utils/designVersion' import { isFavorite } from '../../utils/isFavorite' import { ImportItemOrVaultModalContentV2 } from '../Modal/ImportItemOrVaultModalContentV2' export const AppHeaderContainer = () => { const { currentPage, data: routerData } = useRouter() const { setModal } = useModal() const { searchValue, setSearchValue, isAddMenuOpen, setIsAddMenuOpen } = useAppHeaderContext() const { handleCreateOrEditRecord } = useCreateOrEditRecord() const { theme } = useTheme() const { t } = useTranslation() if (!isV2()) { return null } if (currentPage !== 'vault') { return null } const isFavoritesView = isFavorite(routerData?.folder ?? '') const selectedFolder = routerData?.folder && !isFavoritesView ? routerData.folder : undefined const iconColor = theme.colors.colorTextPrimary const addItems = [ { type: RECORD_TYPES.LOGIN, label: t('Logins'), icon: }, { type: RECORD_TYPES.CREDIT_CARD, label: t('Credit Card'), icon: }, { type: RECORD_TYPES.IDENTITY, label: t('Identities'), icon: }, { type: RECORD_TYPES.NOTE, label: t('Notes'), icon: }, { type: RECORD_TYPES.PASS_PHRASE, label: t('Recovery Phrases'), icon: }, { type: RECORD_TYPES.WIFI_PASSWORD, label: t('Wi-Fi'), icon: }, { type: 'password', label: t('Password'), icon: }, { type: RECORD_TYPES.CUSTOM, label: t('Other'), icon: }, ...(AUTHENTICATOR_ENABLED ? [{ type: RECORD_TYPES.OTP, label: t('Authenticator Code'), icon: }] : []) ] const handleImportClick = () => { setModal() } const addItemControl = ( } open={isAddMenuOpen} onOpenChange={setIsAddMenuOpen} testID="add-item-menu" > {addItems.map(item => ( { handleCreateOrEditRecord({ recordType: item.type, selectedFolder, isFavorite: isFavoritesView ? true : undefined }) setIsAddMenuOpen(false) }} /> ))} ) return ( setSearchValue(val)} onImportClick={handleImportClick} addItemControl={addItemControl} /> ) } ================================================ FILE: src/containers/AppHeaderContainer/index.ts ================================================ export { AppHeaderContainer } from './AppHeaderContainer' ================================================ FILE: src/containers/AttachmentField/index.js ================================================ import { html } from 'htm/react' import { AdditionalItems, AttachmentName, IconWrapper, InputAreaWrapper, Label, MainWrapper, Wrapper } from './styles' import { useModal } from '../../context/ModalContext' import { CommonFileIcon } from '../../lib-react-components' import { isV2 } from '../../utils/designVersion' import { DisplayPictureModalContent } from '../Modal/DisplayPictureModalContent' import { DisplayPictureModalContentV2 } from '../Modal/DisplayPictureModalContentV2/DisplayPictureModalContentV2' const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'] const getExtension = (filename) => filename?.split('.').pop()?.toLowerCase() export const AttachmentField = ({ attachment, label, additionalItems, testId }) => { const { setModal } = useModal() const isImage = attachment?.name ? imageExtensions.includes(getExtension(attachment.name)) : false const handleDownload = (e) => { e.stopPropagation() if (!attachment?.buffer || !attachment?.name) { return } const blob = new Blob([attachment.buffer]) const url = URL.createObjectURL(blob) if (isImage) { const ModalContentComponent = isV2() ? DisplayPictureModalContentV2 : DisplayPictureModalContent setModal() } else { const a = document.createElement('a') a.href = url a.download = attachment.name document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } } return html` <${Wrapper} data-testid=${testId}> <${IconWrapper}> <${CommonFileIcon} size="21" /> <${MainWrapper}> <${Label}> ${label} <${InputAreaWrapper}> <${AttachmentName} href="#" onClick=${handleDownload}> ${attachment.name} ${!!additionalItems && html` <${AdditionalItems} onMouseDown=${(e) => e.stopPropagation()}> ${additionalItems} `} ` } ================================================ FILE: src/containers/AttachmentField/styles.js ================================================ import styled from 'styled-components' export const Wrapper = styled.div` display: flex; align-items: flex-start; gap: 10px; width: 100%; position: relative; border: 1px solid; border-color: ${({ theme }) => theme.colors.grey100.mode1}; border-bottom: none; background: ${({ theme }) => theme.colors.grey400.mode1}; margin-top: 0; padding: 8px 10px; &:first-child { border-top-left-radius: 10px; border-top-right-radius: 10px; } &:last-child { border-bottom-left-radius: 10px; border-bottom-right-radius: 10px; border-bottom: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; } &:hover, &:focus-within { border-color: ${({ theme }) => theme.colors.primary400.mode1}; } &:hover + &, &:focus-within + & { border-top-color: ${({ theme }) => theme.colors.primary400.mode1}; } ` export const InputAreaWrapper = styled.div` position: relative; margin-top: 5px; overflow-x: auto; white-space: nowrap; display: flex; align-items: center; ` export const IconWrapper = styled.div` display: flex; flex-shrink: 0; margin-top: 9px; ` export const MainWrapper = styled.div` flex: 1; display: flex; flex-direction: column; min-width: 0; ` export const AttachmentName = styled.a` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-weight: 700; padding: 1px 0px; height: 21.5px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; ` export const Label = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-weight: 400; ` export const AdditionalItems = styled.div` display: flex; justify-content: flex-end; align-items: center; gap: 10px; align-self: center; ` ================================================ FILE: src/containers/AuthenticationCard/index.js ================================================ import { useState } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { useUserData } from '@tetherto/pearpass-lib-vault' import { stringToBuffer, clearBuffer } from '@tetherto/pearpass-lib-vault/src/utils/buffer' import { html } from 'htm/react' import { ButtonWrapper, CardContainer, CardTitle, Title } from './styles.js' import { useGlobalLoading } from '../../context/LoadingContext.js' import { useTranslation } from '../../hooks/useTranslation.js' import { ButtonPrimary, PearPassPasswordField } from '../../lib-react-components/index.js' import { logger } from '../../utils/logger.js' /** * Authentication card component that provides master password input form for authentication. * Validates the master password and calls the onSuccess callback upon successful authentication. * * @param {Object} props - Component props * @param {string} props.title - The title displayed at the top of the card * @param {string} props.buttonLabel - The label text for the submit button * @param {React.ReactNode} [props.descriptionComponent] - Optional component to display additional description or content below the password field * @param {Function} [props.onSuccess] - Optional callback function invoked after successful authentication, receives the password as an argument * @param {Function} [props.onError] - Optional callback function invoked after failed authentication, receives the error and setErrors function * @param {Object} [props.style] - Optional CSS styles to apply to the card container * @returns {React.ReactElement} The authentication card component */ export const AuthenticationCard = ({ title, buttonLabel, descriptionComponent, onSuccess, onError, style }) => { const { t } = useTranslation() const [isLoading, setIsLoading] = useState(false) useGlobalLoading({ isLoading }) const schema = Validator.object({ password: Validator.string().required(t('Password is required')) }) const { logIn } = useUserData() const { register, handleSubmit, setErrors } = useForm({ initialValues: { password: '' }, validate: (values) => schema.validate(values) }) const onSubmit = async (values) => { if (isLoading) { return } if (!values.password) { setErrors({ password: t('Password is required') }) return } const passwordBuffer = stringToBuffer(values.password) try { setIsLoading(true) await logIn({ password: passwordBuffer }) await onSuccess?.(passwordBuffer) setIsLoading(false) } catch (error) { setIsLoading(false) if (onError) { await onError(error, setErrors) } else { setErrors({ password: t('Invalid password') }) } logger.error( 'AuthenticationCard', 'Error unlocking with master password:', error ) } finally { clearBuffer(passwordBuffer) } } return html` <${CardContainer} onSubmit=${handleSubmit(onSubmit)} style=${style}> <${CardTitle}> <${Title} data-testid="login-title"> ${title} <${PearPassPasswordField} testId="login-password-input" placeholder=${t('Master password')} ...${register('password')} /> ${descriptionComponent} <${ButtonWrapper}> <${ButtonPrimary} testId="login-continue-button" type="submit"> ${buttonLabel} ` } ================================================ FILE: src/containers/AuthenticationCard/styles.js ================================================ import styled from 'styled-components' export const CardContainer = styled.form` display: flex; flex-direction: column; gap: 25px; justify-content: center; width: 609px; ` export const CardTitle = styled.div` display: flex; flex-direction: column; gap: 10px; justify-content: center; ` export const Title = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; text-align: center; font-family: 'Inter'; font-size: 20px; font-style: normal; font-weight: 500; line-height: normal; ` export const ButtonWrapper = styled.div` align-self: center; ` ================================================ FILE: src/containers/BaseInitialPage/index.js ================================================ import { useLingui } from '@lingui/react' import { html } from 'htm/react' import { GreenText, PageContainer, PageContentContainer, PearHand, Title } from './styles' import { InitialPageWrapper } from '../../components/InitialPageWrapper' /** * @param {Object} props - Component props. * @param {React.ReactNode} props.children - Child components to be rendered inside the page. * @returns {JSX.Element} The rendered initial page layout. */ export const BaseInitialPage = ({ children }) => { const { i18n } = useLingui() return html` <${InitialPageWrapper}> <${PageContainer}> <${PageContentContainer}>
<${Title}> ${i18n._('Protect')}${' '} <${GreenText}>${i18n._('your digital')} ${' '} ${i18n._('life')} ${children}
<${PearHand} src="assets/images/pear_lock_clear.png" alt="pearHand" /> ` } ================================================ FILE: src/containers/BaseInitialPage/styles.js ================================================ import styled from 'styled-components' export const PageContainer = styled.div` width: 100%; height: 100%; ` export const PearHand = styled.img` position: relative; width: 50%; max-width: 585px; aspect-ratio: 1/1; ` export const PageContentContainer = styled.div` display: flex; align-items: center; justify-content: space-between; margin-top: 180px; ` export const Title = styled.span` flex: 1; color: ${({ theme }) => theme.colors.white.mode1}; max-width: 700px; font-family: 'Humble Nostalgia'; font-size: clamp(1rem, 8vw, 10rem); font-style: normal; font-weight: 400; line-height: 1.1; ` export const GreenText = styled.span` color: ${({ theme }) => theme.colors.primary400.mode1}; ` ================================================ FILE: src/containers/CustomFields/index.tsx ================================================ import React from 'react' import { FormGroup } from '../../components/FormGroup' import { InputFieldNote } from '../../components/InputFieldNote' import { ButtonRoundIcon, DeleteIcon } from '../../lib-react-components' import { CopyButton } from '../../components/CopyButton' interface CustomField { id: string type: 'note' props: Record } interface CustomFieldsProps { register: (name: string, index: number) => { name: string; value: string; error?: string; onChange: (e: unknown) => void; } customFields?: CustomField[] onClick?: () => void areInputsDisabled: boolean removeItem?: (index: number) => void } /** * @param {{ * register: (name: string, index: number) => { * name: string; * value: string; * error?: string; * onChange: (e: unknown) => void; * } * customFields?: { * id: string * type: 'note' * props: Record * }[] * onClick?: () => void * areInputsDisabled: boolean * removeItem?: () => void * }} props */ export const CustomFields: React.FC = ({ customFields, register, areInputsDisabled, removeItem, onClick }) => { return ( {customFields?.map((customField, index) => { if (customField.type === 'note') { return ( {areInputsDisabled && ( )} {!areInputsDisabled && ( removeItem?.(index)} /> )} } {...register('note', index)} /> ) } return null })} ) } ================================================ FILE: src/containers/EmptyCollectionViewV2/EmptyCollectionViewV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const CONTENT_MAX_WIDTH = 500 export const BUTTONS_MAX_WIDTH = 350 export const ILLUSTRATION_HEIGHT = 151 export const createStyles = () => ({ container: { display: 'flex' as const, flexDirection: 'column' as const, alignItems: 'center' as const, justifyContent: 'center' as const, flex: 1, paddingInline: `${rawTokens.spacing12}px`, paddingBlock: '60px', width: '100%', boxSizing: 'border-box' as const }, content: { display: 'flex' as const, flexDirection: 'column' as const, alignItems: 'center' as const, gap: `${rawTokens.spacing24}px`, width: '100%', maxWidth: `${CONTENT_MAX_WIDTH}px` }, illustration: { width: '100%', height: `${ILLUSTRATION_HEIGHT}px`, overflow: 'hidden' as const, flexShrink: 0, display: 'flex' as const, justifyContent: 'center', }, textBlock: { display: 'flex' as const, flexDirection: 'column' as const, alignItems: 'center' as const, gap: `${rawTokens.spacing6}px`, width: '100%', textAlign: 'center' as const }, descriptionParagraph: { margin: 0 }, ctas: { display: 'flex' as const, flexDirection: 'column' as const, alignItems: 'center' as const, justifyContent: 'center' as const, gap: `${rawTokens.spacing12}px`, width: '100%' }, ctaButton: { width: '100%', maxWidth: `${BUTTONS_MAX_WIDTH}px` } }) ================================================ FILE: src/containers/EmptyCollectionViewV2/EmptyCollectionViewV2.tsx ================================================ import React, { useMemo } from 'react' import { Button, Text, Title, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { Add, ImportExport } from '@tetherto/pearpass-lib-ui-kit/icons' import { ILLUSTRATION_HEIGHT, createStyles } from './EmptyCollectionViewV2.styles' import { useAppHeaderContext } from '../../context/AppHeaderContext' import { useRouter } from '../../context/RouterContext' import { useRecordMenuItemsV2 } from '../../hooks/useRecordMenuItemsV2' import { useTranslation } from '../../hooks/useTranslation' import { SettingsItemKey } from '../../pages/SettingsViewV2/SettingsViewV2' import { ItemCardIllustration } from '../../svgs/ItemCardIllustration' type EmptyCollectionViewV2Props = { recordType?: string selectedFolder?: string isFavoritesView?: boolean } export const EmptyCollectionViewV2 = ({ recordType = 'all', selectedFolder, isFavoritesView = false }: EmptyCollectionViewV2Props) => { const { t } = useTranslation() const { theme } = useTheme() const { navigate } = useRouter() const { setIsAddMenuOpen } = useAppHeaderContext() const { categoriesItems } = useRecordMenuItemsV2() const styles = createStyles() const handleAddItem = () => { setIsAddMenuOpen(true) } const handleImport = () => { navigate('settings', { initialTab: SettingsItemKey.ImportItems }) } const categoryLabel = categoriesItems.find( (item) => item.type === recordType )?.label const { title, descriptionParagraphs } = useMemo<{ title: string descriptionParagraphs: string[] }>(() => { if (isFavoritesView) { return { title: t('No favorite items'), descriptionParagraphs: [t('Mark items as favorites')] } } if (selectedFolder) { return { title: t('Empty folder'), descriptionParagraphs: [ t('Start adding items or save existing ones in the {folder} folder', { folder: selectedFolder }) ] } } if (recordType !== 'all' && categoryLabel) { return { title: t('No item of type {category}', { category: categoryLabel }), descriptionParagraphs: [ t('Start adding items of type {category} in your vault', { category: categoryLabel }) ] } } return { title: t('No item saved'), descriptionParagraphs: [ t('Start using PearPass by creating your first item'), t('or import your items from a different password manager') ] } }, [isFavoritesView, selectedFolder, recordType, categoryLabel, t]) return (
{title} {descriptionParagraphs.map((paragraph, index) => ( ['style'] } > {paragraph} ))}
{!isFavoritesView && (
)}
) } ================================================ FILE: src/containers/EmptyCollectionViewV2/index.ts ================================================ export { EmptyCollectionViewV2 } from './EmptyCollectionViewV2' ================================================ FILE: src/containers/EmptyResultsViewV2/EmptyResultsViewV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ container: { display: 'flex' as const, flexDirection: 'column' as const, alignItems: 'center' as const, justifyContent: 'center' as const, flex: 1, gap: `${rawTokens.spacing16}px`, paddingInline: `${rawTokens.spacing16}px`, paddingBlock: `${rawTokens.spacing24}px`, width: '100%' }, text: { textAlign: 'center' as const } }) ================================================ FILE: src/containers/EmptyResultsViewV2/EmptyResultsViewV2.tsx ================================================ import React from 'react' import { Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { createStyles } from './EmptyResultsViewV2.styles' import { useTranslation } from '../../hooks/useTranslation' export const EmptyResultsViewV2 = () => { const { t } = useTranslation() const { theme } = useTheme() const styles = createStyles() return (
{t('No result found.')}
) } ================================================ FILE: src/containers/EmptyResultsViewV2/index.ts ================================================ export { EmptyResultsViewV2 } from './EmptyResultsViewV2' ================================================ FILE: src/containers/ImagesField/index.js ================================================ import { useEffect, useMemo } from 'react' import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' import { html } from 'htm/react' import { AddContainer, Body, Container, DeleteIconWrapper, DeleteOverlay, Header, ImageContainer, Photo, Title } from './styles' import { useModal } from '../../context/ModalContext' import { DeleteIcon, ImageIcon, PlusIcon } from '../../lib-react-components' import { isV2 } from '../../utils/designVersion' import { DisplayPictureModalContent } from '../Modal/DisplayPictureModalContent' import { DisplayPictureModalContentV2 } from '../Modal/DisplayPictureModalContentV2/DisplayPictureModalContentV2' /** * @param {{ * title: string * pictures: { buffer: ArrayBuffer, name: string }[] * onAdd?: () => void * onRemove?: (index: number) => void * testId?: string * }} props */ export const ImagesField = ({ title, pictures = [], onAdd, onRemove, testId }) => { const { setModal } = useModal() const pictureUrls = useMemo( () => pictures.map((picture) => ({ url: URL.createObjectURL(new Blob([picture.buffer])), name: picture.name })), [pictures] ) useEffect( () => () => { pictureUrls.forEach((p) => URL.revokeObjectURL(p.url)) }, [pictureUrls] ) const handlePictureClick = (url, name) => { const ModalContentComponent = isV2() ? DisplayPictureModalContentV2 : DisplayPictureModalContent setModal() } const handleRemove = (e, index) => { e.stopPropagation() onRemove?.(index) } return html` <${Container} data-testid=${testId}> <${Header}> <${ImageIcon} /> <${Title}>${title} <${Body}> ${pictureUrls?.map( (picture, idx) => html` <${ImageContainer} onClick=${() => handlePictureClick(picture.url, picture.name)} key=${idx} > <${Photo} src=${picture.url} alt="attachment" /> ${onRemove && html`<${DeleteOverlay}> <${DeleteIconWrapper} onClick=${(e) => handleRemove(e, idx)}> <${DeleteIcon} size="24" color=${colors.black.mode1} /> `} ` )} ${!!onAdd && html` <${AddContainer} data-testid=${testId ? `${testId}-add` : undefined} onClick=${onAdd} > <${PlusIcon} color=${colors.primary400.mode1} /> `} ` } ================================================ FILE: src/containers/ImagesField/styles.js ================================================ import styled from 'styled-components' export const Container = styled.div` margin-top: 10px; width: 100%; display: flex; flex-direction: column; gap: 10px; padding: 10px; border-radius: 10px; border: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; background: ${({ theme }) => theme.colors.grey400.mode1}; ` export const Header = styled.div` display: flex; gap: 10px; ` export const Title = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-style: normal; font-weight: 400; line-height: normal; ` export const Body = styled.div` display: flex; flex-wrap: wrap; gap: 10px; min-height: 50px; ` export const ImageContainer = styled.div` display: flex; align-items: center; width: 90px; height: 50px; border-radius: 10px; overflow: hidden; position: relative; border: 1px solid ${({ theme }) => theme.colors.primary400.mode1}; ` export const DeleteOverlay = styled.div` position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.3s ease; &:hover { opacity: 1; } ` export const DeleteIconWrapper = styled.div` width: 20px; height: 20px; position: absolute; top: 0px; right: 0px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background-color: ${({ theme }) => theme.colors.primary400.mode1}; cursor: pointer; ` export const Photo = styled.img` width: 100%; height: 100%; object-fit: cover; ` export const AddContainer = styled.div` display: flex; align-items: center; justify-content: center; width: 90px; height: 50px; border-radius: 10px; border: 2px solid ${({ theme }) => theme.colors.primary400.mode1}; cursor: pointer; ` ================================================ FILE: src/containers/LayoutWithSidebar/index.js ================================================ import { useTheme } from '@tetherto/pearpass-lib-ui-kit' import { html } from 'htm/react' import { ContentWrapper, ContentWrapperV2, LayoutWrapper, SideBarWrapper, SideViewWrapper } from './styles' import { isV2 } from '../../utils/designVersion' import { Sidebar } from '../Sidebar' import { SidebarV2 } from '../Sidebar/SidebarV2' /** * @typedef LayoutWithSidebarProps * @property {import('react').ReactNode} mainView * @property {import('react').ReactNode} sideView * @property {boolean} isSideViewOpen */ /** * @param {LayoutWithSidebarProps} props */ export const LayoutWithSidebar = ({ mainView, sideView, isSideViewOpen }) => { const { theme } = useTheme() const isV2Design = isV2() const VersionBasedContentWrapper = isV2Design ? ContentWrapperV2 : ContentWrapper const v2SideViewStyle = { flexBasis: 0, flexShrink: isSideViewOpen ? 0 : 1, flexGrow: isSideViewOpen ? 1 : 0, minWidth: isSideViewOpen ? '250px' : 0, overflowX: 'hidden', overflowY: isSideViewOpen ? 'auto' : 'hidden', backgroundColor: theme.colors.colorSurfacePrimary, borderLeftWidth: isSideViewOpen ? 1 : 0, borderLeftStyle: 'solid', borderLeftColor: theme.colors.colorBorderPrimary, transition: 'flex-grow 150ms ease, border-left-width 150ms ease, min-width 150ms ease' } const v2MainViewStyle = isV2Design && isSideViewOpen ? { flex: '0 1 350px', minWidth: '300px', transition: 'flex 150ms ease' } : {} return html` <${LayoutWrapper}> <${SideBarWrapper}> ${isV2Design ? html`<${SidebarV2} />` : html`<${Sidebar} />`} <${VersionBasedContentWrapper} style=${v2MainViewStyle}> ${mainView} ${isV2Design ? sideView && html`
${sideView}
` : isSideViewOpen && sideView ? html`<${SideViewWrapper}>${sideView}` : null} ` } ================================================ FILE: src/containers/LayoutWithSidebar/styles.js ================================================ import styled from 'styled-components' export const LayoutWrapper = styled.div` display: flex; width: 100%; height: 100%; ` export const SideBarWrapper = styled.div` flex-shrink: 0; ` export const ContentWrapper = styled.div` position: relative; flex: 1; padding: 29px 15px 0; display: flex; align-items: center; align-self: stretch; background: ${({ theme }) => theme.colors.grey400.mode1}; ` export const ContentWrapperV2 = styled.div` flex: 1; min-width: 0; display: flex; flex-direction: column; align-self: stretch; ` export const SideViewWrapper = styled.div` flex: 1; overflow-y: auto; background: ${({ theme }) => theme.colors.grey500.mode1}; border-left: 1px solid ${({ theme }) => theme.colors.grey300.mode1}; padding: 24px 27px 0 27px; ` ================================================ FILE: src/containers/MainViewHeader/MainViewHeader.styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' import { HEADER_MIN_HEIGHT } from '../../constants/layout' export const SORT_MENU_WIDTH = 260 export const createStyles = (colors: ThemeColors) => ({ container: { display: 'flex' as const, alignItems: 'center' as const, height: `${HEADER_MIN_HEIGHT}px`, paddingInline: `${rawTokens.spacing12}px`, borderBottom: `1px solid ${colors.colorBorderPrimary}`, backgroundColor: colors.colorSurfacePrimary, boxSizing: 'border-box' as const, flexShrink: 0 }, breadcrumbWrapper: { flex: 1, minWidth: 0, display: 'flex' as const, alignItems: 'center' as const }, actions: { display: 'flex' as const, alignItems: 'center' as const, gap: `${rawTokens.spacing4}px` } }) ================================================ FILE: src/containers/MainViewHeader/MainViewHeader.test.tsx ================================================ import React from 'react' import '@testing-library/jest-dom' import { fireEvent, render, screen } from '@testing-library/react' import { SORT_KEYS } from '../../constants/sortOptions' let mockRouterData: Record = {} jest.mock('../../context/RouterContext', () => ({ useRouter: () => ({ data: mockRouterData }) })) jest.mock('../../hooks/useTranslation', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) jest.mock('../../hooks/useRecordMenuItemsV2', () => ({ ALL_ITEMS_TYPE: 'all', useRecordMenuItemsV2: () => ({ categoriesItems: [ { type: 'all', label: 'All Items' }, { type: 'login', label: 'Logins' } ] }) })) jest.mock('@tetherto/pearpass-lib-ui-kit', () => { const React = require('react') return { useTheme: () => ({ theme: { colors: { colorTextPrimary: '#fff', colorBorderPrimary: '#222', colorSurfacePrimary: '#000' } } }), rawTokens: new Proxy({}, { get: () => 0 }), Breadcrumb: ({ items, actions }: { items: string[] actions?: React.ReactNode }) => React.createElement( 'nav', { 'data-testid': 'breadcrumb' }, [ React.createElement( 'span', { key: 'items', 'data-testid': 'breadcrumb-items' }, items.join(' > ') ), actions ] ), Button: ({ children, onClick, disabled, ...rest }: { children?: React.ReactNode onClick?: () => void disabled?: boolean [key: string]: unknown }) => React.createElement( 'button', { type: 'button', onClick, disabled, ...rest }, children ), ContextMenu: ({ trigger, children, open }: { trigger: React.ReactNode children: React.ReactNode open?: boolean }) => React.createElement('div', { 'data-testid': 'context-menu' }, [ React.createElement('div', { key: 'trigger' }, trigger), open ? React.createElement('div', { key: 'menu', role: 'menu' }, children) : null ]), NavbarListItem: ({ label, onClick, testID }: { label?: string onClick?: () => void testID?: string }) => React.createElement( 'button', { type: 'button', 'data-testid': testID, onClick }, label ) } }) jest.mock('@tetherto/pearpass-lib-ui-kit/icons', () => { const React = require('react') const Icon = (name: string) => () => React.createElement('span', { 'data-icon': name }) return { CalendarToday: Icon('CalendarToday'), Check: Icon('Check'), Checklist: Icon('Checklist'), FilterList: Icon('FilterList'), SortByAlpha: Icon('SortByAlpha') } }) import { MainViewHeader } from './MainViewHeader' describe('MainViewHeader', () => { const baseProps = { sortKey: SORT_KEYS.LAST_UPDATED_NEWEST, setSortKey: jest.fn(), isMultiSelectOn: false, setIsMultiSelectOn: jest.fn() } beforeEach(() => { mockRouterData = {} jest.clearAllMocks() }) it('renders the breadcrumb, select and sort controls', () => { render() expect(screen.getByTestId('breadcrumb-items').textContent).toBe( 'All Items > All Folders' ) expect(screen.getByTestId('main-view-header-select')).toBeInTheDocument() expect(screen.getByTestId('main-view-header-sort')).toBeInTheDocument() }) it('toggles multi-select via the select button', () => { const setIsMultiSelectOn = jest.fn() render( ) fireEvent.click(screen.getByTestId('main-view-header-select')) expect(setIsMultiSelectOn).toHaveBeenCalledWith(true) }) it('deactivates multi-select when already active and the select button is clicked', () => { const setIsMultiSelectOn = jest.fn() render( ) fireEvent.click(screen.getByTestId('main-view-header-select')) expect(setIsMultiSelectOn).toHaveBeenCalledWith(false) }) it('reflects router data in the breadcrumb', () => { mockRouterData = { recordType: 'login', folder: 'Work' } render() expect(screen.getByTestId('breadcrumb-items').textContent).toBe( 'Logins > Work' ) }) }) ================================================ FILE: src/containers/MainViewHeader/MainViewHeader.tsx ================================================ import React, { useMemo, useState } from 'react' import { Breadcrumb, Button, ContextMenu, NavbarListItem, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { CalendarToday, Check, Checklist, FilterList, SortByAlpha } from '@tetherto/pearpass-lib-ui-kit/icons' import { createStyles, SORT_MENU_WIDTH } from './MainViewHeader.styles' import { SORT_KEYS, type SortKey } from '../../constants/sortOptions' import { useRouter } from '../../context/RouterContext' import { ALL_ITEMS_TYPE, useRecordMenuItemsV2 } from '../../hooks/useRecordMenuItemsV2' import { useTranslation } from '../../hooks/useTranslation' import { isFavorite } from '../../utils/isFavorite' type MainViewHeaderProps = { sortKey: SortKey setSortKey: (key: SortKey) => void isMultiSelectOn: boolean setIsMultiSelectOn: (value: boolean) => void } export const MainViewHeader = ({ sortKey, setSortKey, isMultiSelectOn, setIsMultiSelectOn }: MainViewHeaderProps) => { const { t } = useTranslation() const { theme } = useTheme() const { data: routerData } = useRouter() const { categoriesItems } = useRecordMenuItemsV2() const styles = createStyles(theme.colors) const [isSortOpen, setIsSortOpen] = useState(false) const recordType = routerData?.recordType ?? ALL_ITEMS_TYPE const categoryLabel = categoriesItems.find((item) => item.type === recordType)?.label ?? t('All Items') const folder = routerData?.folder const folderLabel = isFavorite(folder ?? '') ? t('Favorites') : folder ? folder : t('All Folders') const sortOptions = useMemo< Array<{ key: SortKey; label: string; Icon: React.ComponentType<{ color?: string }> }> >( () => [ { key: SORT_KEYS.TITLE_AZ, label: t('Title (A-Z)'), Icon: SortByAlpha }, { key: SORT_KEYS.LAST_UPDATED_NEWEST, label: t('Last Updated (Newest first)'), Icon: CalendarToday }, { key: SORT_KEYS.LAST_UPDATED_OLDEST, label: t('Last Updated (Oldest first)'), Icon: CalendarToday }, { key: SORT_KEYS.DATE_ADDED_NEWEST, label: t('Date Added (Newest first)'), Icon: CalendarToday }, { key: SORT_KEYS.DATE_ADDED_OLDEST, label: t('Date Added (Oldest first)'), Icon: CalendarToday } ], [t] ) const handleSelectSort = (key: SortKey) => { setSortKey(key) setIsSortOpen(false) } const iconStyle = { color: theme.colors.colorTextPrimary } return (
} />
) } ================================================ FILE: src/containers/Modal/AddDeviceModalContent/ScanQRExpireTimer.tsx ================================================ import { useCountDown } from '@tetherto/pear-apps-lib-ui-react-hooks' import { ExpireTime } from './styles' interface Props { initialSeconds?: number onFinish?: () => void withSuffix?: boolean } export const ScanQRExpireTimer = ({ initialSeconds = 120, onFinish, withSuffix = false }: Props) => { const expireTime = useCountDown({ initialSeconds, onFinish }) return ( {' '} {expireTime} {withSuffix ? 's' : ''}{' '} ) } ================================================ FILE: src/containers/Modal/AddDeviceModalContent/index.ts ================================================ import os from 'os' import { useEffect, useState } from 'react' import type { ClipboardEvent } from 'react' import { html } from 'htm/react' import { generateQRCodeSVG } from '@tetherto/pear-apps-utils-qr' import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' import { authoriseCurrentProtectedVault, useInvite, useVault, usePair } from '@tetherto/pearpass-lib-vault' import { InputFieldWrapper } from './styles' import { PasteIconWrapper } from './styles' import { BackgroundSection, Content, CopyText, ExpireText, HeaderTitle, PairTabs, PairTab, QRCode, QRCodeCopy, QRCodeCopyWrapper, QRCodeSection, QRCodeText, LoadVaultNotice, PairingDescription } from './styles' import { AlertBox } from '../../../components/AlertBox' import { FormModalHeaderWrapper } from '../../../components/FormModalHeaderWrapper' import { useModal } from '../../../context/ModalContext' import { useRouter } from '../../../context/RouterContext' import { useToast } from '../../../context/ToastContext' import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard.electron' import { useAutoLockPreferences } from '../../../hooks/useAutoLockPreferences' import { useGlobalLoading } from '../../../context/LoadingContext' import { useTranslation } from '../../../hooks/useTranslation' import { CopyIcon, TimeIcon, UserSecurityIcon } from '../../../lib-react-components' import { InputField } from '../../../lib-react-components/components/InputField' import { PasteIcon } from '../../../lib-react-components/icons/PasteIcon' import { ModalContent } from '../ModalContent' import { VaultPasswordFormModalContent } from '../VaultPasswordFormModalContent' import { ScanQRExpireTimer } from './ScanQRExpireTimer' export const AddDeviceModalContent = () => { const { t } = useTranslation() const { setToast } = useToast() const { closeModal } = useModal() const [qrSvg, setQrSvg] = useState('') const [isProtected, setIsProtected] = useState(true) const [scanQRStep, setScanQRStep] = useState(true) const { data: vaultData, isVaultProtected, refetch: refetchVault, addDevice } = useVault() const { createInvite, deleteInvite, data } = useInvite() const [inviteCode, setInviteCodeId] = useState('') const { pairActiveVault, isLoading: isPairing, cancelPairActiveVault } = usePair() const { navigate } = useRouter() const { setShouldBypassAutoLock } = useAutoLockPreferences() const { copyToClipboard, isCopied } = useCopyToClipboard() useEffect(() => { setShouldBypassAutoLock(true) return () => setShouldBypassAutoLock(false) }, [setShouldBypassAutoLock]) useGlobalLoading({ isLoading: isPairing }) useEffect(() => { createInvite() return () => { deleteInvite() } }, []) useEffect(() => { if (data?.publicKey) { generateQRCodeSVG(data?.publicKey, { type: 'svg', margin: 0 }).then( (value: string) => setQrSvg(value) ) } }, [data]) useEffect(() => { const checkProtection = async () => { const result = await isVaultProtected(vaultData?.id) setIsProtected(result) } checkProtection() }, [vaultData?.id]) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && isPairing) { cancelPairActiveVault() } } window.addEventListener('keydown', handleKeyDown) return () => { window.removeEventListener('keydown', handleKeyDown) } }, [cancelPairActiveVault, isPairing]) if (isProtected) { return html`<${VaultPasswordFormModalContent} onSubmit=${async (password: string) => { if (await authoriseCurrentProtectedVault(password)) { setIsProtected(false) } }} vault=${vaultData} />` } const handleLoadVault = async (code: string) => { try { const vaultId = await pairActiveVault(code) if (!vaultId) { throw new Error('Vault ID is empty') } await refetchVault(vaultId) await addDevice(os.hostname() + ' ' + os.platform() + ' ' + os.release()) navigate('vault', { recordType: 'all' }) closeModal() } catch { setInviteCodeId('') setToast({ message: t('Something went wrong, please check invite code') }) } } const handleChange = (value: string) => { if (isPairing) { return } setInviteCodeId(value) } const processPastedText = (pastedText: string) => { if (pastedText) { setInviteCodeId(pastedText) setTimeout(() => { if (!isPairing) { handleLoadVault(pastedText) } }, 0) } } const handlePaste = (e: ClipboardEvent) => { const pastedText = e.clipboardData?.getData('text') processPastedText(pastedText) } const handlePasteClick = async () => { try { const pastedText = await navigator.clipboard.readText() processPastedText(pastedText) } catch { setToast({ message: t('Failed to paste from clipboard'), }) } } return html` <${ModalContent} onClose=${closeModal} headerChildren=${html` <${FormModalHeaderWrapper}> <${HeaderTitle}> <${UserSecurityIcon} /> ${t('Add a device')} `} > <${Content}> <${PairingDescription}> ${t( scanQRStep ? 'Scan this QR code or paste the vault key into the PearPass app on your other device to connect it to your account. This method keeps your account secure.' : 'Paste the vault key from the PearPass app on your other device to connect it to your account. This method keeps your account secure.' )} <${PairTabs}> <${PairTab} type="button" $active=${scanQRStep} onClick=${() => setScanQRStep(true)} > ${t('Share this vault')} <${PairTab} type="button" $active=${!scanQRStep} onClick=${() => setScanQRStep(false)} > ${t('Import vault')} ${scanQRStep ? html` <${QRCodeSection}> <${QRCodeText}> ${t('Scan this QR code while in the PearPass App')} <${QRCode} style=${{ width: '200px', height: '200px' }} dangerouslySetInnerHTML=${{ __html: qrSvg }} /> <${BackgroundSection}> <${ExpireText}> ${t('Expires in')} <${ScanQRExpireTimer} onFinish=${closeModal} /> <${TimeIcon} color=${colors.primary400.mode1} /> <${BackgroundSection} onClick=${() => { if (data?.publicKey) { copyToClipboard(data.publicKey) } else { setToast({ message: t('Invite code not found') }) } }} > <${QRCodeCopyWrapper}> <${QRCodeCopy}> <${QRCodeText}> ${t('Copy vault key')} <${CopyIcon} color=${colors.primary400.mode1} /> <${CopyText}> ${isCopied ? t('Copied!') : data?.publicKey || ''} <${AlertBox} message=${t( 'Keep this code private. Anyone with it can connect a device to your vault.' )} /> ` : html` <${InputFieldWrapper}> <${InputField} testId="add-device-input-code" label=${t('Vault key')} placeholder=${t('Insert vault key...')} variant="outline" onChange=${handleChange} value=${inviteCode} onPaste=${handlePaste} additionalItems=${html`<${PasteIconWrapper} onClick=${handlePasteClick} > <${PasteIcon} color=${colors.primary400.mode1} size="16" /> ${t('Paste')} `} /> ${isPairing && html` <${LoadVaultNotice}> ${t('Click Escape to cancel pairing')} `} `} ` } ================================================ FILE: src/containers/Modal/AddDeviceModalContent/styles.ts ================================================ import styled, { css } from 'styled-components' export const HeaderTitle = styled.div` display: flex; align-items: center; gap: 8px; color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-weight: 500; ` export const PairingDescription = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; text-align: center; margin-bottom: 10px; ` export const PairTabs = styled.div` display: flex; align-items: stretch; justify-content: center; gap: 4px; width: 100%; border: 2px solid ${({ theme }) => theme.colors.grey100.mode1}; border-radius: 10px; color: ${({ theme }) => theme.colors.primary400.mode1}; ` export const PairTab = styled.button<{ $active?: boolean }>` flex: 1; padding: 8px 14px; border: none; outline: none; cursor: pointer; font-family: 'Inter'; font-size: 14px; font-weight: 700; display: flex; align-items: center; justify-content: center; transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out, border-color 0.15s ease-in-out; background-color: transparent; border-radius: 7px; color: ${({ theme }) => theme.colors.primary400.mode1}; ${({ $active }) => $active && css` background-color: ${({ theme }) => theme.colors.primary400.mode1}; color: ${({ theme }) => theme.colors.black.dark}; `} ` export const Content = styled.div` display: flex; flex-direction: column; align-items: center; gap: 15px; ` export const QRCodeSection = styled.div` display: flex; flex-direction: column; gap: 8px; align-items: center; ` export const QRCodeText = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; font-weight: 500; ` export const QRCodeCopy = styled.div` display: flex; align-items: center; gap: 10px; ` export const QRCodeCopyWrapper = styled.div` max-width: 100%; display: flex; flex-direction: column; align-items: center; gap: 8px; ` export const QRCode = styled.div` width: 226px; height: 226px; padding: 15px; border-radius: 10px; background-color: ${({ theme }) => theme.colors.white.mode1}; ` export const BackgroundSection = styled.div` max-width: 100%; display: flex; padding: 7px 10px; justify-content: center; align-items: center; gap: 10px; border-radius: 10px; background-color: ${({ theme }) => theme.colors.grey400.mode1}; cursor: ${({ onClick }) => (onClick ? 'pointer' : 'default')}; ` export const ExpireText = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; font-weight: 500; ` export const ExpireTime = styled.span` color: ${({ theme }) => theme.colors.primary400.mode1}; ` export const CopyText = styled.div` color: ${({ theme }) => theme.colors.grey200.mode1}; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; font-family: 'Inter'; font-size: 16px; font-weight: 500; flex: 1; min-width: 0; width: 100%; ` export const WarningSection = styled.div` display: flex; padding: 10px; align-items: flex-start; gap: 8px; border-radius: 10px; border: 1px solid ${({ theme }) => theme.colors.errorYellow.mode1}; background: linear-gradient( 0deg, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.8) 100% ), ${({ theme }) => theme.colors.errorYellow.mode1}; ` export const WarningText = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; font-weight: 500; ` export const IconWrapper = styled.div` flex-shrink: 0; ` export const PasteIconWrapper = styled.div` display: flex; align-items: center; justify-content: center; border-radius: 10px; background-color: ${({ theme }) => theme.colors.black.dark}; color: ${({ theme }) => theme.colors.primary400.mode1}; padding: 9px 15px; cursor: pointer; gap: 7px; font-family: 'Inter'; font-size: 12px; ` export const InputFieldWrapper = styled.div` width: 100%; > div { border-top-left-radius: 10px; border-top-right-radius: 10px; } ` export const LoadVaultNotice = styled.div` white-space: nowrap; width: 100%; color: ${({ theme }) => theme.colors.white.mode1}; text-align: left; font-family: 'Inter'; font-size: 12px; font-style: normal; font-weight: 400; line-height: normal; ` ================================================ FILE: src/containers/Modal/AddDeviceModalContentV2/AddDeviceModalContentV2.styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = (colors: ThemeColors) => ({ divider: { width: '100%', height: 1, backgroundColor: colors.colorBorderPrimary, flexShrink: 0, border: 'none', padding: 0, margin: 0 }, body: { display: 'flex' as const, flexDirection: 'column' as const, gap: rawTokens.spacing12, boxSizing: 'border-box' as const }, accessPanel: { display: 'flex' as const, flexDirection: 'column' as const, alignItems: 'stretch' as const, borderWidth: 1, borderStyle: 'solid' as const, borderColor: colors.colorBorderPrimary, borderRadius: rawTokens.radius8, backgroundColor: colors.colorSurfacePrimary, boxSizing: 'border-box' as const }, qrWrap: { display: 'flex' as const, justifyContent: 'center' as const, alignItems: 'center' as const, marginTop: rawTokens.spacing24, marginBottom: rawTokens.spacing16, }, qrInner: { width: 160, height: 160, padding: rawTokens.spacing10, borderRadius: rawTokens.radius8, backgroundColor: '#FFFFFF', boxSizing: 'border-box' as const, display: 'flex' as const, alignItems: 'center' as const, justifyContent: 'center' as const, }, timerRow: { display: 'flex' as const, flexDirection: 'row' as const, alignItems: 'center' as const, justifyContent: 'center' as const, gap: rawTokens.spacing8, flexWrap: 'wrap' as const, marginBottom: rawTokens.spacing24, }, timerTextRow: { display: 'flex' as const, flexDirection: 'row' as const, alignItems: 'center' as const, flexWrap: 'wrap' as const, gap: rawTokens.spacing4 }, vaultLinkSection: { display: 'flex' as const, flexDirection: 'row' as const, alignItems: 'center' as const, gap: rawTokens.spacing4, width: '100%', minWidth: 0, padding: rawTokens.spacing12, boxSizing: 'border-box' as const }, vaultLinkTextColumn: { display: 'flex' as const, flexDirection: 'column' as const, alignItems: 'stretch' as const, gap: rawTokens.spacing8, flex: '1 1 auto' as const, minWidth: 0 }, vaultLinkValue: { display: 'block' as const, minWidth: 0, maxWidth: '100%', overflow: 'hidden' as const, textOverflow: 'ellipsis' as const, whiteSpace: 'nowrap' as const } }) ================================================ FILE: src/containers/Modal/AddDeviceModalContentV2/AddDeviceModalContentV2.tsx ================================================ import React, { useEffect, useState } from 'react' import { generateQRCodeSVG } from '@tetherto/pear-apps-utils-qr' import { AlertMessage, Button, Dialog, RingSpinner, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { ContentCopy } from '@tetherto/pearpass-lib-ui-kit/icons' import { useInvite, useVault } from '@tetherto/pearpass-lib-vault' import { createStyles } from './AddDeviceModalContentV2.styles' import { useModal } from '../../../context/ModalContext' import { useToast } from '../../../context/ToastContext' import { useAutoLockPreferences } from '../../../hooks/useAutoLockPreferences' import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard.electron' import { useTranslation } from '../../../hooks/useTranslation' import { ScanQRExpireTimer } from '../AddDeviceModalContent/ScanQRExpireTimer' export const AddDeviceModalContentV2 = () => { const { t } = useTranslation() const { setToast } = useToast() const { closeModal } = useModal() const { theme } = useTheme() const { colors } = theme const [qrSvg, setQrSvg] = useState('') const { createInvite, deleteInvite, data } = useInvite() const { data: vault } = useVault() const { setShouldBypassAutoLock } = useAutoLockPreferences() const { copyToClipboard, isCopied } = useCopyToClipboard() useEffect(() => { setShouldBypassAutoLock(true) return () => setShouldBypassAutoLock(false) }, [setShouldBypassAutoLock]) useEffect(() => { if (!data?.publicKey) { createInvite() } return () => { deleteInvite() } // `data?.publicKey` intentionally excluded: this is a mount/unmount // lifecycle - create once on open, delete once on close. }, [createInvite, deleteInvite]) useEffect(() => { if (data?.publicKey) { generateQRCodeSVG(data.publicKey, { type: 'svg', margin: 0 }).then( (value: string) => setQrSvg(value) ) } }, [data]) const styles = createStyles(colors) const handleCopyKey = () => { if (data?.publicKey) { copyToClipboard(data.publicKey) } else { setToast({ message: t('Invite code not found') }) } } const displayLink = isCopied ? t('Copied!') : (data?.publicKey ?? '') return (
{t('Access Code')}
{t('Code expires in')}
{t('Vault Link')}
{displayLink}
) } ================================================ FILE: src/containers/Modal/AuthenticationModalContentV2/index.tsx ================================================ import React, { useState } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { Button, Dialog, Form, PasswordField, Text } from '@tetherto/pearpass-lib-ui-kit' import { KeyboardArrowRightRound } from '@tetherto/pearpass-lib-ui-kit/icons' import { useUserData } from '@tetherto/pearpass-lib-vault' import { clearBuffer, stringToBuffer } from '@tetherto/pearpass-lib-vault/src/utils/buffer' import { useGlobalLoading } from '../../../context/LoadingContext' import { useModal } from '../../../context/ModalContext' import { useTranslation } from '../../../hooks/useTranslation' import { createStyles } from './styles' export type AuthenticationModalContentV2Props = { open?: boolean onClose?: () => void onSuccess?: (password: Uint8Array) => Promise onError?: ( error: unknown, setErrors: (errors: Record) => void ) => Promise } export const AuthenticationModalContentV2 = ({ open, onClose, onSuccess, onError }: AuthenticationModalContentV2Props) => { const styles = createStyles() const { t } = useTranslation() const { closeModal } = useModal() const handleClose = onClose ?? closeModal const [isLoading, setIsLoading] = useState(false) useGlobalLoading({ isLoading }) const schema = Validator.object({ password: Validator.string().required(t('Password is required')) }) const { logIn } = useUserData() const { register, handleSubmit, setErrors } = useForm({ initialValues: { password: '' }, validate: (values: { password: string }) => schema.validate(values) }) const onSubmit = async (values: { password: string }) => { if (isLoading) { return } if (!values.password) { setErrors({ password: t('Password is required') }) return } const passwordBuffer = stringToBuffer(values.password) try { setIsLoading(true) await logIn({ password: passwordBuffer }) await onSuccess?.(passwordBuffer) setIsLoading(false) } catch (error) { setIsLoading(false) if (onError) { await onError(error, setErrors) } else { setErrors({ password: t('Invalid password') }) } } finally { clearBuffer(passwordBuffer) } } const { onChange: onChangePassword, ...passwordFieldProps } = register('password') return ( } onClick={() => handleSubmit(onSubmit)()} data-testid="authentication-continue-v2" > {t('Continue')} } >
['style']} testID="authentication-form-v2" > {t('Use your Master Password to authorize this action.')} onChangePassword(e.target.value)} error={passwordFieldProps.error || undefined} testID="authentication-password-v2" />
) } ================================================ FILE: src/containers/Modal/AuthenticationModalContentV2/styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ container: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing16}px`, width: '100%' } }) ================================================ FILE: src/containers/Modal/BlindPeersModalContent/index.js ================================================ import React, { useEffect, useState } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { BLIND_PEER_FORM_NAME, BLIND_PEERS_FORM_NAME, BLIND_PEER_TYPE } from '@tetherto/pearpass-lib-constants' import { useBlindMirrors } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { ActionsContainer, ContentWrapper, FormWrapper, HeaderWrapper } from './styles' import { RadioSelect } from '../../../components/RadioSelect' import { useTranslation } from '../../../hooks/useTranslation' import { ButtonPrimary, ButtonSecondary, ButtonSingleInput, CompoundField, DeleteIcon, InputField, PlusIcon } from '../../../lib-react-components' import { ModalContent } from '../ModalContent' const { DEFAULT, PERSONAL } = BLIND_PEER_TYPE /** * @component * @param {Object} props * @param {(data: { blindPeerType: 'default' | 'personal', blindPeers?: string[] }) => void} props.onConfirm * @param {() => void} props.onClose */ export const BlindPeersModalContent = ({ onConfirm, onClose }) => { const { t } = useTranslation() const { data: blindMirrorsData } = useBlindMirrors() const isEditMode = blindMirrorsData.length > 0 const [selectedOption, setSelectedOption] = useState(DEFAULT) const getInitialValues = () => { const manualPeers = blindMirrorsData.filter((item) => !item.isDefault) if (manualPeers.length > 0) { return { blindPeers: manualPeers.map((item) => ({ name: BLIND_PEER_FORM_NAME, blindPeer: item.key })) } } return { blindPeers: [ { name: BLIND_PEER_FORM_NAME, blindPeer: '' } ] } } const { registerArray } = useForm({ initialValues: getInitialValues() }) const { value: blindPeersList, addItem, registerItem, removeItem } = registerArray(BLIND_PEERS_FORM_NAME) useEffect(() => { if (isEditMode && blindMirrorsData.length > 0) { setSelectedOption(blindMirrorsData[0].isDefault ? DEFAULT : PERSONAL) } }, [isEditMode, blindMirrorsData.length]) const radioOptions = [ { label: t('Automatic blind peers'), value: DEFAULT }, { label: t('Manual blind peers'), value: PERSONAL } ] const handleOptionChange = (option) => { setSelectedOption(option) } const handleBlindPeersConfirm = async () => { if (selectedOption === DEFAULT) { onConfirm({ blindPeerType: DEFAULT, isEditMode }) } else if (selectedOption === PERSONAL) { const blindPeers = blindPeersList .map((peer) => peer.blindPeer?.trim()) .filter((peer) => peer && peer.length > 0) if (blindPeers.length === 0) { return } onConfirm({ blindPeerType: PERSONAL, blindPeers, isEditMode }) } } return html` <${ModalContent} onClose=${onClose} headerChildren=${html` <${HeaderWrapper}> ${t('Choose your Blind Peer')} `} > <${ContentWrapper}> <${RadioSelect} options=${radioOptions} selectedOption=${selectedOption} onChange=${handleOptionChange} /> <${FormWrapper} isOpen=${selectedOption === PERSONAL}> <${CompoundField}> ${blindPeersList.map( (blindPeer, index) => html` <${React.Fragment} key=${blindPeer.id}> <${InputField} label=${'#' + (index + 1) + ' ' + t('Blind Peer')} placeholder=${t('Add here your code...')} isFirst=${index === 0} ...${registerItem(BLIND_PEER_FORM_NAME, index)} additionalItems=${index === 0 ? html` <${ButtonSingleInput} startIcon=${PlusIcon} onClick=${() => addItem({ name: 'website' })} > ${t('Add Peer')} ` : html` <${ButtonSingleInput} startIcon=${DeleteIcon} onClick=${() => removeItem(index)} > ${t('Remove Peer')} `} /> ` )} <${ActionsContainer}> <${ButtonPrimary} onClick=${handleBlindPeersConfirm}> ${t('Confirm')} <${ButtonSecondary} onClick=${onClose}> ${t('Cancel')} ` } ================================================ FILE: src/containers/Modal/BlindPeersModalContent/styles.js ================================================ import styled from 'styled-components' export const HeaderWrapper = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 20px; font-weight: 700; ` export const ContentWrapper = styled.div` display: flex; flex-direction: column; gap: 15px; ` export const FormWrapper = styled.div` max-height: 400px; overflow-y: auto; pointer-events: ${({ isOpen }) => (isOpen ? 'auto' : 'none')}; max-height: ${({ isOpen }) => (isOpen ? '300px' : '0')}; transition: max-height 0.5s ease; ` export const ActionsContainer = styled.div` display: flex; justify-content: space-between; align-items: center; ` ================================================ FILE: src/containers/Modal/BrowserExtensionDialogV2/index.tsx ================================================ import React from 'react' import { Button, Dialog, Text, Title } from '@tetherto/pearpass-lib-ui-kit' import { OpenInNew } from '@tetherto/pearpass-lib-ui-kit/icons' import { useTheme } from '@tetherto/pearpass-lib-ui-kit' import { createStyles } from './styles' import { LOCAL_STORAGE_KEYS } from '../../../constants/localStorage' import { CHROME_EXTENSION_STORE_LINK } from '../../../constants/pearpassLinks' import { useModal } from '../../../context/ModalContext' import { useTranslation } from '../../../hooks/useTranslation' export const BrowserExtensionDialogV2 = () => { const { t } = useTranslation() const { closeModal } = useModal() const { theme } = useTheme() const styles = createStyles(theme.colors) const handleDismiss = () => { localStorage.setItem(LOCAL_STORAGE_KEYS.EXTENSION_DIALOG_DISMISSED, 'true') closeModal() } const handleDownload = () => { localStorage.setItem(LOCAL_STORAGE_KEYS.EXTENSION_DIALOG_DISMISSED, 'true') window.electronAPI?.openExternal(CHROME_EXTENSION_STORE_LINK) closeModal() } return ( } >
{/* @ts-ignore */} {t("You've got the app.")}{'\n'} {t('Now unlock the full experience.')} {/* @ts-ignore */} {t('Install the browser extension to autofill passwords, save new logins with one click, and sign in instantly —')}{'\n'} {t('right where you browse.')}{'\n\n'} {t('No copy-paste. No interruptions. Just seamless security.')}
) } ================================================ FILE: src/containers/Modal/BrowserExtensionDialogV2/styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = (colors: ThemeColors) => ({ body: { display: 'flex' as const, flexDirection: 'column' as const, alignItems: 'flex-start', padding: rawTokens.spacing8, gap: rawTokens.spacing32, position: 'relative' as const }, browserMockup: { width: '100%', borderRadius: rawTokens.radius8, height: '80px', objectFit: 'cover' as const, userSelect: 'none' as const, pointerEvents: 'none' as const }, textContent: { display: 'flex' as const, flexDirection: 'column' as const, gap: rawTokens.spacing12, width: '100%', textAlign: 'center' as const, lineHeight: 'normal' }, heading: { whiteSpace: 'pre-wrap' as const }, description: { color: colors.colorTextSecondary, whiteSpace: 'pre-wrap' as const }, }) ================================================ FILE: src/containers/Modal/ConfirmationModalContent/index.js ================================================ import { useLingui } from '@lingui/react' import { html } from 'htm/react' import { ButtonWrapper, HeaderWrapper, TextWrapper } from './styles' import { useModal } from '../../../context/ModalContext' import { ButtonPrimary, ButtonSecondary } from '../../../lib-react-components' import { ModalContent } from '../ModalContent' /** * @param {{ * title: string * text: string * primaryAction: () => void * secondaryAction: () => void * primaryLabel: string * secondaryLabel: string * }} props */ export const ConfirmationModalContent = (props) => { const { i18n } = useLingui() const { title = i18n._('Are you sure?'), text = i18n._('Are you sure you want to proceed?'), primaryAction, secondaryAction, primaryLabel = i18n._('Confirm'), secondaryLabel = i18n._('Cancel') } = props const { closeModal } = useModal() return html` <${ModalContent} onClose=${closeModal} headerChildren=${html` <${HeaderWrapper}> ${title} `} > <${TextWrapper}> ${text} <${ButtonWrapper}> <${ButtonPrimary} data-testid="confirmation-button-confirm" onClick=${primaryAction} > ${primaryLabel} <${ButtonSecondary} data-testid="confirmation-button-cancel" onClick=${secondaryAction} > ${secondaryLabel} ` } ================================================ FILE: src/containers/Modal/ConfirmationModalContent/styles.js ================================================ import styled from 'styled-components' export const HeaderWrapper = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-weight: 400; ` export const TextWrapper = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-weight: 400; padding-top: 5px; ` export const ButtonWrapper = styled.div` display: flex; align-items: center; padding-top: 20px; gap: 15px; ` ================================================ FILE: src/containers/Modal/CreateFileEncryptionPassword/index.tsx ================================================ import { html } from 'htm/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { FormModalHeaderWrapper } from '../../../components/FormModalHeaderWrapper' import { useLoadingContext } from '../../../context/LoadingContext' import { useModal } from '../../../context/ModalContext' import { useTranslation } from '../../../hooks/useTranslation' import { ButtonPrimary, PearPassPasswordField } from '../../../lib-react-components' import { logger } from '../../../utils/logger' import { ModalContent } from '../ModalContent' import { Description, Header, Title, UnlockVaultContainer } from './styles' interface props { onSubmit: (password: string) => Promise } /** * * @param {Object} props * @param {(password: string) => Promise} props.onSubmit */ export const CreateFileEncryptionPassword = ({ onSubmit }: props) => { const { t } = useTranslation() const { closeModal } = useModal() const { setIsLoading } = useLoadingContext() const schema = Validator.object({ password: Validator.string().required(t('Password is required')), confirmPassword: Validator.string().required( t('Confirm password is required') ) }) const { register, handleSubmit, setErrors } = useForm({ initialValues: { password: '', confirmPassword: '' }, validate: (values: { password: string; confirmPassword: string }) => schema.validate(values) }) const submit = async (values: { password: string confirmPassword: string }) => { try { if (values.password !== values.confirmPassword) { setErrors({ confirmPassword: t('Passwords do not match') }) return } setIsLoading(true) await onSubmit?.(values.password) setIsLoading(false) } catch (error) { logger.error('CreateFileEncryptionPassword', error) setIsLoading(false) setErrors({ password: t('Invalid password') }) } } return html` <${ModalContent} onClose=${closeModal} headerChildren=${html` <${FormModalHeaderWrapper}> <${Header}> <${Title}> ${t('Are you sure to encrypt your Vault?')} <${Description}> ${t('This will create a password for your exported file.')} `} > <${UnlockVaultContainer} onSubmit=${handleSubmit(submit)}> <${PearPassPasswordField} placeholder=${t('Set file password')} ...${register('password')} /> <${PearPassPasswordField} placeholder=${t('Repeat file password')} ...${register('confirmPassword')} /> <${ButtonPrimary} type="submit"> ${t('Export')} ` } ================================================ FILE: src/containers/Modal/CreateFileEncryptionPassword/styles.ts ================================================ import styled from 'styled-components' export const Header = styled.div` display: flex; flex-direction: column; align-items: flex-start; gap: 10px; ` export const Title = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-style: normal; font-weight: 400; line-height: normal; ` export const Description = styled.span` color: ${({ theme }) => theme.colors.grey100.mode1}; font-family: 'Inter'; font-size: 12px; font-style: normal; font-weight: 400; line-height: normal; ` export const VaultsContainer = styled.div` display: flex; flex-direction: column; align-items: center; gap: 15px; ` export const UnlockVaultContainer = styled.form` display: flex; flex-direction: column; align-items: self-start; gap: 20px; ` ================================================ FILE: src/containers/Modal/CreateFolderModalContent/index.js ================================================ import React from 'react' import { useLingui } from '@lingui/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { useCreateFolder, useFolders } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { HeaderWrapper } from './styles' import { useGlobalLoading } from '../../../context/LoadingContext' import { useModal } from '../../../context/ModalContext' import { InputField, ButtonLittle, SaveIcon } from '../../../lib-react-components' import { ModalContent } from '../ModalContent' /** * @param {{ * onCreate: (folderName: string) => void * initialValues: {title: string} * }} props */ export const CreateFolderModalContent = ({ onCreate, initialValues }) => { const { i18n } = useLingui() const { closeModal } = useModal() const { renameFolder } = useFolders() const { isLoading, createFolder } = useCreateFolder({ onCompleted: (folderData) => { onCreate?.(folderData) closeModal() } }) useGlobalLoading({ isLoading }) const { data } = useFolders() const customFolders = Object.values(data?.customFolders ?? {}) const schema = Validator.object({ title: Validator.string() .required(i18n._('Title is required')) .refine((value) => { const isDuplicate = customFolders.some( (folder) => folder.name === value ) if (isDuplicate) { return i18n._('Folder already exists') } return null }) }) const { register, handleSubmit } = useForm({ initialValues: { title: initialValues?.title ?? '' }, validate: (values) => schema.validate(values) }) const onSubmit = async (values) => { if (initialValues) { await renameFolder(initialValues.title, values.title) closeModal() } else { createFolder(values.title) } } return html` <${React.Fragment}> <${ModalContent} onSubmit=${handleSubmit(onSubmit)} onClose=${closeModal} headerChildren=${html` <${HeaderWrapper}> <${ButtonLittle} data-testid="createfolder-button-submit" startIcon=${SaveIcon} type="submit" > ${!!initialValues ? i18n._('Save') : i18n._('Create folder')} `} > <${InputField} data-testid="input-folder-name" label=${i18n._('Title')} placeholder=${i18n._('Insert folder name')} variant="outline" autoFocus ...${register('title')} /> ` } ================================================ FILE: src/containers/Modal/CreateFolderModalContent/styles.js ================================================ import styled from 'styled-components' export const HeaderWrapper = styled.div` display: flex; justify-content: flex-end; ` ================================================ FILE: src/containers/Modal/CreateFolderModalContentV2/CreateFolderModalContentV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ form: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing16}px`, width: '100%' } }) ================================================ FILE: src/containers/Modal/CreateFolderModalContentV2/CreateFolderModalContentV2.tsx ================================================ import React, { useState } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { Button, Dialog, Form, InputField } from '@tetherto/pearpass-lib-ui-kit' import { useCreateFolder, useFolders } from '@tetherto/pearpass-lib-vault' import { createStyles } from './CreateFolderModalContentV2.styles' import { useGlobalLoading } from '../../../context/LoadingContext' import { useTranslation } from '../../../hooks/useTranslation' export interface CreateFolderModalContentV2Props { onClose: () => void onCreate?: (folderName: string) => void onRename?: (newFolderName: string, previousFolderName: string) => void initialValues?: { title: string } } export const CreateFolderModalContentV2 = ({ onClose, onCreate, onRename, initialValues }: CreateFolderModalContentV2Props) => { const { t } = useTranslation() const styles = createStyles() const isRename = !!initialValues const { renameFolder, data } = useFolders() const customFolders = Object.values(data?.customFolders ?? {}) const [isRenameLoading, setIsRenameLoading] = useState(false) const { isLoading: isCreateLoading, createFolder } = useCreateFolder({ onCompleted: (folderData: { folder: string }) => { onCreate?.(folderData.folder) onClose() } }) const isLoading = isRename ? isRenameLoading : isCreateLoading useGlobalLoading({ isLoading }) const schema = Validator.object({ title: Validator.string() .required(t('Title is required')) .refine((value: string) => { if (isRename && value === initialValues?.title) { return null } const isDuplicate = (customFolders as { name: string }[]).some( (folder: { name: string }) => folder.name === value ) if (isDuplicate) { return t('Folder already exists') } return null }) }) const { register, handleSubmit, values } = useForm({ initialValues: { title: initialValues?.title ?? '' }, validate: (formValues: { title: string }) => schema.validate(formValues) }) const onSubmit = async (formValues: { title: string }) => { if (isLoading) return if (isRename) { try { setIsRenameLoading(true) await renameFolder(initialValues.title, formValues.title) onRename?.(formValues.title, initialValues.title) onClose() } finally { setIsRenameLoading(false) } } else { createFolder(formValues.title) } } const isSaveDisabled = !values?.title?.trim() || isLoading const titleField = register('title') return ( } >
['style']} testID="createfolder-form-v2" > ) => titleField.onChange(e.target.value) } error={titleField.error || undefined} testID="createfolder-name-v2" />
) } ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditAuthenticatorModalContent/CreateOrEditAuthenticatorModalContent.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ form: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, width: '100%' }, sectionLabel: { marginTop: `${rawTokens.spacing8}px` } }) ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditAuthenticatorModalContent/CreateOrEditAuthenticatorModalContent.tsx ================================================ import React from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { AttachmentField as UiKitAttachmentField, Button, Dialog, Form, InputField, MultiSlotInput, PasswordField, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { useCreateRecord, useRecords } from '@tetherto/pearpass-lib-vault' import { Add, TrashOutlined, UploadFileFilled } from '@tetherto/pearpass-lib-ui-kit/icons' import { html } from 'htm/react' import { createStyles } from './CreateOrEditAuthenticatorModalContent.styles' import { ATTACHMENTS_FIELD_KEY } from '../../../../constants/formFields' import { useGlobalLoading } from '../../../../context/LoadingContext' import { useModal } from '../../../../context/ModalContext' import { useToast } from '../../../../context/ToastContext' import { useTranslation } from '../../../../hooks/useTranslation' import { useGetMultipleFiles } from '../../../../hooks/useGetMultipleFiles' import { getFilteredAttachmentsById } from '../../../../utils/getFilteredAttachmentsById' import { handleFileSelect } from '../../../../utils/handleFileSelect' import { UploadFilesModalContentV2 } from '../../UploadFilesModalContentV2' export type CreateOrEditAuthenticatorModalContentProps = { initialRecord?: { data: { title: string note: string attachments: { id: string; name: string }[] [key: string]: unknown } folder?: string isFavorite?: boolean attachments?: { id: string; name: string }[] [key: string]: unknown } selectedFolder?: string isFavorite?: boolean onTypeChange?: (type: string) => void } export const CreateOrEditAuthenticatorModalContent = ({ initialRecord, selectedFolder, isFavorite }: CreateOrEditAuthenticatorModalContentProps) => { const { t } = useTranslation() const { closeModal, setModal } = useModal() const { theme } = useTheme() const styles = createStyles() const { setToast } = useToast() const { createRecord, isLoading: isCreateLoading } = useCreateRecord({ onCompleted: () => { closeModal() setToast({ message: t('Record created successfully') }) } }) const { updateRecords, isLoading: isUpdateLoading } = useRecords({ onCompleted: () => { closeModal() setToast({ message: t('Record updated successfully') }) } }) const onError = (error: { message: string }) => { setToast({ message: error.message }) } const isLoading = isCreateLoading || isUpdateLoading useGlobalLoading({ isLoading }) const schema = Validator.object({ title: Validator.string().required(t('Title is required')), otpSecret: Validator.string(), note: Validator.string(), attachments: Validator.array().items( Validator.object({ id: Validator.string(), name: Validator.string().required() }) ) }) const { register, handleSubmit, values, setValue } = useForm({ initialValues: { title: initialRecord?.data?.title ?? '', otpSecret: initialRecord?.data?.otpInput ?? (initialRecord?.data?.otp as { secret?: string } | undefined)?.secret ?? '', note: initialRecord?.data?.note ?? '', attachments: initialRecord?.attachments ?? [] }, validate: (formValues: Record) => schema.validate(formValues) }) useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) const onSubmit = (formValues: Record) => { const otpInput = (formValues.otpSecret as string)?.trim() || undefined const data = { type: RECORD_TYPES.LOGIN, folder: selectedFolder ?? initialRecord?.folder, isFavorite: initialRecord?.isFavorite ?? isFavorite, data: { ...(initialRecord?.data ? initialRecord.data : {}), title: formValues.title, note: formValues.note, attachments: formValues.attachments, otpInput } } if (initialRecord) { updateRecords([{ ...initialRecord, ...data }], onError) } else { createRecord(data, onError) } } const handleFileLoad = () => { setModal( html`<${UploadFilesModalContentV2} type=${'file'} onFilesSelected=${(files: File[]) => handleFileSelect({ files: files as unknown as FileList, fieldName: ATTACHMENTS_FIELD_KEY, setValue, values })} />` ) } const isEdit = !!initialRecord const titleField = register('title') const otpSecretField = register('otpSecret') const noteField = register('note') return ( } >
['style']} testID='createoredit-authenticator-form' > titleField.onChange(e.target.value)} error={titleField.error || undefined} testID='createoredit-authenticator-input-title' /> otpSecretField.onChange(e.target.value)} error={otpSecretField.error || undefined} testID='createoredit-authenticator-input-otpsecret' />
{t('Additional')}
noteField.onChange(e.target.value)} error={noteField.error || undefined} testID='createoredit-authenticator-input-comment' /> } onClick={handleFileLoad} data-testid='createoredit-authenticator-button-addattachment' > {t('Add Another Attachment')} } > {values.attachments.length > 0 ? values.attachments.map( ( attachment: { id?: string tempId?: string name: string }, index: number ) => ( } onClick={() => setValue( ATTACHMENTS_FIELD_KEY, getFilteredAttachmentsById( values.attachments, attachment ) ) } data-testid={`createoredit-authenticator-button-deleteattachment-${index}`} /> } /> ) ) : null} } />
) } ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditCreditCardModalContent/index.js ================================================ import { useLingui } from '@lingui/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { RECORD_TYPES, useCreateRecord, useRecords } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { CreateCustomField } from '../../../../components/CreateCustomField' import { FolderDropdown } from '../../../../components/FolderDropdown' import { FormGroup } from '../../../../components/FormGroup' import { FormModalHeaderWrapper } from '../../../../components/FormModalHeaderWrapper' import { FormWrapper } from '../../../../components/FormWrapper' import { InputFieldNote } from '../../../../components/InputFieldNote' import { RecordTypeMenu } from '../../../../components/RecordTypeMenu' import { ATTACHMENTS_FIELD_KEY } from '../../../../constants/formFields' import { useGlobalLoading } from '../../../../context/LoadingContext' import { useModal } from '../../../../context/ModalContext' import { useToast } from '../../../../context/ToastContext' import { useGetMultipleFiles } from '../../../../hooks/useGetMultipleFiles' import { ButtonLittle, ButtonSingleInput, CalendarIcon, CreditCardIcon, DeleteIcon, ImageIcon, InputField, NineDotsIcon, PasswordField, SaveIcon, UserIcon } from '../../../../lib-react-components' import { getFilteredAttachmentsById } from '../../../../utils/getFilteredAttachmentsById' import { handleFileSelect } from '../../../../utils/handleFileSelect' import { AttachmentField } from '../../../AttachmentField' import { CustomFields } from '../../../CustomFields' import { ModalContent } from '../../ModalContent' import { DropdownsWrapper } from '../../styles' import { UploadFilesModalContent } from '../../UploadImageModalContent' /** * @param {{ * initialRecord: { * data: { * title: string * name: string * number: string * expireDate: string * securityCode: string * pinCode: string * note: string * customFields: { * type: string * name: string * }[] * attachments: { id: string, name: string}[] * } * } * selectedFolder?: string * isFavorite?: boolean * onTypeChange: (type: string) => void * }} props */ export const CreateOrEditCreditCardModalContent = ({ initialRecord, selectedFolder, isFavorite, onTypeChange }) => { const { i18n } = useLingui() const { closeModal, setModal } = useModal() const { setToast } = useToast() const { createRecord, isLoading: isCreateLoading } = useCreateRecord({ onCompleted: () => { closeModal() setToast({ message: i18n._('Record created successfully') }) } }) const onError = (error) => { setToast({ message: error.message }) } const { updateRecords, isLoading: isUpdateLoading } = useRecords({ onCompleted: () => { closeModal() setToast({ message: i18n._('Record updated successfully') }) } }) const isLoading = isCreateLoading || isUpdateLoading useGlobalLoading({ isLoading }) const schema = Validator.object({ title: Validator.string().required(i18n._('Title is required')), name: Validator.string(), number: Validator.string().numeric( i18n._('Number on card must be a number') ), expireDate: Validator.string(), securityCode: Validator.string().numeric(i18n._('Note must be a string')), pinCode: Validator.string().numeric(i18n._('Pin code must be a number')), note: Validator.string(), customFields: Validator.array().items( Validator.object({ note: Validator.string().required(i18n._('Comment is required')) }) ), folder: Validator.string(), attachments: Validator.array().items( Validator.object({ id: Validator.string(), name: Validator.string().required(), buffer: Validator.object({}) }) ) }) const { values, register, handleSubmit, registerArray, setValue } = useForm({ initialValues: { title: initialRecord?.data?.title ?? '', name: initialRecord?.data?.name ?? '', number: initialRecord?.data?.number ?? '', expireDate: initialRecord?.data?.expireDate ?? '', securityCode: initialRecord?.data?.securityCode ?? '', pinCode: initialRecord?.data?.pinCode ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [] }, validate: (values) => schema.validate(values) }) useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) const { value: list, addItem, registerItem, removeItem } = registerArray('customFields') const onSubmit = (values) => { const data = { type: RECORD_TYPES.CREDIT_CARD, folder: values.folder, isFavorite: initialRecord?.isFavorite ?? isFavorite, data: { title: values.title, name: values.name, number: values.number, expireDate: values.expireDate, securityCode: values.securityCode, pinCode: values.pinCode, note: values.note, customFields: values.customFields, attachments: values.attachments } } if (initialRecord) { updateRecords([{ ...initialRecord, ...data }], onError) } else { createRecord(data, onError) } } const handleRecordTypeChange = (item) => { onTypeChange(item) } const handleExpireDateChange = (inputValue) => { let value = inputValue.replace(/\D/g, '') if (value.length > 4) { value = value.slice(0, 4) } if (value.length > 2) { value = `${value.slice(0, 2)} ${value.slice(2)}` } setValue('expireDate', value) } const handleFileLoad = () => { setModal( html`<${UploadFilesModalContent} type=${'file'} onFilesSelected=${(files) => handleFileSelect({ files, fieldName: ATTACHMENTS_FIELD_KEY, setValue, values })} />` ) } const handleNumericInputChange = (value, field) => { setValue(field, value.replace(/\D/g, '')) } return html` <${ModalContent} onClose=${closeModal} onSubmit=${handleSubmit(onSubmit)} headerChildren=${html` <${FormModalHeaderWrapper} buttons=${html` <${ButtonLittle} testId="createoredit-button-loadfile" startIcon=${ImageIcon} onClick=${handleFileLoad} > ${i18n._('Load file')} <${ButtonLittle} testId="createoredit-button-save" startIcon=${SaveIcon} type="submit" > ${i18n._('Save')} `} > <${DropdownsWrapper}> <${FolderDropdown} testId="createoredit-dropdown-folder" selectedFolder=${values?.folder} onFolderSelect=${(folder) => setValue('folder', folder?.name)} /> ${!initialRecord && html` <${RecordTypeMenu} testId="createoredit-dropdown-recordtype" selectedRecord=${RECORD_TYPES.CREDIT_CARD} onRecordSelect=${(record) => handleRecordTypeChange(record?.type)} />`} `} > <${FormWrapper}> <${FormGroup}> <${InputField} testId="createoredit-input-title" label=${i18n._('Title')} placeholder=${i18n._('Insert title')} variant="outline" ...${register('title')} /> <${FormGroup}> <${InputField} testId="createoredit-input-fullname" label=${i18n._('Full name')} placeholder=${i18n._('Full name')} variant="outline" icon=${UserIcon} ...${register('name')} /> <${InputField} testId="createoredit-input-number" label=${i18n._('Number on card')} placeholder="1234 1234 1234 1234 " variant="outline" icon=${CreditCardIcon} ...${register('number')} value=${values.number.replace(/(.{4})/g, '$1 ').trim()} onChange=${(value) => handleNumericInputChange(value, 'number')} /> <${InputField} testId="createoredit-input-expiredate" label=${i18n._('Date of expire')} placeholder="MM YY" variant="outline" icon=${CalendarIcon} value=${values.expireDate} onChange=${handleExpireDateChange} /> <${PasswordField} testId="createoredit-input-securitycode" label=${i18n._('Security code')} placeholder="123" variant="outline" icon=${CreditCardIcon} ...${register('securityCode')} onChange=${(value) => handleNumericInputChange(value, 'securityCode')} /> <${PasswordField} testId="createoredit-input-pincode" label=${i18n._('Pin code')} placeholder="1234" variant="outline" icon=${NineDotsIcon} ...${register('pinCode')} onChange=${(value) => handleNumericInputChange(value, 'pinCode')} /> ${values.attachments.length > 0 && html` <${FormGroup}> ${values.attachments.map( (attachment) => html`<${AttachmentField} testId="createoredit-attachment" key=${attachment.id || attachment.tempId} attachment=${attachment} label=${i18n._('File')} additionalItems=${html` <${ButtonSingleInput} testId="createoredit-button-deleteattachment" startIcon=${DeleteIcon} onClick=${() => setValue( ATTACHMENTS_FIELD_KEY, getFilteredAttachmentsById( values.attachments, attachment ) )} > ${i18n._('Delete File')} `} />` )} `} <${FormGroup}> <${InputFieldNote} testId="createoredit-input-note" ...${register('note')} /> <${CustomFields} customFields=${list} register=${registerItem} removeItem=${removeItem} /> <${FormGroup}> <${CreateCustomField} testId="createoredit-button-createcustom" onCreateCustom=${(type) => addItem({ type: type, name: type })} /> ` } ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditCreditCardModalContentV2/CreateOrEditCreditCardModalContentV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ form: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, width: '100%' }, sectionLabel: { marginTop: `${rawTokens.spacing8}px` } }) ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditCreditCardModalContentV2/CreateOrEditCreditCardModalContentV2.tsx ================================================ import React from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { AttachmentField as UiKitAttachmentField, Button, DateField, Dialog, Form, InputField, MultiSlotInput, PasswordField, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { useCreateRecord, useRecords } from '@tetherto/pearpass-lib-vault' import { Add, TrashOutlined, UploadFileFilled } from '@tetherto/pearpass-lib-ui-kit/icons' import { html } from 'htm/react' import { createStyles } from './CreateOrEditCreditCardModalContentV2.styles' import { ATTACHMENTS_FIELD_KEY } from '../../../../constants/formFields' import { useGlobalLoading } from '../../../../context/LoadingContext' import { useModal } from '../../../../context/ModalContext' import { useToast } from '../../../../context/ToastContext' import { useTranslation } from '../../../../hooks/useTranslation' import { useGetMultipleFiles } from '../../../../hooks/useGetMultipleFiles' import { getFilteredAttachmentsById } from '../../../../utils/getFilteredAttachmentsById' import { handleFileSelect } from '../../../../utils/handleFileSelect' import { UploadFilesModalContentV2 } from '../../UploadFilesModalContentV2' import { FolderDropdownV2 } from '../../../../components/FolderDropdown/FolderDropdownV2' export type CreateOrEditCreditCardModalContentV2Props = { initialRecord?: { data: { title: string name: string number: string expireDate: string securityCode: string pinCode: string note: string customFields: { type: string; name: string }[] attachments: { id: string; name: string }[] [key: string]: unknown } folder?: string isFavorite?: boolean attachments?: { id: string; name: string }[] [key: string]: unknown } selectedFolder?: string isFavorite?: boolean onTypeChange: (type: string) => void } const formatCardNumber = (raw: string): string => { const digits = raw.replace(/\D/g, '').slice(0, 16) return digits.match(/.{1,4}/g)?.join(' ') ?? digits } const formatExpireDate = (raw: string): string => { const digits = raw.replace(/\D/g, '').slice(0, 4) return digits.length > 2 ? `${digits.slice(0, 2)} ${digits.slice(2)}` : digits } export const CreateOrEditCreditCardModalContentV2 = ({ initialRecord, selectedFolder, isFavorite, onTypeChange: _onTypeChange }: CreateOrEditCreditCardModalContentV2Props) => { const { t } = useTranslation() const { closeModal, setModal } = useModal() const { setToast } = useToast() const { theme } = useTheme() const styles = createStyles() const { createRecord, isLoading: isCreateLoading } = useCreateRecord({ onCompleted: () => { closeModal() setToast({ message: t('Record created successfully') }) } }) const { updateRecords, isLoading: isUpdateLoading } = useRecords({ onCompleted: () => { closeModal() setToast({ message: t('Record updated successfully') }) } }) const onError = (error: { message: string }) => { setToast({ message: error.message }) } const isLoading = isCreateLoading || isUpdateLoading useGlobalLoading({ isLoading }) const schema = Validator.object({ title: Validator.string().required(t('Title is required')), name: Validator.string(), number: Validator.string(), expireDate: Validator.string(), securityCode: Validator.string().numeric(t('Should contain only numbers')), pinCode: Validator.string().numeric(t('Should contain only numbers')), note: Validator.string(), customFields: Validator.array().items( Validator.object({ note: Validator.string() }) ), folder: Validator.string(), attachments: Validator.array().items( Validator.object({ id: Validator.string(), name: Validator.string().required() }) ) }) const { register, handleSubmit, registerArray, values, setValue } = useForm({ initialValues: { title: initialRecord?.data?.title ?? '', name: initialRecord?.data?.name ?? '', number: initialRecord?.data?.number ?? '', expireDate: initialRecord?.data?.expireDate ?? '', securityCode: initialRecord?.data?.securityCode ?? '', pinCode: initialRecord?.data?.pinCode ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields?.length ? initialRecord.data.customFields : [{ type: 'note', name: 'note', note: '' }], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [] }, validate: (formValues: Record) => schema.validate(formValues) }) const { value: customFieldsList, addItem: addCustomField, registerItem: registerCustomFieldItem, removeItem: removeCustomFieldItem } = registerArray('customFields') useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) const onSubmit = (formValues: Record) => { const data = { type: RECORD_TYPES.CREDIT_CARD, folder: formValues.folder, isFavorite: initialRecord?.isFavorite ?? isFavorite, data: { ...(initialRecord?.data ? initialRecord.data : {}), title: formValues.title, name: formValues.name, number: formValues.number, expireDate: formValues.expireDate, securityCode: formValues.securityCode, pinCode: formValues.pinCode, note: formValues.note, customFields: ( (formValues.customFields as Array<{ type: string; note?: string }>) ?? [] ).filter((f) => f.note?.trim().length), attachments: formValues.attachments } } if (initialRecord) { updateRecords([{ ...initialRecord, ...data }], onError) } else { createRecord(data, onError) } } const handleFileLoad = () => { setModal( html`<${UploadFilesModalContentV2} type=${'file'} onFilesSelected=${(files: File[]) => handleFileSelect({ files: files as unknown as FileList, fieldName: ATTACHMENTS_FIELD_KEY, setValue, values })} />` ) } const isEdit = !!initialRecord const titleField = register('title') const nameField = register('name') const securityCodeField = register('securityCode') const pinCodeField = register('pinCode') const noteField = register('note') return ( } >
['style']} testID="createoredit-creditcard-form-v2" > titleField.onChange(e.target.value)} error={titleField.error || undefined} testID="createoredit-creditcard-input-title-v2" />
{t('Details')}
nameField.onChange(e.target.value)} error={nameField.error || undefined} testID="createoredit-creditcard-input-name-v2" /> setValue('number', formatCardNumber(e.target.value))} testID="createoredit-creditcard-input-number-v2" /> setValue('expireDate', formatExpireDate(e.target.value))} pickerMode="month-year" testID="createoredit-creditcard-input-expiredate-v2" /> securityCodeField.onChange(e.target.value.replace(/\D/g, '')) } error={securityCodeField.error || undefined} testID="createoredit-creditcard-input-securitycode-v2" /> pinCodeField.onChange(e.target.value.replace(/\D/g, '')) } error={pinCodeField.error || undefined} testID="createoredit-creditcard-input-pincode-v2" />
{t('Additional')}
setValue('folder', name === values.folder ? '' : name) } /> noteField.onChange(e.target.value)} error={noteField.error || undefined} testID="createoredit-creditcard-input-comment-v2" /> } onClick={handleFileLoad} data-testid="createoredit-creditcard-button-addattachment-v2" > {t('Add Another Attachment')} } > {values.attachments.length > 0 ? values.attachments.map( ( attachment: { id?: string tempId?: string name: string }, index: number ) => ( } onClick={() => setValue( ATTACHMENTS_FIELD_KEY, getFilteredAttachmentsById( values.attachments, attachment ) ) } data-testid={`createoredit-creditcard-button-deleteattachment-v2-${index}`} /> } /> ) ) : null} } /> } onClick={() => addCustomField({ type: 'note', name: 'note' })} data-testid="createoredit-creditcard-button-addhiddenmessage-v2" > {t('Add Another Message')} } > {customFieldsList.map((field: { id: string }, index: number) => { const fieldReg = registerCustomFieldItem('note', index) const canRemove = customFieldsList.length > 1 return (
) } ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditCustomModalContent/index.js ================================================ import { useLingui } from '@lingui/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { RECORD_TYPES, useCreateRecord, useRecords } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { CreateCustomField } from '../../../../components/CreateCustomField' import { FolderDropdown } from '../../../../components/FolderDropdown' import { FormGroup } from '../../../../components/FormGroup' import { FormModalHeaderWrapper } from '../../../../components/FormModalHeaderWrapper' import { FormWrapper } from '../../../../components/FormWrapper' import { RecordTypeMenu } from '../../../../components/RecordTypeMenu' import { ATTACHMENTS_FIELD_KEY } from '../../../../constants/formFields' import { useGlobalLoading } from '../../../../context/LoadingContext' import { useModal } from '../../../../context/ModalContext' import { useToast } from '../../../../context/ToastContext' import { useGetMultipleFiles } from '../../../../hooks/useGetMultipleFiles' import { ButtonLittle, ButtonSingleInput, DeleteIcon, ImageIcon, InputField, SaveIcon } from '../../../../lib-react-components' import { getFilteredAttachmentsById } from '../../../../utils/getFilteredAttachmentsById' import { handleFileSelect } from '../../../../utils/handleFileSelect' import { AttachmentField } from '../../../AttachmentField' import { CustomFields } from '../../../CustomFields' import { ModalContent } from '../../ModalContent' import { DropdownsWrapper } from '../../styles' import { UploadFilesModalContent } from '../../UploadImageModalContent' /** * @param {{ * initialRecord: { * data: { * title: string * customFields: { * note: string * type: string * }[] * attachments: { id: string, name: string}[] * } * } * selectedFolder?: string * isFavorite?: boolean * onTypeChange: (type: string) => void * }} props * @returns */ export const CreateOrEditCustomModalContent = ({ initialRecord, selectedFolder, isFavorite, onTypeChange }) => { const { i18n } = useLingui() const { closeModal, setModal } = useModal() const { setToast } = useToast() const { createRecord, isLoading: isCreateLoading } = useCreateRecord({ onCompleted: () => { closeModal() setToast({ message: i18n._('Record created successfully') }) } }) const onError = (error) => { setToast({ message: error.message }) } const { updateRecords, isLoading: isUpdateLoading } = useRecords({ onCompleted: () => { closeModal() setToast({ message: i18n._('Record updated successfully') }) } }) const isLoading = isCreateLoading || isUpdateLoading useGlobalLoading({ isLoading }) const schema = Validator.object({ title: Validator.string().required(i18n._('Title is required')), customFields: Validator.array().items( Validator.object({ note: Validator.string().required(i18n._('Comment is required')) }) ), folder: Validator.string(), attachments: Validator.array().items( Validator.object({ id: Validator.string(), name: Validator.string().required() }) ) }) const { register, handleSubmit, registerArray, values, setValue } = useForm({ initialValues: { title: initialRecord?.data?.title || '', customFields: initialRecord?.data?.customFields || [], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [] }, validate: (values) => schema.validate(values) }) const { value: list, addItem, registerItem, removeItem } = registerArray('customFields') useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) const onSubmit = (values) => { const data = { type: RECORD_TYPES.CUSTOM, folder: values.folder, isFavorite: initialRecord?.isFavorite ?? isFavorite, data: { title: values.title, customFields: values.customFields, attachments: values.attachments } } if (initialRecord) { updateRecords( [ { ...initialRecord, ...data } ], onError ) } else { createRecord(data, onError) } } const handleRecordTypeChange = (item) => { onTypeChange(item) } const handleFileLoad = () => { setModal( html`<${UploadFilesModalContent} type=${'file'} onFilesSelected=${(files) => handleFileSelect({ files, fieldName: ATTACHMENTS_FIELD_KEY, setValue, values })} />` ) } return html` <${ModalContent} onSubmit=${handleSubmit(onSubmit)} onClose=${closeModal} closeButtonDataId="custom-close-button" headerChildren=${html` <${FormModalHeaderWrapper} buttons=${html` <${ButtonLittle} testId="createoredit-button-loadfile" startIcon=${ImageIcon} onClick=${handleFileLoad} > ${i18n._('Load file')} <${ButtonLittle} testId="createoredit-button-save" dataId="custom-save-button" startIcon=${SaveIcon} type="submit" > ${i18n._('Save')} `} > <${DropdownsWrapper}> <${FolderDropdown} testId="createoredit-dropdown-folder" selectedFolder=${values?.folder} onFolderSelect=${(folder) => setValue('folder', folder?.name)} /> ${!initialRecord && html` <${RecordTypeMenu} testId="createoredit-dropdown-recordtype" selectedRecord=${RECORD_TYPES.CUSTOM} onRecordSelect=${(record) => handleRecordTypeChange(record?.type)} />`} `} > <${FormWrapper}> <${FormGroup}> <${InputField} testId="createoredit-input-title" dataId="custom-title-input" label=${i18n._('Title')} placeholder=${i18n._('Insert title')} variant="outline" ...${register('title')} /> ${values.attachments.length > 0 && html` <${FormGroup}> ${values.attachments.map( (attachment) => html`<${AttachmentField} testId="createoredit-attachment" key=${attachment.id || attachment.tempId} attachment=${attachment} label=${i18n._('File')} additionalItems=${html` <${ButtonSingleInput} startIcon=${DeleteIcon} onClick=${() => setValue( ATTACHMENTS_FIELD_KEY, getFilteredAttachmentsById( values.attachments, attachment ) )} > ${i18n._('Delete File')} `} />` )} `}
<${CustomFields} customFields=${list} register=${registerItem} removeItem=${removeItem} />
<${FormGroup}> <${CreateCustomField} testId="createoredit-button-createcustom" dataId="custom-add-field-button" onCreateCustom=${(type) => addItem({ type: type, name: type })} /> ` } ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditCustomModalContentV2/CreateOrEditCustomModalContentV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ form: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, width: '100%' }, sectionLabel: { marginTop: `${rawTokens.spacing8}px` } }) ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditCustomModalContentV2/CreateOrEditCustomModalContentV2.tsx ================================================ import React from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { AttachmentField as UiKitAttachmentField, Button, Dialog, Form, InputField, MultiSlotInput, PasswordField, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { useCreateRecord, useRecords } from '@tetherto/pearpass-lib-vault' import { Add, TrashOutlined, UploadFileFilled } from '@tetherto/pearpass-lib-ui-kit/icons' import { html } from 'htm/react' import { createStyles } from './CreateOrEditCustomModalContentV2.styles' import { ATTACHMENTS_FIELD_KEY } from '../../../../constants/formFields' import { useGlobalLoading } from '../../../../context/LoadingContext' import { useModal } from '../../../../context/ModalContext' import { useToast } from '../../../../context/ToastContext' import { useTranslation } from '../../../../hooks/useTranslation' import { useGetMultipleFiles } from '../../../../hooks/useGetMultipleFiles' import { getFilteredAttachmentsById } from '../../../../utils/getFilteredAttachmentsById' import { handleFileSelect } from '../../../../utils/handleFileSelect' import { UploadFilesModalContentV2 } from '../../UploadFilesModalContentV2' import { FolderDropdownV2 } from '../../../../components/FolderDropdown/FolderDropdownV2' export type CreateOrEditCustomModalContentV2Props = { initialRecord?: { data: { title: string note?: string customFields: { type: string; name?: string; note?: string }[] attachments: { id: string; name: string }[] [key: string]: unknown } folder?: string isFavorite?: boolean attachments?: { id: string; name: string }[] [key: string]: unknown } selectedFolder?: string isFavorite?: boolean onTypeChange: (type: string) => void } export const CreateOrEditCustomModalContentV2 = ({ initialRecord, selectedFolder, isFavorite, onTypeChange: _onTypeChange }: CreateOrEditCustomModalContentV2Props) => { const { t } = useTranslation() const { closeModal, setModal } = useModal() const { setToast } = useToast() const { theme } = useTheme() const styles = createStyles() const { createRecord, isLoading: isCreateLoading } = useCreateRecord({ onCompleted: () => { closeModal() setToast({ message: t('Record created successfully') }) } }) const { updateRecords, isLoading: isUpdateLoading } = useRecords({ onCompleted: () => { closeModal() setToast({ message: t('Record updated successfully') }) } }) const onError = (error: { message: string }) => { setToast({ message: error.message }) } const isLoading = isCreateLoading || isUpdateLoading useGlobalLoading({ isLoading }) const schema = Validator.object({ title: Validator.string().required(t('Title is required')), note: Validator.string(), customFields: Validator.array().items( Validator.object({ note: Validator.string() }) ), folder: Validator.string(), attachments: Validator.array().items( Validator.object({ id: Validator.string(), name: Validator.string().required() }) ) }) const { register, handleSubmit, registerArray, values, setValue } = useForm({ initialValues: { title: initialRecord?.data?.title ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields?.length ? initialRecord.data.customFields : [{ type: 'note', name: 'note', note: '' }], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [] }, validate: (formValues: Record) => schema.validate(formValues) }) const { value: customFieldsList, addItem: addCustomField, registerItem: registerCustomFieldItem, removeItem: removeCustomFieldItem } = registerArray('customFields') useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) const onSubmit = (formValues: Record) => { const data = { type: RECORD_TYPES.CUSTOM, folder: formValues.folder, isFavorite: initialRecord?.isFavorite ?? isFavorite, data: { ...(initialRecord?.data ? initialRecord.data : {}), title: formValues.title, note: formValues.note, customFields: ( (formValues.customFields as Array<{ type: string; note?: string }>) ?? [] ).filter((f) => f.note?.trim().length), attachments: formValues.attachments } } if (initialRecord) { updateRecords([{ ...initialRecord, ...data }], onError) } else { createRecord(data, onError) } } const handleFileLoad = () => { setModal( html`<${UploadFilesModalContentV2} type=${'file'} onFilesSelected=${(files: File[]) => handleFileSelect({ files: files as unknown as FileList, fieldName: ATTACHMENTS_FIELD_KEY, setValue, values })} />` ) } const isEdit = !!initialRecord const titleField = register('title') const noteField = register('note') return ( } >
['style']} testID="createoredit-custom-form-v2" > titleField.onChange(e.target.value)} error={titleField.error || undefined} testID="createoredit-custom-input-title-v2" />
{t('Additional')}
setValue('folder', name === values.folder ? '' : name) } /> noteField.onChange(e.target.value)} error={noteField.error || undefined} testID="createoredit-custom-input-comment-v2" /> } onClick={handleFileLoad} data-testid="createoredit-custom-button-addattachment-v2" > {t('Add Another Attachment')} } > {values.attachments.length > 0 ? values.attachments.map( ( attachment: { id?: string tempId?: string name: string }, index: number ) => ( } onClick={() => setValue( ATTACHMENTS_FIELD_KEY, getFilteredAttachmentsById( values.attachments, attachment ) ) } data-testid={`createoredit-custom-button-deleteattachment-v2-${index}`} /> } /> ) ) : null} } /> } onClick={() => addCustomField({ type: 'note', name: 'note', note: '' }) } data-testid="createoredit-custom-button-addhiddenmessage-v2" > {t('Add Another Message')} } > {customFieldsList.map((field: { id: string }, index: number) => { const fieldReg = registerCustomFieldItem('note', index) const canRemove = customFieldsList.length > 1 return (
) } ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditIdentityModalContent/index.js ================================================ import { useLingui } from '@lingui/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { DATE_FORMAT } from '@tetherto/pearpass-lib-constants' import { RECORD_TYPES, useCreateRecord, useRecords } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { CreateCustomField } from '../../../../components/CreateCustomField' import { FolderDropdown } from '../../../../components/FolderDropdown' import { FormGroup } from '../../../../components/FormGroup' import { FormModalHeaderWrapper } from '../../../../components/FormModalHeaderWrapper' import { FormWrapper } from '../../../../components/FormWrapper' import { InputFieldNote } from '../../../../components/InputFieldNote' import { RecordTypeMenu } from '../../../../components/RecordTypeMenu' import { ATTACHMENTS_FIELD_KEY } from '../../../../constants/formFields' import { useGlobalLoading } from '../../../../context/LoadingContext' import { useModal } from '../../../../context/ModalContext' import { useToast } from '../../../../context/ToastContext' import { useGetMultipleFiles } from '../../../../hooks/useGetMultipleFiles' import { ButtonLittle, ButtonSingleInput, CalendarIcon, DeleteIcon, EmailIcon, GenderIcon, GroupIcon, ImageIcon, InputField, NationalityIcon, PhoneIcon, SaveIcon, UserIcon } from '../../../../lib-react-components' import { handleFileSelect } from '../../../../utils/handleFileSelect' import { AttachmentField } from '../../../AttachmentField' import { CustomFields } from '../../../CustomFields' import { ImagesField } from '../../../ImagesField' import { ModalContent } from '../../ModalContent' import { DropdownsWrapper } from '../../styles' import { UploadFilesModalContent } from '../../UploadImageModalContent' /** * @param {{ * initialRecord: { * data: { * title: string * fullName: string * email: string * phoneNumber: string * address: string * zip: string * city: string * region: string * country: string * note: string * customFields: { * note: string * type: string * }[] * passportFullName: string * passportNumber: string * passportIssuingCountry: string * passportDateOfIssue: string * passportExpiryDate: string * passportNationality: string * passportDob: string * passportGender: string * passportPicture: { id: string, name: string}[] * idCardNumber: string * idCardDateOfIssue: string * idCardExpiryDate: string * idCardIssuingCountry: string * idCardPicture: { id: string, name: string}[] * drivingLicenseNumber: string * drivingLicenseDateOfIssue: string * drivingLicenseExpiryDate: string * drivingLicenseIssuingCountry: string * drivingLicensePicture: { id: string, name: string}[] * attachments: { id: string, name: string}[] * } * folder?: string * } * selectedFolder?: string * isFavorite?: boolean * onTypeChange: (type: string) => void * }} props */ export const CreateOrEditIdentityModalContent = ({ initialRecord, selectedFolder, isFavorite, onTypeChange }) => { const { i18n } = useLingui() const { closeModal, setModal } = useModal() const { setToast } = useToast() const { createRecord, isLoading: isCreateLoading } = useCreateRecord({ onCompleted: () => { closeModal() setToast({ message: i18n._('Record created successfully') }) } }) const { updateRecords, isLoading: isUpdateLoading } = useRecords({ onCompleted: () => { closeModal() setToast({ message: i18n._('Record updated successfully') }) } }) const isLoading = isCreateLoading || isUpdateLoading useGlobalLoading({ isLoading }) const onError = (error) => { setToast({ message: error.message }) } const schema = Validator.object({ title: Validator.string().required(i18n._('Title is required')), fullName: Validator.string(), email: Validator.string().email(i18n._('Invalid email format')), phoneNumber: Validator.string(), address: Validator.string(), zip: Validator.string(), city: Validator.string(), region: Validator.string(), country: Validator.string(), note: Validator.string(), customFields: Validator.array().items( Validator.object({ note: Validator.string().required(i18n._('Comment is required')) }) ), folder: Validator.string(), passportFullName: Validator.string(), passportNumber: Validator.string(), passportIssuingCountry: Validator.string(), passportDateOfIssue: Validator.string(), passportExpiryDate: Validator.string(), passportNationality: Validator.string(), passportDob: Validator.string(), passportGender: Validator.string(), passportPicture: Validator.array().items( Validator.object({ id: Validator.string(), name: Validator.string().required() }) ), idCardNumber: Validator.string(), idCardDateOfIssue: Validator.string(), idCardExpiryDate: Validator.string(), idCardIssuingCountry: Validator.string(), idCardPicture: Validator.array().items( Validator.object({ id: Validator.string(), name: Validator.string().required() }) ), drivingLicenseNumber: Validator.string(), drivingLicenseDateOfIssue: Validator.string(), drivingLicenseExpiryDate: Validator.string(), drivingLicenseIssuingCountry: Validator.string(), drivingLicensePicture: Validator.array().items( Validator.object({ id: Validator.string(), name: Validator.string().required() }) ), attachments: Validator.array().items( Validator.object({ id: Validator.string(), name: Validator.string().required() }) ) }) const { register, handleSubmit, registerArray, values, setValue } = useForm({ initialValues: { title: initialRecord?.data?.title ?? '', fullName: initialRecord?.data?.fullName ?? '', email: initialRecord?.data?.email ?? '', phoneNumber: initialRecord?.data?.phoneNumber ?? '', address: initialRecord?.data?.address ?? '', zip: initialRecord?.data?.zip ?? '', city: initialRecord?.data?.city ?? '', region: initialRecord?.data?.region ?? '', country: initialRecord?.data?.country ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields || [], folder: selectedFolder ?? initialRecord?.folder, passportFullName: initialRecord?.data?.passportFullName ?? '', passportNumber: initialRecord?.data?.passportNumber ?? '', passportIssuingCountry: initialRecord?.data?.passportIssuingCountry ?? '', passportDateOfIssue: initialRecord?.data?.passportDateOfIssue ?? '', passportExpiryDate: initialRecord?.data?.passportExpiryDate ?? '', passportNationality: initialRecord?.data?.passportNationality ?? '', passportDob: initialRecord?.data?.passportDob ?? '', passportGender: initialRecord?.data?.passportGender ?? '', passportPicture: initialRecord?.data?.passportPicture || [], idCardNumber: initialRecord?.data?.idCardNumber ?? '', idCardDateOfIssue: initialRecord?.data?.idCardDateOfIssue ?? '', idCardExpiryDate: initialRecord?.data?.idCardExpiryDate ?? '', idCardIssuingCountry: initialRecord?.data?.idCardIssuingCountry ?? '', idCardPicture: initialRecord?.data?.idCardPicture || [], drivingLicenseNumber: initialRecord?.data?.drivingLicenseNumber ?? '', drivingLicenseDateOfIssue: initialRecord?.data?.drivingLicenseDateOfIssue ?? '', drivingLicenseExpiryDate: initialRecord?.data?.drivingLicenseExpiryDate ?? '', drivingLicenseIssuingCountry: initialRecord?.data?.drivingLicenseIssuingCountry ?? '', drivingLicensePicture: initialRecord?.data?.drivingLicensePicture || [], attachments: initialRecord?.attachments || [] }, validate: (values) => schema.validate(values) }) const { value: list, addItem, registerItem, removeItem } = registerArray('customFields') useGetMultipleFiles({ fieldNames: [ ATTACHMENTS_FIELD_KEY, 'passportPicture', 'idCardPicture', 'drivingLicensePicture' ], updateValues: setValue, initialRecord }) const onSubmit = (values) => { const data = { type: RECORD_TYPES.IDENTITY, folder: values.folder, isFavorite: initialRecord?.isFavorite ?? isFavorite, data: { title: values.title, fullName: values.fullName, email: values.email, phoneNumber: values.phoneNumber, address: values.address, zip: values.zip, city: values.city, region: values.region, country: values.country, note: values.note, customFields: values.customFields, passportFullName: values.passportFullName, passportNumber: values.passportNumber, passportIssuingCountry: values.passportIssuingCountry, passportDateOfIssue: values.passportDateOfIssue, passportExpiryDate: values.passportExpiryDate, passportNationality: values.passportNationality, passportDob: values.passportDob, passportGender: values.passportGender, passportPicture: values.passportPicture, idCardNumber: values.idCardNumber, idCardDateOfIssue: values.idCardDateOfIssue, idCardExpiryDate: values.idCardExpiryDate, idCardIssuingCountry: values.idCardIssuingCountry, idCardPicture: values.idCardPicture, drivingLicenseNumber: values.drivingLicenseNumber, drivingLicenseDateOfIssue: values.drivingLicenseDateOfIssue, drivingLicenseExpiryDate: values.drivingLicenseExpiryDate, drivingLicenseIssuingCountry: values.drivingLicenseIssuingCountry, drivingLicensePicture: values.drivingLicensePicture, attachments: values.attachments } } if (initialRecord) { updateRecords( [ { ...initialRecord, ...data } ], onError ) } else { createRecord(data, onError) } } const handleFileLoad = (fieldName) => { setModal( html`<${UploadFilesModalContent} type=${'file'} onFilesSelected=${(files) => handleFileSelect({ files, fieldName, setValue, values })} />` ) } const handleAttachmentRemove = (fieldName, index) => { const updatedAttachments = values[fieldName].filter( (_, idx) => idx !== index ) setValue(fieldName, updatedAttachments) } return html` <${ModalContent} onClose=${closeModal} onSubmit=${handleSubmit(onSubmit)} headerChildren=${html` <${FormModalHeaderWrapper} buttons=${html` <${ButtonLittle} testId="createoredit-button-loadfile" startIcon=${ImageIcon} onClick=${() => handleFileLoad(ATTACHMENTS_FIELD_KEY)} > ${i18n._('Load file')} <${ButtonLittle} testId="createoredit-button-save" startIcon=${SaveIcon} type="submit" > ${i18n._('Save')} `} > <${DropdownsWrapper}> <${FolderDropdown} testId="createoredit-dropdown-folder" selectedFolder=${values?.folder} onFolderSelect=${(folder) => setValue('folder', folder?.name)} /> ${!initialRecord && html` <${RecordTypeMenu} testId="createoredit-dropdown-recordtype" selectedRecord=${RECORD_TYPES.IDENTITY} onRecordSelect=${(record) => onTypeChange(record?.type)} />`} `} > <${FormWrapper}> <${FormGroup}> <${InputField} testId="createoredit-input-title" label=${i18n._('Title')} placeholder=${i18n._('Insert title')} variant="outline" ...${register('title')} /> <${FormGroup} testId="createoredit-section-personalinfo" title=${i18n._('Personal information')} isCollapse > <${InputField} testId="createoredit-input-fullname" label=${i18n._('Full name')} placeholder=${i18n._('Full name')} variant="outline" icon=${UserIcon} ...${register('fullName')} /> <${InputField} testId="createoredit-input-email" label=${i18n._('Email')} placeholder=${i18n._('Insert email')} variant="outline" icon=${EmailIcon} ...${register('email')} /> <${InputField} testId="createoredit-input-phonenumber" label=${i18n._('Phone number ')} placeholder=${i18n._('Phone number ')} variant="outline" icon=${PhoneIcon} ...${register('phoneNumber')} /> <${FormGroup} testId="createoredit-section-address" title=${i18n._('Detail of address')} isCollapse > <${InputField} testId="createoredit-input-address" label=${i18n._('Address')} placeholder=${i18n._('Address')} variant="outline" ...${register('address')} /> <${InputField} testId="createoredit-input-zip" label=${i18n._('ZIP')} placeholder=${i18n._('Insert zip')} variant="outline" ...${register('zip')} /> <${InputField} testId="createoredit-input-city" label=${i18n._('City')} placeholder=${i18n._('City')} variant="outline" ...${register('city')} /> <${InputField} testId="createoredit-input-region" label=${i18n._('Region')} placeholder=${i18n._('Region')} variant="outline" ...${register('region')} /> <${InputField} testId="createoredit-input-country" label=${i18n._('Country')} placeholder=${i18n._('Country')} variant="outline" ...${register('country')} /> <${FormGroup} testId="createoredit-section-passport" defaultOpenState=${false} title=${i18n._('Passport')} isCollapse >
<${InputField} testId="createoredit-input-passportfullname" label=${i18n._('Full name')} placeholder="John Smith" variant="outline" icon=${UserIcon} ...${register('passportFullName')} /> <${InputField} testId="createoredit-input-passportnumber" label=${i18n._('Passport number')} placeholder=${i18n._('Insert numbers')} variant="outline" icon=${GroupIcon} ...${register('passportNumber')} /> <${InputField} testId="createoredit-input-passportissuingcountry" label=${i18n._('Issuing country')} placeholder=${i18n._('Insert country')} variant="outline" icon=${NationalityIcon} ...${register('passportIssuingCountry')} /> <${InputField} testId="createoredit-input-passportdateofissue" label=${i18n._('Date of issue')} placeholder=${DATE_FORMAT} variant="outline" icon=${CalendarIcon} ...${register('passportDateOfIssue')} /> <${InputField} testId="createoredit-input-passportexpirydate" label=${i18n._('Expiry Date')} placeholder=${DATE_FORMAT} variant="outline" icon=${CalendarIcon} ...${register('passportExpiryDate')} /> <${InputField} testId="createoredit-input-passportnationality" label=${i18n._('Nationality')} placeholder=${i18n._('Insert your nationality')} variant="outline" icon=${NationalityIcon} ...${register('passportNationality')} /> <${InputField} testId="createoredit-input-passportdob" label=${i18n._('Date of birth')} placeholder=${DATE_FORMAT} variant="outline" icon=${CalendarIcon} ...${register('passportDob')} /> <${InputField} testId="createoredit-input-passportgender" label=${i18n._('Gender')} placeholder=${i18n._('M/F')} variant="outline" icon=${GenderIcon} ...${register('passportGender')} /> <${ImagesField} testId="createoredit-imagesfield-passportimages" title=${i18n._('Passport Images')} onAdd=${() => handleFileLoad('passportPicture')} pictures=${values.passportPicture} onRemove=${(index) => handleAttachmentRemove('passportPicture', index)} /> <${FormGroup} testId="createoredit-section-idcard" defaultOpenState=${false} title=${i18n._('Identity card')} isCollapse >
<${InputField} testId="createoredit-input-idcardnumber" label=${i18n._('ID number')} placeholder="123456789" variant="outline" icon=${GroupIcon} ...${register('idCardNumber')} /> <${InputField} testId="createoredit-input-idcarddateofissue" label=${i18n._('Creation date')} placeholder=${DATE_FORMAT} variant="outline" icon=${CalendarIcon} ...${register('idCardDateOfIssue')} /> <${InputField} testId="createoredit-input-idcardexpirydate" label=${i18n._('Expiry date')} placeholder=${DATE_FORMAT} variant="outline" icon=${CalendarIcon} ...${register('idCardExpiryDate')} /> <${InputField} testId="createoredit-input-idcardissuingcountry" label=${i18n._('Issue country')} placeholder=${i18n._('Insert country')} variant="outline" icon=${NationalityIcon} ...${register('idCardIssuingCountry')} /> <${ImagesField} testId="createoredit-imagesfield-idcardimages" title=${i18n._('Identity Card Images')} onAdd=${() => handleFileLoad('idCardPicture')} pictures=${values.idCardPicture} onRemove=${(index) => handleAttachmentRemove('idCardPicture', index)} /> <${FormGroup} testId="createoredit-section-drivinglicense" defaultOpenState=${false} title=${i18n._('Driving license')} isCollapse >
<${InputField} testId="createoredit-input-drivinglicensenumber" label=${i18n._('ID number')} placeholder="123456789" variant="outline" icon=${GroupIcon} ...${register('drivingLicenseNumber')} /> <${InputField} testId="createoredit-input-drivinglicensedateofissue" label=${i18n._('Creation date')} placeholder=${DATE_FORMAT} variant="outline" icon=${CalendarIcon} ...${register('drivingLicenseDateOfIssue')} /> <${InputField} testId="createoredit-input-drivinglicenseexpirydate" label=${i18n._('Expiry date')} placeholder=${DATE_FORMAT} variant="outline" icon=${CalendarIcon} ...${register('drivingLicenseExpiryDate')} /> <${InputField} testId="createoredit-input-drivinglicenseissuingcountry" label=${i18n._('Issue country')} placeholder=${i18n._('Insert country')} variant="outline" icon=${NationalityIcon} ...${register('drivingLicenseIssuingCountry')} /> <${ImagesField} testId="createoredit-imagesfield-drivinglicenseimages" title=${i18n._('Driving License Images')} onAdd=${() => handleFileLoad('drivingLicensePicture')} pictures=${values.drivingLicensePicture} onRemove=${(index) => handleAttachmentRemove('drivingLicensePicture', index)} /> ${values.attachments.length > 0 && html` <${FormGroup}> ${values.attachments.map( (attachment, index) => html`<${AttachmentField} testId="createoredit-attachment" attachment=${attachment} label=${i18n._('File')} additionalItems=${html` <${ButtonSingleInput} testId="createoredit-button-deleteattachment" startIcon=${DeleteIcon} onClick=${() => handleAttachmentRemove(ATTACHMENTS_FIELD_KEY, index)} > ${i18n._('Delete File')} `} />` )} `} <${FormGroup}> <${InputFieldNote} testId="createoredit-input-note" ...${register('note')} /> <${CustomFields} customFields=${list} register=${registerItem} removeItem=${removeItem} /> <${FormGroup}> <${CreateCustomField} testId="createoredit-button-createcustom" onCreateCustom=${(type) => addItem({ type: type, name: type })} /> ` } ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditIdentityModalContentV2/CreateOrEditIdentityModalContentV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ form: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, width: '100%' }, sectionLabel: { marginTop: `${rawTokens.spacing8}px` } }) ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditIdentityModalContentV2/CreateOrEditIdentityModalContentV2.tsx ================================================ import React from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { AttachmentField as UiKitAttachmentField, Button, DateField, Dialog, Form, InputField, MultiSlotInput, PasswordField, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { useCreateRecord, useRecords } from '@tetherto/pearpass-lib-vault' import { Add, TrashOutlined, UploadFileFilled } from '@tetherto/pearpass-lib-ui-kit/icons' import { html } from 'htm/react' import { createStyles } from './CreateOrEditIdentityModalContentV2.styles' import { ATTACHMENTS_FIELD_KEY } from '../../../../constants/formFields' import { useGlobalLoading } from '../../../../context/LoadingContext' import { useModal } from '../../../../context/ModalContext' import { useToast } from '../../../../context/ToastContext' import { useTranslation } from '../../../../hooks/useTranslation' import { useGetMultipleFiles } from '../../../../hooks/useGetMultipleFiles' import { getFilteredAttachmentsById } from '../../../../utils/getFilteredAttachmentsById' import { handleFileSelect } from '../../../../utils/handleFileSelect' import { UploadFilesModalContentV2 } from '../../UploadFilesModalContentV2' import { FolderDropdownV2 } from '../../../../components/FolderDropdown/FolderDropdownV2' type IdentityData = { title: string fullName: string email: string phoneNumber: string address: string zip: string city: string region: string country: string note: string customFields: { type: string; note?: string }[] passportFullName: string passportNumber: string passportIssuingCountry: string passportDateOfIssue: string passportExpiryDate: string passportNationality: string passportDob: string passportGender: string idCardNumber: string idCardDateOfIssue: string idCardExpiryDate: string idCardIssuingCountry: string drivingLicenseNumber: string drivingLicenseDateOfIssue: string drivingLicenseExpiryDate: string drivingLicenseIssuingCountry: string attachments: { id: string; name: string }[] } export type CreateOrEditIdentityModalContentV2Props = { initialRecord?: { data: Partial & Record folder?: string isFavorite?: boolean attachments?: { id: string; name: string }[] [key: string]: unknown } selectedFolder?: string isFavorite?: boolean onTypeChange: (type: string) => void } export const CreateOrEditIdentityModalContentV2 = ({ initialRecord, selectedFolder, isFavorite, onTypeChange: _onTypeChange }: CreateOrEditIdentityModalContentV2Props) => { const { t } = useTranslation() const { closeModal, setModal } = useModal() const { setToast } = useToast() const { theme } = useTheme() const styles = createStyles() const { createRecord, isLoading: isCreateLoading } = useCreateRecord({ onCompleted: () => { closeModal() setToast({ message: t('Record created successfully') }) } }) const { updateRecords, isLoading: isUpdateLoading } = useRecords({ onCompleted: () => { closeModal() setToast({ message: t('Record updated successfully') }) } }) const onError = (error: { message: string }) => { setToast({ message: error.message }) } const isLoading = isCreateLoading || isUpdateLoading useGlobalLoading({ isLoading }) const schema = Validator.object({ title: Validator.string().required(t('Title is required')), fullName: Validator.string(), email: Validator.string().email(t('Invalid email format')), phoneNumber: Validator.string(), address: Validator.string(), zip: Validator.string(), city: Validator.string(), region: Validator.string(), country: Validator.string(), note: Validator.string(), customFields: Validator.array().items( Validator.object({ note: Validator.string() }) ), folder: Validator.string(), passportFullName: Validator.string(), passportNumber: Validator.string(), passportIssuingCountry: Validator.string(), passportDateOfIssue: Validator.string(), passportExpiryDate: Validator.string(), passportNationality: Validator.string(), passportDob: Validator.string(), passportGender: Validator.string(), idCardNumber: Validator.string(), idCardDateOfIssue: Validator.string(), idCardExpiryDate: Validator.string(), idCardIssuingCountry: Validator.string(), drivingLicenseNumber: Validator.string(), drivingLicenseDateOfIssue: Validator.string(), drivingLicenseExpiryDate: Validator.string(), drivingLicenseIssuingCountry: Validator.string(), attachments: Validator.array().items( Validator.object({ id: Validator.string(), name: Validator.string().required() }) ) }) const { register, handleSubmit, registerArray, values, setValue } = useForm({ initialValues: { title: initialRecord?.data?.title ?? '', fullName: initialRecord?.data?.fullName ?? '', email: initialRecord?.data?.email ?? '', phoneNumber: initialRecord?.data?.phoneNumber ?? '', address: initialRecord?.data?.address ?? '', zip: initialRecord?.data?.zip ?? '', city: initialRecord?.data?.city ?? '', region: initialRecord?.data?.region ?? '', country: initialRecord?.data?.country ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields?.length ? initialRecord.data.customFields : [{ type: 'note', note: '' }], folder: selectedFolder ?? initialRecord?.folder, passportFullName: initialRecord?.data?.passportFullName ?? '', passportNumber: initialRecord?.data?.passportNumber ?? '', passportIssuingCountry: initialRecord?.data?.passportIssuingCountry ?? '', passportDateOfIssue: initialRecord?.data?.passportDateOfIssue ?? '', passportExpiryDate: initialRecord?.data?.passportExpiryDate ?? '', passportNationality: initialRecord?.data?.passportNationality ?? '', passportDob: initialRecord?.data?.passportDob ?? '', passportGender: initialRecord?.data?.passportGender ?? '', idCardNumber: initialRecord?.data?.idCardNumber ?? '', idCardDateOfIssue: initialRecord?.data?.idCardDateOfIssue ?? '', idCardExpiryDate: initialRecord?.data?.idCardExpiryDate ?? '', idCardIssuingCountry: initialRecord?.data?.idCardIssuingCountry ?? '', drivingLicenseNumber: initialRecord?.data?.drivingLicenseNumber ?? '', drivingLicenseDateOfIssue: initialRecord?.data?.drivingLicenseDateOfIssue ?? '', drivingLicenseExpiryDate: initialRecord?.data?.drivingLicenseExpiryDate ?? '', drivingLicenseIssuingCountry: initialRecord?.data?.drivingLicenseIssuingCountry ?? '', attachments: initialRecord?.attachments ?? [] }, validate: (formValues: Record) => schema.validate(formValues) }) const { value: customFieldsList, addItem: addCustomField, registerItem: registerCustomFieldItem, removeItem: removeCustomFieldItem } = registerArray('customFields') useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) const onSubmit = (formValues: Record) => { const data = { type: RECORD_TYPES.IDENTITY, folder: formValues.folder, isFavorite: initialRecord?.isFavorite ?? isFavorite, data: { ...(initialRecord?.data ? initialRecord.data : {}), title: formValues.title, fullName: formValues.fullName, email: formValues.email, phoneNumber: formValues.phoneNumber, address: formValues.address, zip: formValues.zip, city: formValues.city, region: formValues.region, country: formValues.country, note: formValues.note, customFields: ( (formValues.customFields as Array<{ type: string; note?: string }>) ?? [] ).filter((f) => f.note?.trim().length), passportFullName: formValues.passportFullName, passportNumber: formValues.passportNumber, passportIssuingCountry: formValues.passportIssuingCountry, passportDateOfIssue: formValues.passportDateOfIssue, passportExpiryDate: formValues.passportExpiryDate, passportNationality: formValues.passportNationality, passportDob: formValues.passportDob, passportGender: formValues.passportGender, idCardNumber: formValues.idCardNumber, idCardDateOfIssue: formValues.idCardDateOfIssue, idCardExpiryDate: formValues.idCardExpiryDate, idCardIssuingCountry: formValues.idCardIssuingCountry, drivingLicenseNumber: formValues.drivingLicenseNumber, drivingLicenseDateOfIssue: formValues.drivingLicenseDateOfIssue, drivingLicenseExpiryDate: formValues.drivingLicenseExpiryDate, drivingLicenseIssuingCountry: formValues.drivingLicenseIssuingCountry, attachments: formValues.attachments } } if (initialRecord) { updateRecords([{ ...initialRecord, ...data }], onError) } else { createRecord(data, onError) } } const handleFileLoad = () => { setModal( html`<${UploadFilesModalContentV2} type=${'file'} onFilesSelected=${(files: File[]) => handleFileSelect({ files: files as unknown as FileList, fieldName: ATTACHMENTS_FIELD_KEY, setValue, values })} />` ) } const isEdit = !!initialRecord const titleField = register('title') const fullNameField = register('fullName') const emailField = register('email') const phoneNumberField = register('phoneNumber') const addressField = register('address') const zipField = register('zip') const cityField = register('city') const regionField = register('region') const countryField = register('country') const noteField = register('note') const passportFullNameField = register('passportFullName') const passportNumberField = register('passportNumber') const passportIssuingCountryField = register('passportIssuingCountry') const passportNationalityField = register('passportNationality') const passportGenderField = register('passportGender') const idCardNumberField = register('idCardNumber') const idCardIssuingCountryField = register('idCardIssuingCountry') const drivingLicenseNumberField = register('drivingLicenseNumber') const drivingLicenseIssuingCountryField = register('drivingLicenseIssuingCountry') return ( } >
['style']} testID="createoredit-identity-form-v2" > titleField.onChange(e.target.value)} error={titleField.error || undefined} testID="createoredit-identity-input-title-v2" />
{t('Personal Information')}
fullNameField.onChange(e.target.value)} error={fullNameField.error || undefined} testID="createoredit-identity-input-fullname-v2" /> emailField.onChange(e.target.value)} error={emailField.error || undefined} testID="createoredit-identity-input-email-v2" /> phoneNumberField.onChange(e.target.value)} error={phoneNumberField.error || undefined} testID="createoredit-identity-input-phone-v2" />
{t('Address Details')}
addressField.onChange(e.target.value)} error={addressField.error || undefined} testID="createoredit-identity-input-address-v2" /> countryField.onChange(e.target.value)} error={countryField.error || undefined} testID="createoredit-identity-input-country-v2" /> cityField.onChange(e.target.value)} error={cityField.error || undefined} testID="createoredit-identity-input-city-v2" /> regionField.onChange(e.target.value)} error={regionField.error || undefined} testID="createoredit-identity-input-region-v2" /> zipField.onChange(e.target.value)} error={zipField.error || undefined} testID="createoredit-identity-input-zip-v2" />
{t('Passport Details')}
passportFullNameField.onChange(e.target.value)} error={passportFullNameField.error || undefined} testID="createoredit-identity-input-passportfullname-v2" /> passportNumberField.onChange(e.target.value)} error={passportNumberField.error || undefined} testID="createoredit-identity-input-passportnumber-v2" /> passportIssuingCountryField.onChange(e.target.value)} error={passportIssuingCountryField.error || undefined} testID="createoredit-identity-input-passportissuingcountry-v2" /> setValue('passportDob', e.target.value)} testID="createoredit-identity-input-passportdob-v2" /> setValue('passportDateOfIssue', e.target.value)} testID="createoredit-identity-input-passportdateofissue-v2" /> setValue('passportExpiryDate', e.target.value)} testID="createoredit-identity-input-passportexpirydate-v2" /> passportNationalityField.onChange(e.target.value)} error={passportNationalityField.error || undefined} testID="createoredit-identity-input-passportnationality-v2" /> passportGenderField.onChange(e.target.value)} error={passportGenderField.error || undefined} testID="createoredit-identity-input-passportgender-v2" />
{t('Identity Card Details')}
idCardNumberField.onChange(e.target.value)} error={idCardNumberField.error || undefined} testID="createoredit-identity-input-idcardnumber-v2" /> setValue('idCardDateOfIssue', e.target.value)} testID="createoredit-identity-input-idcarddateofissue-v2" /> setValue('idCardExpiryDate', e.target.value)} testID="createoredit-identity-input-idcardexpirydate-v2" /> idCardIssuingCountryField.onChange(e.target.value)} error={idCardIssuingCountryField.error || undefined} testID="createoredit-identity-input-idcardissuingcountry-v2" />
{t('Driving License Details')}
drivingLicenseNumberField.onChange(e.target.value)} error={drivingLicenseNumberField.error || undefined} testID="createoredit-identity-input-drivinglicensenumber-v2" /> setValue('drivingLicenseDateOfIssue', e.target.value)} testID="createoredit-identity-input-drivinglicensedateofissue-v2" /> setValue('drivingLicenseExpiryDate', e.target.value)} testID="createoredit-identity-input-drivinglicenseexpirydate-v2" /> drivingLicenseIssuingCountryField.onChange(e.target.value)} error={drivingLicenseIssuingCountryField.error || undefined} testID="createoredit-identity-input-drivinglicenseissuingcountry-v2" />
{t('Additional')}
setValue('folder', name === values.folder ? '' : name) } /> noteField.onChange(e.target.value)} error={noteField.error || undefined} testID="createoredit-identity-input-comment-v2" /> } onClick={handleFileLoad} data-testid="createoredit-identity-button-addattachment-v2" > {t('Add Another Attachment')} } > {values.attachments.length > 0 ? values.attachments.map( ( attachment: { id?: string tempId?: string name: string }, index: number ) => ( } onClick={() => setValue( ATTACHMENTS_FIELD_KEY, getFilteredAttachmentsById( values.attachments, attachment ) ) } data-testid={`createoredit-identity-button-deleteattachment-v2-${index}`} /> } /> ) ) : null} } /> } onClick={() => addCustomField({ type: 'note', note: '' })} data-testid="createoredit-identity-button-addcustomfield-v2" > {t('Add Another Message')} } > {customFieldsList.map((field: { id: string }, index: number) => { const fieldReg = registerCustomFieldItem('note', index) const canRemove = customFieldsList.length > 1 return (
) } ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditLoginModalContent/index.js ================================================ import React from 'react' import { useLingui } from '@lingui/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { AUTHENTICATOR_ENABLED } from '@tetherto/pearpass-lib-constants' import { RECORD_TYPES, useCreateRecord, useRecords } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { CreateCustomField } from '../../../../components/CreateCustomField' import { FolderDropdown } from '../../../../components/FolderDropdown' import { FormGroup } from '../../../../components/FormGroup' import { FormModalHeaderWrapper } from '../../../../components/FormModalHeaderWrapper' import { FormWrapper } from '../../../../components/FormWrapper' import { InputFieldNote } from '../../../../components/InputFieldNote' import { RecordTypeMenu } from '../../../../components/RecordTypeMenu' import { ATTACHMENTS_FIELD_KEY } from '../../../../constants/formFields' import { useGlobalLoading } from '../../../../context/LoadingContext' import { useModal } from '../../../../context/ModalContext' import { useToast } from '../../../../context/ToastContext' import { useCreateOrEditRecord } from '../../../../hooks/useCreateOrEditRecord' import { useGetMultipleFiles } from '../../../../hooks/useGetMultipleFiles' import { ButtonLittle, ButtonRoundIcon, ButtonSingleInput, CompoundField, DeleteIcon, ImageIcon, InputField, KeyIcon, LockIcon, PasswordField, PasswordIcon, PlusIcon, SaveIcon, UserIcon, WorldIcon } from '../../../../lib-react-components' import { addHttps } from '../../../../utils/addHttps' import { formatPasskeyDate } from '../../../../utils/formatPasskeyDate' import { getFilteredAttachmentsById } from '../../../../utils/getFilteredAttachmentsById' import { handleFileSelect } from '../../../../utils/handleFileSelect' import { AttachmentField } from '../../../AttachmentField' import { CustomFields } from '../../../CustomFields' import { ModalContent } from '../../ModalContent' import { DropdownsWrapper } from '../../styles' import { UploadFilesModalContent } from '../../UploadImageModalContent' /** * @param {{ * initialRecord: { * data: { * title: string * username: string * password: string * note: string * websites: string[] * customFields: { * type: string * name: string * }[] * attachments: { id: string, name: string}[] * } * } * selectedFolder?: string * isFavorite?: boolean * onTypeChange: (type: string) => void * }} props */ export const CreateOrEditLoginModalContent = ({ initialRecord, selectedFolder, isFavorite, onTypeChange }) => { const { i18n } = useLingui() const { closeModal, setModal } = useModal() const { handleCreateOrEditRecord } = useCreateOrEditRecord() const { setToast } = useToast() const { createRecord, isLoading: isCreateLoading } = useCreateRecord({ onCompleted: () => { closeModal() setToast({ message: i18n._('Record created successfully') }) } }) const { updateRecords, isLoading: isUpdateLoading } = useRecords({ onCompleted: () => { closeModal() setToast({ message: i18n._('Record updated successfully') }) } }) const onError = (error) => { setToast({ message: error.message }) } const isLoading = isCreateLoading || isUpdateLoading useGlobalLoading({ isLoading }) const schema = Validator.object({ title: Validator.string().required(i18n._('Title is required')), username: Validator.string(), password: Validator.string(), otpSecret: Validator.string(), note: Validator.string(), websites: Validator.array().items( Validator.object({ website: Validator.string().website('Wrong format of website') }) ), customFields: Validator.array().items( Validator.object({ note: Validator.string().required(i18n._('Comment is required')) }) ), folder: Validator.string(), attachments: Validator.array().items( Validator.object({ id: Validator.string(), name: Validator.string().required() }) ), passwordUpdatedAt: Validator.number() }) const { register, handleSubmit, registerArray, values, setValue } = useForm({ initialValues: { title: initialRecord?.data?.title ?? '', username: initialRecord?.data?.username ?? '', password: initialRecord?.data?.password ?? '', otpSecret: initialRecord?.data?.otpInput ?? initialRecord?.data?.otp?.secret ?? '', note: initialRecord?.data?.note ?? '', websites: initialRecord?.data?.websites?.length ? initialRecord?.data?.websites.map((website) => ({ website })) : [{ name: 'website' }], customFields: initialRecord?.data.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [], credential: initialRecord?.data?.credential?.id ?? '', passkeyCreatedAt: initialRecord?.data?.passkeyCreatedAt }, validate: (values) => schema.validate(values) }) const { value: websitesList, addItem, registerItem, removeItem } = registerArray('websites') const { value: customFieldsList, addItem: addCustomField, registerItem: registerCustomFieldItem, removeItem: removeCustomFieldItem } = registerArray('customFields') useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) const onSubmit = (values) => { const otpInput = values.otpSecret?.trim() || undefined const data = { type: RECORD_TYPES.LOGIN, folder: values.folder, isFavorite: initialRecord?.isFavorite ?? isFavorite, data: { ...(initialRecord?.data ? initialRecord.data : {}), title: values.title, username: values.username, password: values.password, note: values.note, websites: values.websites .filter((website) => !!website?.website?.trim().length) .map((website) => addHttps(website.website)), customFields: values.customFields, attachments: values.attachments, passwordUpdatedAt: initialRecord?.data?.passwordUpdatedAt, otpInput } } if (initialRecord) { updateRecords( [ { ...initialRecord, ...data } ], onError ) } else { createRecord(data, onError) } } const handleRecordTypeChange = (type) => { onTypeChange(type) } const handleFileLoad = () => { setModal( html`<${UploadFilesModalContent} type=${'file'} onFilesSelected=${(files) => handleFileSelect({ files, fieldName: ATTACHMENTS_FIELD_KEY, setValue, values })} />` ) } return html` <${ModalContent} onClose=${closeModal} onSubmit=${handleSubmit(onSubmit)} headerChildren=${html` <${FormModalHeaderWrapper} buttons=${html` <${ButtonLittle} key=${'createoredit-button-loadfile'} testId="createoredit-button-loadfile" startIcon=${ImageIcon} onClick=${handleFileLoad} > ${i18n._('Load file')} <${ButtonLittle} key=${'createoredit-button-save'} testId="createoredit-button-save" startIcon=${SaveIcon} type="submit" > ${i18n._('Save')} `} > <${DropdownsWrapper}> <${FolderDropdown} testId="createoredit-dropdown-folder" selectedFolder=${values?.folder} onFolderSelect=${(folder) => setValue('folder', folder?.name)} /> ${!initialRecord && html` <${RecordTypeMenu} testId="createoredit-dropdown-recordtype" selectedRecord=${RECORD_TYPES.LOGIN} onRecordSelect=${(record) => handleRecordTypeChange(record?.type)} />`} `} > <${FormWrapper}> <${FormGroup}> <${InputField} testId="createoredit-input-title" label=${i18n._('Title')} placeholder=${i18n._('Insert title')} variant="outline" ...${register('title')} /> <${FormGroup}> <${InputField} testId="createoredit-input-username" label=${i18n._('Email or username')} placeholder=${i18n._('Email or username')} variant="outline" icon=${UserIcon} ...${register('username')} /> <${PasswordField} testId="createoredit-input-password" label=${i18n._('Password')} placeholder=${i18n._('Password')} variant="outline" icon=${KeyIcon} hasStrongness additionalItems=${html` <${ButtonRoundIcon} testId="createoredit-button-generatepassword" startIcon=${PasswordIcon} onClick=${() => handleCreateOrEditRecord({ recordType: 'password', setValue: (value) => setValue('password', value) })} /> `} ...${register('password')} /> ${!!values?.credential && html` <${FormGroup}> <${InputField} label=${i18n._('Passkey')} value=${formatPasskeyDate(values.passkeyCreatedAt) || i18n._('Passkey Stored')} variant="outline" icon=${KeyIcon} isDisabled /> `} ${AUTHENTICATOR_ENABLED && html` <${FormGroup}> <${PasswordField} testId="createoredit-input-otpsecret" label=${i18n._('Authenticator Secret Key')} placeholder=${i18n._('Enter Secret Key or otpauth:// URI')} variant="outline" icon=${LockIcon} ...${register('otpSecret')} /> `} <${CompoundField}> ${websitesList.map( (website, index) => html` <${React.Fragment} key=${website.id}> <${InputField} testId="createoredit-input-website" label=${i18n._('Website')} placeholder=${i18n._('https://')} icon=${WorldIcon} ...${registerItem('website', index)} additionalItems=${index === 0 ? html` <${ButtonSingleInput} testId="createoredit-button-addwebsite" startIcon=${PlusIcon} onClick=${() => addItem({ name: 'website' })} > ${i18n._('Add website')} ` : html` <${ButtonSingleInput} testId="createoredit-button-removewebsite" startIcon=${DeleteIcon} onClick=${() => removeItem(index)} > ${i18n._('Remove website')} `} /> ` )} ${values.attachments.length > 0 && html` <${FormGroup}> ${values.attachments.map( (attachment) => html`<${AttachmentField} testId="createoredit-attachment" key=${attachment.id || attachment.tempId} attachment=${attachment} label=${i18n._('File')} additionalItems=${html` <${ButtonSingleInput} testId="createoredit-button-deleteattachment" startIcon=${DeleteIcon} onClick=${() => setValue( ATTACHMENTS_FIELD_KEY, getFilteredAttachmentsById( values.attachments, attachment ) )} > ${i18n._('Delete File')} `} />` )} `} <${FormGroup}> <${InputFieldNote} testId="createoredit-input-note" ...${register('note')} /> <${CustomFields} customFields=${customFieldsList} register=${registerCustomFieldItem} removeItem=${removeCustomFieldItem} /> <${FormGroup}> <${CreateCustomField} testId="createoredit-button-createcustom" onCreateCustom=${(type) => addCustomField({ type: type, name: type })} /> ` } ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditLoginModalContentV2/CreateOrEditLoginModalContentV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ form: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, width: '100%' }, sectionLabel: { marginTop: `${rawTokens.spacing8}px` } }) ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditLoginModalContentV2/CreateOrEditLoginModalContentV2.tsx ================================================ import React, { useState } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' // @ts-ignore - declaration file is incomplete import { AUTHENTICATOR_ENABLED } from '@tetherto/pearpass-lib-constants' import { AttachmentField as UiKitAttachmentField, Button, Dialog, Form, InputField, MultiSlotInput, PasswordField, Text, useTheme, } from '@tetherto/pearpass-lib-ui-kit' import { RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { useCreateRecord, useRecords } from '@tetherto/pearpass-lib-vault' import { Add, SyncLock, TrashOutlined, UploadFileFilled } from '@tetherto/pearpass-lib-ui-kit/icons' import { html } from 'htm/react' import { createStyles } from './CreateOrEditLoginModalContentV2.styles' import { ATTACHMENTS_FIELD_KEY } from '../../../../constants/formFields' import { useGlobalLoading } from '../../../../context/LoadingContext' import { useModal } from '../../../../context/ModalContext' import { useToast } from '../../../../context/ToastContext' import { useTranslation } from '../../../../hooks/useTranslation' import { useCreateOrEditRecord } from '../../../../hooks/useCreateOrEditRecord' import { useGetMultipleFiles } from '../../../../hooks/useGetMultipleFiles' import { addHttps } from '../../../../utils/addHttps' import { formatPasskeyDate } from '../../../../utils/formatPasskeyDate' import { getFilteredAttachmentsById } from '../../../../utils/getFilteredAttachmentsById' import { handleFileSelect } from '../../../../utils/handleFileSelect' import { UploadFilesModalContentV2 } from '../../UploadFilesModalContentV2' import { FolderDropdownV2 } from '../../../../components/FolderDropdown/FolderDropdownV2' import { PassType } from '../../../../shared/types' import { PasswordFieldStrengthIndicator } from '../../../../components/PasswordFieldStrengthIndicator' export type CreateOrEditLoginModalContentV2Props = { initialRecord?: { data: { title: string username: string password: string note: string websites: string[] customFields: { type: string; name: string }[] attachments: { id: string; name: string }[] otpInput?: string otp?: { secret?: string } credential?: { id: string } passkeyCreatedAt?: number passwordUpdatedAt?: number [key: string]: unknown } folder?: string isFavorite?: boolean attachments?: { id: string; name: string }[] [key: string]: unknown } selectedFolder?: string isFavorite?: boolean onTypeChange: (type: string) => void } export const CreateOrEditLoginModalContentV2 = ({ initialRecord, selectedFolder, isFavorite, onTypeChange: _onTypeChange }: CreateOrEditLoginModalContentV2Props) => { const { t } = useTranslation() const { closeModal, setModal } = useModal() const { handleCreateOrEditRecord } = useCreateOrEditRecord() const [passwordType, setPasswordType] = useState(PassType.Password) const { setToast } = useToast() const { theme } = useTheme() const styles = createStyles() const { createRecord, isLoading: isCreateLoading } = useCreateRecord({ onCompleted: () => { closeModal() setToast({ message: t('Record created successfully') }) } }) const { updateRecords, isLoading: isUpdateLoading } = useRecords({ onCompleted: () => { closeModal() setToast({ message: t('Record updated successfully') }) } }) const onError = (error: { message: string }) => { setToast({ message: error.message }) } const isLoading = isCreateLoading || isUpdateLoading useGlobalLoading({ isLoading }) const schema = Validator.object({ title: Validator.string().required(t('Title is required')), username: Validator.string(), password: Validator.string(), otpSecret: Validator.string(), note: Validator.string(), websites: Validator.array().items( Validator.object({ website: Validator.string().website('Wrong format of website') }) ), customFields: Validator.array().items( Validator.object({ note: Validator.string() }) ), folder: Validator.string(), attachments: Validator.array().items( Validator.object({ id: Validator.string(), name: Validator.string().required() }) ), passwordUpdatedAt: Validator.number() }) const { register, handleSubmit, registerArray, values, setValue } = useForm({ initialValues: { title: initialRecord?.data?.title ?? '', username: initialRecord?.data?.username ?? '', password: initialRecord?.data?.password ?? '', otpSecret: initialRecord?.data?.otpInput ?? initialRecord?.data?.otp?.secret ?? '', note: initialRecord?.data?.note ?? '', websites: initialRecord?.data?.websites?.length ? initialRecord.data.websites.map((website: string) => ({ website })) : [{ name: 'website' }], customFields: initialRecord?.data?.customFields?.length ? initialRecord.data.customFields : [{ type: 'note', name: 'note', note: '' }], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [], credential: initialRecord?.data?.credential?.id ?? '', passkeyCreatedAt: initialRecord?.data?.passkeyCreatedAt }, validate: (formValues: Record) => schema.validate(formValues) }) const { value: websitesList, addItem: addWebsite, registerItem: registerWebsiteItem, removeItem: removeWebsite } = registerArray('websites') const { value: customFieldsList, addItem: addCustomField, registerItem: registerCustomFieldItem, removeItem: removeCustomFieldItem } = registerArray('customFields') useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) const onSubmit = (formValues: Record) => { const otpInput = (formValues.otpSecret as string)?.trim() || undefined const data = { type: RECORD_TYPES.LOGIN, folder: formValues.folder, isFavorite: initialRecord?.isFavorite ?? isFavorite, data: { ...(initialRecord?.data ? initialRecord.data : {}), title: formValues.title, username: formValues.username, password: formValues.password, note: formValues.note, websites: (formValues.websites as Array<{ website?: string }>) .filter((website) => !!website?.website?.trim().length) .map((website) => addHttps(website.website!)), customFields: ( (formValues.customFields as Array<{ type: string; note?: string }>) ?? [] ).filter((f) => f.note?.trim().length), attachments: formValues.attachments, passwordUpdatedAt: initialRecord?.data?.passwordUpdatedAt, otpInput } } if (initialRecord) { updateRecords([{ ...initialRecord, ...data }], onError) } else { createRecord(data, onError) } } const handleFileLoad = () => { setModal( html`<${UploadFilesModalContentV2} type=${'file'} onFilesSelected=${(files: File[]) => handleFileSelect({ files: files as unknown as FileList, fieldName: ATTACHMENTS_FIELD_KEY, setValue, values })} />` ) } const isEdit = !!initialRecord const titleField = register('title') const usernameField = register('username') const passwordField = register('password') const otpSecretField = register('otpSecret') const noteField = register('note') return ( } >
['style']} testID='createoredit-form-v2' > titleField.onChange(e.target.value)} error={titleField.error || undefined} testID='createoredit-input-title-v2' />
{t('Credentials')}
} onClick={() => handleCreateOrEditRecord({ recordType: 'password', setValue: (value: string, type: PassType) => { setValue('password', value) setPasswordType( type === PassType.PassPhrase ? PassType.PassPhrase : PassType.Password ) } }) } data-testid='createoredit-button-generatepassword-v2' > {t('Generate Password')} } > usernameField.onChange(e.target.value)} error={usernameField.error || undefined} testID='createoredit-input-username-v2' /> {AUTHENTICATOR_ENABLED ? ( otpSecretField.onChange(e.target.value)} error={otpSecretField.error || undefined} testID='createoredit-input-otpsecret-v2' /> ) : null} {!!values?.credential ? ( ) : null}
{t('Details')}
} onClick={() => addWebsite({ name: 'website' })} data-testid='createoredit-button-addwebsite-v2' > {t('Add Another Website')} } > {websitesList.map((website: { id: string }, index: number) => { const websiteField = registerWebsiteItem('website', index) return ( websiteField.onChange(e.target.value)} error={websiteField.error || undefined} testID={`createoredit-input-website-v2-${index}`} rightSlot={ index > 0 ? ( } > {values.attachments.length > 0 ? values.attachments.map( ( attachment: { id?: string tempId?: string name: string }, index: number ) => ( } onClick={() => setValue( ATTACHMENTS_FIELD_KEY, getFilteredAttachmentsById( values.attachments, attachment ) ) } data-testid={`createoredit-button-deleteattachment-v2-${index}`} /> } /> ) ) : null} } /> } onClick={() => addCustomField({ type: 'note', name: 'note' })} data-testid='createoredit-button-addhiddenmessage-v2' > {t('Add Another Message')} } > {customFieldsList.map((field: { id: string }, index: number) => { const fieldReg = registerCustomFieldItem('note', index) const canRemove = customFieldsList.length > 1 return (
) } ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditNoteModalContent/index.js ================================================ import { useLingui } from '@lingui/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { RECORD_TYPES, useCreateRecord, useRecords } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { CreateCustomField } from '../../../../components/CreateCustomField' import { FolderDropdown } from '../../../../components/FolderDropdown' import { FormGroup } from '../../../../components/FormGroup' import { FormModalHeaderWrapper } from '../../../../components/FormModalHeaderWrapper' import { FormWrapper } from '../../../../components/FormWrapper' import { RecordTypeMenu } from '../../../../components/RecordTypeMenu' import { ATTACHMENTS_FIELD_KEY } from '../../../../constants/formFields' import { useGlobalLoading } from '../../../../context/LoadingContext' import { useModal } from '../../../../context/ModalContext' import { useToast } from '../../../../context/ToastContext' import { useGetMultipleFiles } from '../../../../hooks/useGetMultipleFiles' import { ButtonLittle, ButtonSingleInput, DeleteIcon, ImageIcon, InputField, SaveIcon, TextArea } from '../../../../lib-react-components' import { getFilteredAttachmentsById } from '../../../../utils/getFilteredAttachmentsById' import { handleFileSelect } from '../../../../utils/handleFileSelect' import { AttachmentField } from '../../../AttachmentField' import { CustomFields } from '../../../CustomFields' import { ModalContent } from '../../ModalContent' import { DropdownsWrapper } from '../../styles' import { UploadFilesModalContent } from '../../UploadImageModalContent' /** * @param {{ * initialRecord: { * data: { * title: string * note: string * customFields: { * type: string * name: string * }[] * attachments: { id: string, name: string}[] * } * } * selectedFolder?: string * isFavorite?: boolean * onTypeChange: (type: string) => void * }} props */ export const CreateOrEditNoteModalContent = ({ initialRecord, selectedFolder, isFavorite, onTypeChange }) => { const { i18n } = useLingui() const { closeModal, setModal } = useModal() const { setToast } = useToast() const { createRecord, isLoading: isCreateLoading } = useCreateRecord({ onCompleted: () => { closeModal() setToast({ message: i18n._('Record created successfully') }) } }) const { updateRecords, isLoading: isUpdateLoading } = useRecords({ onCompleted: () => { closeModal() setToast({ message: i18n._('Record updated successfully') }) } }) const onError = (error) => { setToast({ message: error.message }) } const isLoading = isCreateLoading || isUpdateLoading useGlobalLoading({ isLoading }) const schema = Validator.object({ title: Validator.string().required(i18n._('Title is required')), note: Validator.string(), customFields: Validator.array().items( Validator.object({ note: Validator.string().required(i18n._('Comment is required')) }) ), folder: Validator.string(), attachments: Validator.array().items( Validator.object({ id: Validator.string(), name: Validator.string().required() }) ) }) const { register, handleSubmit, registerArray, values, setValue } = useForm({ initialValues: { title: initialRecord?.data?.title ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [] }, validate: (values) => schema.validate(values) }) const { value: list, addItem, registerItem, removeItem } = registerArray('customFields') useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) const onSubmit = (values) => { const data = { type: RECORD_TYPES.NOTE, folder: values.folder, isFavorite: initialRecord?.isFavorite ?? isFavorite, data: { title: values.title, note: values.note, customFields: values.customFields, attachments: values.attachments } } if (initialRecord) { updateRecords( [ { ...initialRecord, ...data } ], onError ) } else { createRecord(data, onError) } } const handleFileLoad = () => { setModal( html`<${UploadFilesModalContent} type=${'file'} onFilesSelected=${(files) => handleFileSelect({ files, fieldName: ATTACHMENTS_FIELD_KEY, setValue, values })} />` ) } return html` <${ModalContent} onSubmit=${handleSubmit(onSubmit)} onClose=${closeModal} closeButtonDataId="note-close-button" headerChildren=${html` <${FormModalHeaderWrapper} buttons=${html` <${ButtonLittle} testId="createoredit-button-loadfile" dataId="note-load-file-button" startIcon=${ImageIcon} onClick=${handleFileLoad} > ${i18n._('Load file')} <${ButtonLittle} testId="createoredit-button-save" dataId="note-save-button" startIcon=${SaveIcon} type="submit" > ${i18n._('Save')} `} > <${DropdownsWrapper}> <${FolderDropdown} testId="createoredit-dropdown-folder" selectedFolder=${values?.folder} onFolderSelect=${(folder) => setValue('folder', folder?.name)} /> ${!initialRecord && html` <${RecordTypeMenu} testId="createoredit-dropdown-recordtype" selectedRecord=${RECORD_TYPES.NOTE} onRecordSelect=${(record) => onTypeChange(record?.type)} />`} `} > <${FormWrapper}> <${FormGroup}> <${InputField} testId="createoredit-input-title" dataId="note-title-input" label=${i18n._('Title')} placeholder=${i18n._('Insert title')} variant="outline" ...${register('title')} /> <${FormGroup}> <${TextArea} testId="createoredit-textarea-note" dataId="note-content-textarea" ...${register('note')} placeholder=${i18n._('Write a comment...')} /> ${values.attachments.length > 0 && html` <${FormGroup}> ${values.attachments.map( (attachment) => html`<${AttachmentField} testId="createoredit-attachment" key=${attachment.id || attachment.tempId} attachment=${attachment} label=${i18n._('File')} additionalItems=${html` <${ButtonSingleInput} startIcon=${DeleteIcon} onClick=${() => setValue( ATTACHMENTS_FIELD_KEY, getFilteredAttachmentsById( values.attachments, attachment ) )} > ${i18n._('Delete File')} `} />` )} `} <${CustomFields} customFields=${list} register=${registerItem} removeItem=${removeItem} /> <${FormGroup}> <${CreateCustomField} testId="createoredit-button-createcustom" onCreateCustom=${(type) => addItem({ type: type, name: type })} /> ` } ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditNoteModalContentV2/CreateOrEditNoteModalContentV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ form: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, width: '100%' }, sectionLabel: { marginTop: `${rawTokens.spacing8}px` } }) ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditNoteModalContentV2/CreateOrEditNoteModalContentV2.tsx ================================================ import React from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { AttachmentField as UiKitAttachmentField, Button, Dialog, Form, InputField, MultiSlotInput, PasswordField, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { useCreateRecord, useRecords } from '@tetherto/pearpass-lib-vault' import { Add, TrashOutlined, UploadFileFilled } from '@tetherto/pearpass-lib-ui-kit/icons' import { html } from 'htm/react' import { createStyles } from './CreateOrEditNoteModalContentV2.styles' import { ATTACHMENTS_FIELD_KEY } from '../../../../constants/formFields' import { useGlobalLoading } from '../../../../context/LoadingContext' import { useModal } from '../../../../context/ModalContext' import { useToast } from '../../../../context/ToastContext' import { useTranslation } from '../../../../hooks/useTranslation' import { useGetMultipleFiles } from '../../../../hooks/useGetMultipleFiles' import { getFilteredAttachmentsById } from '../../../../utils/getFilteredAttachmentsById' import { handleFileSelect } from '../../../../utils/handleFileSelect' import { UploadFilesModalContentV2 } from '../../UploadFilesModalContentV2' import { FolderDropdownV2 } from '../../../../components/FolderDropdown/FolderDropdownV2' export type CreateOrEditNoteModalContentV2Props = { initialRecord?: { data: { title: string note: string customFields: { type: string; name: string }[] attachments: { id: string; name: string }[] [key: string]: unknown } folder?: string isFavorite?: boolean attachments?: { id: string; name: string }[] [key: string]: unknown } selectedFolder?: string isFavorite?: boolean onTypeChange: (type: string) => void } export const CreateOrEditNoteModalContentV2 = ({ initialRecord, selectedFolder, isFavorite, onTypeChange: _onTypeChange }: CreateOrEditNoteModalContentV2Props) => { const { t } = useTranslation() const { closeModal, setModal } = useModal() const { setToast } = useToast() const { theme } = useTheme() const styles = createStyles() const { createRecord, isLoading: isCreateLoading } = useCreateRecord({ onCompleted: () => { closeModal() setToast({ message: t('Record created successfully') }) } }) const { updateRecords, isLoading: isUpdateLoading } = useRecords({ onCompleted: () => { closeModal() setToast({ message: t('Record updated successfully') }) } }) const onError = (error: { message: string }) => { setToast({ message: error.message }) } const isLoading = isCreateLoading || isUpdateLoading useGlobalLoading({ isLoading }) const schema = Validator.object({ title: Validator.string().required(t('Title is required')), note: Validator.string(), customFields: Validator.array().items( Validator.object({ note: Validator.string() }) ), folder: Validator.string(), attachments: Validator.array().items( Validator.object({ id: Validator.string(), name: Validator.string().required() }) ) }) const { register, handleSubmit, registerArray, values, setValue } = useForm({ initialValues: { title: initialRecord?.data?.title ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields?.length ? initialRecord.data.customFields : [{ type: 'note', note: '' }], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [] }, validate: (formValues: Record) => schema.validate(formValues) }) const { value: customFieldsList, addItem: addCustomField, registerItem: registerCustomFieldItem, removeItem: removeCustomFieldItem } = registerArray('customFields') useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) const onSubmit = (formValues: Record) => { const data = { type: RECORD_TYPES.NOTE, folder: formValues.folder, isFavorite: initialRecord?.isFavorite ?? isFavorite, data: { ...(initialRecord?.data ? initialRecord.data : {}), title: formValues.title, note: formValues.note, customFields: ( (formValues.customFields as Array<{ type: string; note?: string }>) ?? [] ).filter((f) => f.note?.trim().length), attachments: formValues.attachments } } if (initialRecord) { updateRecords([{ ...initialRecord, ...data }], onError) } else { createRecord(data, onError) } } const handleFileLoad = () => { setModal( html`<${UploadFilesModalContentV2} type=${'file'} onFilesSelected=${(files: File[]) => handleFileSelect({ files: files as unknown as FileList, fieldName: ATTACHMENTS_FIELD_KEY, setValue, values })} />` ) } const isEdit = !!initialRecord const titleField = register('title') const noteField = register('note') return ( } >
['style']} testID="createoredit-note-form-v2" > titleField.onChange(e.target.value)} error={titleField.error || undefined} testID="createoredit-note-input-title-v2" />
{t('Details')}
noteField.onChange(e.target.value)} error={noteField.error || undefined} testID="createoredit-note-input-note-v2" />
{t('Additional')}
setValue('folder', name === values.folder ? '' : name) } /> } onClick={handleFileLoad} data-testid="createoredit-note-button-addattachment-v2" > {t('Add Another Attachment')} } > {values.attachments.length > 0 ? values.attachments.map( ( attachment: { id?: string tempId?: string name: string }, index: number ) => ( } onClick={() => setValue( ATTACHMENTS_FIELD_KEY, getFilteredAttachmentsById( values.attachments, attachment ) ) } data-testid={`createoredit-note-button-deleteattachment-v2-${index}`} /> } /> ) ) : null} } /> } onClick={() => addCustomField({ type: 'note', note: '' })} data-testid="createoredit-note-button-addhiddenmessage-v2" > {t('Add Another Message')} } > {customFieldsList.map((field: { id: string }, index: number) => { const fieldReg = registerCustomFieldItem('note', index) const canRemove = customFieldsList.length > 1 return (
) } ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditPassPhraseModalContent/index.js ================================================ import { useLingui } from '@lingui/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { RECORD_TYPES, useCreateRecord, useRecords } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { CreateCustomField } from '../../../../components/CreateCustomField' import { FolderDropdown } from '../../../../components/FolderDropdown' import { FormGroup } from '../../../../components/FormGroup' import { FormModalHeaderWrapper } from '../../../../components/FormModalHeaderWrapper' import { FormWrapper } from '../../../../components/FormWrapper' import { InputFieldNote } from '../../../../components/InputFieldNote' import { RecordTypeMenu } from '../../../../components/RecordTypeMenu' import { useGlobalLoading } from '../../../../context/LoadingContext' import { useModal } from '../../../../context/ModalContext' import { useToast } from '../../../../context/ToastContext' import { ButtonLittle, InputField, SaveIcon } from '../../../../lib-react-components' import { CubeIcon } from '../../../../lib-react-components/icons/CubeIcon' import { CustomFields } from '../../../CustomFields' import { PassPhrase } from '../../../PassPhrase' import { ModalContent } from '../../ModalContent' import { DropdownsWrapper } from '../../styles' /** * @param {{ * initialRecord: { * data: { * title: string * passPhrase: string * note: string * customFields: { * type: string * name: string * }[] * } * } * selectedFolder?: string * isFavorite?: boolean * onTypeChange: (type: string) => void * }} props */ export const CreateOrEditPassPhraseModalContent = ({ initialRecord, selectedFolder, isFavorite, onTypeChange }) => { const { i18n } = useLingui() const { closeModal } = useModal() const { setToast } = useToast() const { createRecord, isLoading: isCreateLoading } = useCreateRecord({ onCompleted: () => { closeModal() setToast({ message: i18n._('Record created successfully') }) } }) const { updateRecords, isLoading: isUpdateLoading } = useRecords({ onCompleted: () => { closeModal() setToast({ message: i18n._('Record updated successfully') }) } }) const onError = (error) => { setToast({ message: error.message }) } const isLoading = isCreateLoading || isUpdateLoading useGlobalLoading({ isLoading }) const schema = Validator.object({ title: Validator.string().required(i18n._('Title is required')), passPhrase: Validator.string().required(i18n._('PassPhrase is required')), note: Validator.string(), customFields: Validator.array().items( Validator.object({ note: Validator.string().required(i18n._('Comment is required')) }) ), folder: Validator.string() }) const { register, handleSubmit, registerArray, values, setValue } = useForm({ initialValues: { title: initialRecord?.data?.title ?? '', passPhrase: initialRecord?.data?.passPhrase ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder }, validate: (values) => schema.validate(values) }) const { value: list, addItem, registerItem, removeItem } = registerArray('customFields') const onSubmit = (values) => { const data = { type: RECORD_TYPES.PASS_PHRASE, folder: values.folder, isFavorite: initialRecord?.isFavorite ?? isFavorite, data: { title: values.title, passPhrase: values.passPhrase, note: values.note, customFields: values.customFields } } if (initialRecord) { updateRecords( [ { ...initialRecord, ...data } ], onError ) } else { createRecord(data, onError) } } return html` <${ModalContent} onSubmit=${handleSubmit(onSubmit)} onClose=${closeModal} headerChildren=${html` <${FormModalHeaderWrapper} buttons=${html` <${ButtonLittle} testId="createoredit-button-save" startIcon=${SaveIcon} type="submit" > ${i18n._('Save')} `} > <${DropdownsWrapper}> <${FolderDropdown} testId="createoredit-dropdown-folder" selectedFolder=${values?.folder} onFolderSelect=${(folder) => setValue('folder', folder?.name)} /> ${!initialRecord && html` <${RecordTypeMenu} testId="createoredit-dropdown-recordtype" selectedRecord=${RECORD_TYPES.PASS_PHRASE} onRecordSelect=${(record) => onTypeChange(record?.type)} />`} `} > <${FormWrapper}> <${FormGroup}> <${InputField} testId="createoredit-input-title" icon=${CubeIcon} label=${i18n._('Application')} placeholder=${i18n._('Insert Application name')} variant="outline" ...${register('title')} /> <${FormGroup}> <${PassPhrase} isCreateOrEdit ...${register('passPhrase')} /> <${FormGroup}> <${InputFieldNote} ...${register('note')} /> <${CustomFields} customFields=${list} register=${registerItem} removeItem=${removeItem} /> <${FormGroup}> <${CreateCustomField} onCreateCustom=${(type) => addItem({ type: type, name: type })} /> ` } ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditPassPhraseModalContentV2/CreateOrEditPassPhraseModalContentV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ form: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, width: '100%' }, sectionLabel: { marginTop: `${rawTokens.spacing8}px` } }) ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditPassPhraseModalContentV2/CreateOrEditPassPhraseModalContentV2.tsx ================================================ import React from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { VALID_WORD_COUNTS } from '@tetherto/pearpass-lib-constants' import { Button, Dialog, Form, InputField, MultiSlotInput, PasswordField, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { useCreateRecord, useRecords } from '@tetherto/pearpass-lib-vault' import { Add, TrashOutlined } from '@tetherto/pearpass-lib-ui-kit/icons' import { createStyles } from './CreateOrEditPassPhraseModalContentV2.styles' import { PassPhraseV2 } from '../../../PassPhrase/PassPhraseV2' import { useGlobalLoading } from '../../../../context/LoadingContext' import { useModal } from '../../../../context/ModalContext' import { useToast } from '../../../../context/ToastContext' import { useTranslation } from '../../../../hooks/useTranslation' import { FolderDropdownV2 } from '../../../../components/FolderDropdown/FolderDropdownV2' export type CreateOrEditPassPhraseModalContentV2Props = { initialRecord?: { data: { title: string passPhrase: string note: string customFields: { type: string; name: string }[] [key: string]: unknown } folder?: string isFavorite?: boolean [key: string]: unknown } selectedFolder?: string isFavorite?: boolean onTypeChange: (type: string) => void } const parsePassphraseText = (text: string): string[] => text .trim() .split(/[-\s]+/) .map((word) => word.trim()) .filter((word) => word.length > 0) export const CreateOrEditPassPhraseModalContentV2 = ({ initialRecord, selectedFolder, isFavorite, onTypeChange: _onTypeChange }: CreateOrEditPassPhraseModalContentV2Props) => { const { t } = useTranslation() const { closeModal } = useModal() const { setToast } = useToast() const { theme } = useTheme() const styles = createStyles() const { createRecord, isLoading: isCreateLoading } = useCreateRecord({ onCompleted: () => { closeModal() setToast({ message: t('Record created successfully') }) } }) const { updateRecords, isLoading: isUpdateLoading } = useRecords({ onCompleted: () => { closeModal() setToast({ message: t('Record updated successfully') }) } }) const onError = (error: { message: string }) => { setToast({ message: error.message }) } const isLoading = isCreateLoading || isUpdateLoading useGlobalLoading({ isLoading }) const schema = Validator.object({ title: Validator.string().required(t('Title is required')), passPhrase: Validator.string().required(t('Recovery phrase is required')), note: Validator.string(), customFields: Validator.array().items( Validator.object({ note: Validator.string() }) ), folder: Validator.string() }) const { register, handleSubmit, registerArray, setValue, values } = useForm({ initialValues: { title: initialRecord?.data?.title ?? '', passPhrase: initialRecord?.data?.passPhrase ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields?.length ? initialRecord.data.customFields : [{ type: 'note', note: '' }], folder: selectedFolder ?? initialRecord?.folder }, validate: (formValues: Record) => { const validationErrors = (schema.validate(formValues) as Record) ?? {} const wordCount = parsePassphraseText( (formValues.passPhrase as string) ?? '' ).length if (!wordCount) { validationErrors.passPhrase = t('Recovery phrase is required') } else if (!VALID_WORD_COUNTS.includes(wordCount)) { validationErrors.passPhrase = t('Recovery phrase must contain 12 or 24 words') } return validationErrors } }) const { value: customFieldsList, addItem: addCustomField, registerItem: registerCustomFieldItem, removeItem: removeCustomFieldItem } = registerArray('customFields') const onSubmit = (formValues: Record) => { const data = { type: RECORD_TYPES.PASS_PHRASE, folder: formValues.folder, isFavorite: initialRecord?.isFavorite ?? isFavorite, data: { ...(initialRecord?.data ? initialRecord.data : {}), title: formValues.title, passPhrase: formValues.passPhrase, note: formValues.note, customFields: ( (formValues.customFields as Array<{ type: string; note?: string }>) ?? [] ).filter((f) => f.note?.trim().length) } } if (initialRecord) { updateRecords([{ ...initialRecord, ...data }], onError) } else { createRecord(data, onError) } } const isEdit = !!initialRecord const titleField = register('title') const noteField = register('note') const passPhraseField = register('passPhrase') return ( } >
['style']} testID="createoredit-passphrase-form-v2" > titleField.onChange(e.target.value)} error={titleField.error || undefined} testID="createoredit-passphrase-input-title-v2" />
{t('Details')}
setValue('passPhrase', val)} error={passPhraseField.error || undefined} />
{t('Additional')}
setValue('folder', name === values.folder ? '' : name) } /> noteField.onChange(e.target.value)} error={noteField.error || undefined} testID="createoredit-passphrase-input-comment-v2" /> } onClick={() => addCustomField({ type: 'note', note: '' })} data-testid="createoredit-passphrase-button-addhiddenmessage-v2" > {t('Add Another Message')} } > {customFieldsList.map((field: { id: string }, index: number) => { const fieldReg = registerCustomFieldItem('note', index) const canRemove = customFieldsList.length > 1 return (
) } ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditWifiModalContent/index.js ================================================ import { useLingui } from '@lingui/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { RECORD_TYPES, useCreateRecord, useRecords } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { CreateCustomField } from '../../../../components/CreateCustomField' import { FolderDropdown } from '../../../../components/FolderDropdown' import { FormGroup } from '../../../../components/FormGroup' import { FormModalHeaderWrapper } from '../../../../components/FormModalHeaderWrapper' import { FormWrapper } from '../../../../components/FormWrapper' import { InputFieldNote } from '../../../../components/InputFieldNote' import { RecordTypeMenu } from '../../../../components/RecordTypeMenu' import { ATTACHMENTS_FIELD_KEY } from '../../../../constants/formFields' import { useGlobalLoading } from '../../../../context/LoadingContext' import { useModal } from '../../../../context/ModalContext' import { useToast } from '../../../../context/ToastContext' import { useCreateOrEditRecord } from '../../../../hooks/useCreateOrEditRecord' import { useGetMultipleFiles } from '../../../../hooks/useGetMultipleFiles' import { ButtonLittle, ButtonRoundIcon, ButtonSingleInput, DeleteIcon, ImageIcon, InputField, PasswordField, PasswordIcon, SaveIcon } from '../../../../lib-react-components' import { WifiIcon } from '../../../../lib-react-components/icons/WifiIcon' import { getFilteredAttachmentsById } from '../../../../utils/getFilteredAttachmentsById' import { handleFileSelect } from '../../../../utils/handleFileSelect' import { AttachmentField } from '../../../AttachmentField' import { CustomFields } from '../../../CustomFields' import { ModalContent } from '../../ModalContent' import { DropdownsWrapper } from '../../styles' import { UploadFilesModalContent } from '../../UploadImageModalContent' /** * @param {{ * initialRecord: { * data: { * title: string * password: string * note: string * customFields: { * type: string * name: string * }[] * attachments: { id: string, name: string}[] * } * } * selectedFolder?: string * isFavorite?: boolean * onTypeChange: (type: string) => void * }} props */ export const CreateOrEditWifiModalContent = ({ initialRecord, selectedFolder, isFavorite, onTypeChange }) => { const { i18n } = useLingui() const { closeModal, setModal } = useModal() const { handleCreateOrEditRecord } = useCreateOrEditRecord() const { setToast } = useToast() const { createRecord, isLoading: isCreateLoading } = useCreateRecord({ onCompleted: () => { closeModal() setToast({ message: i18n._('Record created successfully') }) } }) const { updateRecords, isLoading: isUpdateLoading } = useRecords({ onCompleted: () => { closeModal() setToast({ message: i18n._('Record updated successfully') }) } }) const onError = (error) => { setToast({ message: error.message }) } const isLoading = isCreateLoading || isUpdateLoading useGlobalLoading({ isLoading }) const schema = Validator.object({ title: Validator.string().required(i18n._('Title is required')), password: Validator.string().required(i18n._('Password is required')), note: Validator.string(), customFields: Validator.array().items( Validator.object({ note: Validator.string().required(i18n._('Comment is required')) }) ), folder: Validator.string(), attachments: Validator.array().items( Validator.object({ id: Validator.string(), name: Validator.string().required() }) ) }) const { register, handleSubmit, registerArray, values, setValue } = useForm({ initialValues: { title: initialRecord?.data?.title ?? '', password: initialRecord?.data?.password ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [] }, validate: (values) => schema.validate(values) }) const { value: list, addItem, registerItem, removeItem } = registerArray('customFields') useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) const onSubmit = (values) => { const data = { type: RECORD_TYPES.WIFI_PASSWORD, folder: values.folder, isFavorite: initialRecord?.isFavorite ?? isFavorite, data: { title: values.title, password: values.password, note: values.note, customFields: values.customFields, attachments: values.attachments } } if (initialRecord) { updateRecords( [ { ...initialRecord, ...data } ], onError ) } else { createRecord(data, onError) } } const handleFileLoad = () => { setModal( html`<${UploadFilesModalContent} type=${'file'} onFilesSelected=${(files) => handleFileSelect({ files, fieldName: ATTACHMENTS_FIELD_KEY, setValue, values })} />` ) } return html` <${ModalContent} onSubmit=${handleSubmit(onSubmit)} onClose=${closeModal} headerChildren=${html` <${FormModalHeaderWrapper} buttons=${html` <${ButtonLittle} testId="createoredit-button-loadfile" startIcon=${ImageIcon} onClick=${handleFileLoad} > ${i18n._('Load file')} <${ButtonLittle} testId="createoredit-button-save" startIcon=${SaveIcon} type="submit" > ${i18n._('Save')} `} > <${DropdownsWrapper}> <${FolderDropdown} testId="createoredit-dropdown-folder" selectedFolder=${values?.folder} onFolderSelect=${(folder) => setValue('folder', folder?.name)} /> ${!initialRecord && html` <${RecordTypeMenu} testId="createoredit-dropdown-recordtype" selectedRecord=${RECORD_TYPES.WIFI_PASSWORD} onRecordSelect=${(record) => onTypeChange(record?.type)} />`} `} > <${FormWrapper}> <${FormGroup}> <${InputField} testId="createoredit-input-wifiname" icon=${WifiIcon} label=${i18n._('Wi-Fi Name')} placeholder=${i18n._('Insert Wi-Fi Name')} variant="outline" ...${register('title')} /> <${FormGroup}> <${PasswordField} testId="createoredit-input-wifipassword" icon=${PasswordIcon} label=${i18n._('Wi-Fi Password')} placeholder=${i18n._('Insert Wi-Fi Password')} variant="outline" additionalItems=${html` <${ButtonRoundIcon} testId="createoredit-button-generatepassword" startIcon=${PasswordIcon} onClick=${() => handleCreateOrEditRecord({ recordType: 'password', setValue: (value) => setValue('password', value) })} /> `} ...${register('password')} /> <${FormGroup}> <${InputFieldNote} testId="createoredit-input-note" ...${register('note')} /> ${values.attachments.length > 0 && html` <${FormGroup}> ${values.attachments.map( (attachment) => html`<${AttachmentField} testId="createoredit-attachment" key=${attachment.id || attachment.tempId} attachment=${attachment} label=${i18n._('File')} additionalItems=${html` <${ButtonSingleInput} testId="createoredit-button-deleteattachment" startIcon=${DeleteIcon} onClick=${() => setValue( ATTACHMENTS_FIELD_KEY, getFilteredAttachmentsById( values.attachments, attachment ) )} > ${i18n._('Delete File')} `} />` )} `} <${CustomFields} customFields=${list} register=${registerItem} removeItem=${removeItem} /> <${FormGroup}> <${CreateCustomField} testId="createoredit-button-createcustom" onCreateCustom=${(type) => addItem({ type: type, name: type })} /> ` } ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditWifiModalContentV2/CreateOrEditWifiModalContentV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ form: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, width: '100%' }, sectionLabel: { marginTop: `${rawTokens.spacing8}px` } }) ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/CreateOrEditWifiModalContentV2/CreateOrEditWifiModalContentV2.tsx ================================================ import React, { useState } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { Button, Dialog, Form, InputField, MultiSlotInput, PasswordField, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { useCreateRecord, useRecords } from '@tetherto/pearpass-lib-vault' import { Add, SyncLock, TrashOutlined } from '@tetherto/pearpass-lib-ui-kit/icons' import { createStyles } from './CreateOrEditWifiModalContentV2.styles' import { useGlobalLoading } from '../../../../context/LoadingContext' import { useModal } from '../../../../context/ModalContext' import { useToast } from '../../../../context/ToastContext' import { useTranslation } from '../../../../hooks/useTranslation' import { useCreateOrEditRecord } from '../../../../hooks/useCreateOrEditRecord' import { PasswordFieldStrengthIndicator } from '../../../../components/PasswordFieldStrengthIndicator' import { PassType } from '../../../../shared/types' import { FolderDropdownV2 } from '../../../../components/FolderDropdown/FolderDropdownV2' export type CreateOrEditWifiModalContentV2Props = { initialRecord?: { data: { title: string password: string note: string customFields: { type: string; name: string }[] [key: string]: unknown } folder?: string isFavorite?: boolean [key: string]: unknown } selectedFolder?: string isFavorite?: boolean onTypeChange: (type: string) => void } export const CreateOrEditWifiModalContentV2 = ({ initialRecord, selectedFolder, isFavorite, onTypeChange: _onTypeChange }: CreateOrEditWifiModalContentV2Props) => { const { t } = useTranslation() const { closeModal } = useModal() const { handleCreateOrEditRecord } = useCreateOrEditRecord() const [passwordType, setPasswordType] = useState(PassType.Password) const { setToast } = useToast() const { theme } = useTheme() const styles = createStyles() const { createRecord, isLoading: isCreateLoading } = useCreateRecord({ onCompleted: () => { closeModal() setToast({ message: t('Record created successfully') }) } }) const { updateRecords, isLoading: isUpdateLoading } = useRecords({ onCompleted: () => { closeModal() setToast({ message: t('Record updated successfully') }) } }) const onError = (error: { message: string }) => { setToast({ message: error.message }) } const isLoading = isCreateLoading || isUpdateLoading useGlobalLoading({ isLoading }) const schema = Validator.object({ title: Validator.string().required(t('Name is required')), password: Validator.string().required(t('Password is required')), note: Validator.string(), customFields: Validator.array().items( Validator.object({ note: Validator.string() }) ), folder: Validator.string() }) const { register, handleSubmit, registerArray, setValue, values } = useForm({ initialValues: { title: initialRecord?.data?.title ?? '', password: initialRecord?.data?.password ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields?.length ? initialRecord.data.customFields : [{ type: 'note', note: '' }], folder: selectedFolder ?? initialRecord?.folder }, validate: (formValues: Record) => schema.validate(formValues) }) const { value: customFieldsList, addItem: addCustomField, registerItem: registerCustomFieldItem, removeItem: removeCustomFieldItem } = registerArray('customFields') const onSubmit = (formValues: Record) => { const data = { type: RECORD_TYPES.WIFI_PASSWORD, folder: formValues.folder, isFavorite: initialRecord?.isFavorite ?? isFavorite, data: { ...(initialRecord?.data ? initialRecord.data : {}), title: formValues.title, password: formValues.password, note: formValues.note, customFields: ( (formValues.customFields as Array<{ type: string; note?: string }>) ?? [] ).filter((f) => f.note?.trim().length) } } if (initialRecord) { updateRecords([{ ...initialRecord, ...data }], onError) } else { createRecord(data, onError) } } const isEdit = !!initialRecord const titleField = register('title') const passwordField = register('password') const noteField = register('note') return ( } >
['style']} testID="createoredit-wifi-form-v2" >
{t('Credentials')}
} onClick={() => handleCreateOrEditRecord({ recordType: 'password', setValue: (value: string, type: PassType) => { setValue('password', value) setPasswordType(type === PassType.PassPhrase ? PassType.PassPhrase : PassType.Password) } }) } data-testid="createoredit-wifi-button-generatepassword-v2" > {t('Generate Password')} } > titleField.onChange(e.target.value)} error={titleField.error || undefined} testID="createoredit-wifi-input-name-v2" />
{t('Additional')}
setValue('folder', name === values.folder ? '' : name) } /> noteField.onChange(e.target.value)} error={noteField.error || undefined} testID="createoredit-wifi-input-comment-v2" /> } onClick={() => addCustomField({ type: 'note', note: '' })} data-testid="createoredit-wifi-button-addhiddenmessage-v2" > {t('Add Another Message')} } > {customFieldsList.map((field: { id: string }, index: number) => { const fieldReg = registerCustomFieldItem('note', index) const canRemove = customFieldsList.length > 1 return (
) } ================================================ FILE: src/containers/Modal/CreateOrEditCategoryWrapper/index.js ================================================ import { useState } from 'react' import { RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { CreateOrEditAuthenticatorModalContent } from './CreateOrEditAuthenticatorModalContent/CreateOrEditAuthenticatorModalContent' import { CreateOrEditCreditCardModalContent } from './CreateOrEditCreditCardModalContent' import { CreateOrEditCreditCardModalContentV2 } from './CreateOrEditCreditCardModalContentV2/CreateOrEditCreditCardModalContentV2' import { CreateOrEditCustomModalContent } from './CreateOrEditCustomModalContent' import { CreateOrEditCustomModalContentV2 } from './CreateOrEditCustomModalContentV2/CreateOrEditCustomModalContentV2' import { CreateOrEditIdentityModalContent } from './CreateOrEditIdentityModalContent' import { CreateOrEditIdentityModalContentV2 } from './CreateOrEditIdentityModalContentV2/CreateOrEditIdentityModalContentV2' import { CreateOrEditLoginModalContent } from './CreateOrEditLoginModalContent' import { CreateOrEditLoginModalContentV2 } from './CreateOrEditLoginModalContentV2/CreateOrEditLoginModalContentV2' import { CreateOrEditNoteModalContent } from './CreateOrEditNoteModalContent' import { CreateOrEditNoteModalContentV2 } from './CreateOrEditNoteModalContentV2/CreateOrEditNoteModalContentV2' import { CreateOrEditPassPhraseModalContent } from './CreateOrEditPassPhraseModalContent' import { CreateOrEditPassPhraseModalContentV2 } from './CreateOrEditPassPhraseModalContentV2/CreateOrEditPassPhraseModalContentV2' import { CreateOrEditWifiModalContent } from './CreateOrEditWifiModalContent' import { CreateOrEditWifiModalContentV2 } from './CreateOrEditWifiModalContentV2/CreateOrEditWifiModalContentV2' import { isV2 } from '../../../utils/designVersion' export const CreateOrEditCategoryWrapper = ({ initialRecord, selectedFolder, recordType, isFavorite }) => { const [currentRecordType, setCurrentRecordType] = useState(recordType) if (currentRecordType === RECORD_TYPES.OTP) { return html`<${CreateOrEditAuthenticatorModalContent} initialRecord=${initialRecord} selectedFolder=${selectedFolder} isFavorite=${isFavorite} onTypeChange=${setCurrentRecordType} />` } if (currentRecordType === RECORD_TYPES.LOGIN) { if (isV2()) { return html`<${CreateOrEditLoginModalContentV2} initialRecord=${initialRecord} selectedFolder=${selectedFolder} isFavorite=${isFavorite} onTypeChange=${setCurrentRecordType} />` } return html`<${CreateOrEditLoginModalContent} initialRecord=${initialRecord} selectedFolder=${selectedFolder} isFavorite=${isFavorite} onTypeChange=${setCurrentRecordType} />` } if (currentRecordType === RECORD_TYPES.CREDIT_CARD) { if (isV2()) { return html`<${CreateOrEditCreditCardModalContentV2} initialRecord=${initialRecord} selectedFolder=${selectedFolder} isFavorite=${isFavorite} onTypeChange=${setCurrentRecordType} />` } return html`<${CreateOrEditCreditCardModalContent} initialRecord=${initialRecord} selectedFolder=${selectedFolder} isFavorite=${isFavorite} onTypeChange=${setCurrentRecordType} />` } if (currentRecordType === RECORD_TYPES.IDENTITY) { if (isV2()) { return html`<${CreateOrEditIdentityModalContentV2} initialRecord=${initialRecord} selectedFolder=${selectedFolder} isFavorite=${isFavorite} onTypeChange=${setCurrentRecordType} />` } return html`<${CreateOrEditIdentityModalContent} initialRecord=${initialRecord} selectedFolder=${selectedFolder} isFavorite=${isFavorite} onTypeChange=${setCurrentRecordType} />` } if (currentRecordType === RECORD_TYPES.NOTE) { if (isV2()) { return html`<${CreateOrEditNoteModalContentV2} initialRecord=${initialRecord} selectedFolder=${selectedFolder} isFavorite=${isFavorite} onTypeChange=${setCurrentRecordType} />` } return html`<${CreateOrEditNoteModalContent} initialRecord=${initialRecord} selectedFolder=${selectedFolder} isFavorite=${isFavorite} onTypeChange=${setCurrentRecordType} />` } if (currentRecordType === RECORD_TYPES.WIFI_PASSWORD) { if (isV2()) { return html`<${CreateOrEditWifiModalContentV2} initialRecord=${initialRecord} selectedFolder=${selectedFolder} isFavorite=${isFavorite} onTypeChange=${setCurrentRecordType} />` } return html`<${CreateOrEditWifiModalContent} initialRecord=${initialRecord} selectedFolder=${selectedFolder} isFavorite=${isFavorite} onTypeChange=${setCurrentRecordType} />` } if (currentRecordType === RECORD_TYPES.PASS_PHRASE) { if (isV2()) { return html`<${CreateOrEditPassPhraseModalContentV2} initialRecord=${initialRecord} selectedFolder=${selectedFolder} isFavorite=${isFavorite} onTypeChange=${setCurrentRecordType} />` } return html`<${CreateOrEditPassPhraseModalContent} initialRecord=${initialRecord} selectedFolder=${selectedFolder} isFavorite=${isFavorite} onTypeChange=${setCurrentRecordType} />` } if (currentRecordType === RECORD_TYPES.CUSTOM) { if (isV2()) { return html`<${CreateOrEditCustomModalContentV2} initialRecord=${initialRecord} selectedFolder=${selectedFolder} isFavorite=${isFavorite} onTypeChange=${setCurrentRecordType} />` } return html`<${CreateOrEditCustomModalContent} initialRecord=${initialRecord} selectedFolder=${selectedFolder} isFavorite=${isFavorite} onTypeChange=${setCurrentRecordType} />` } } ================================================ FILE: src/containers/Modal/CreateOrEditVaultModalContentV2/CreateOrEditVaultModalContentV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ form: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing16}px`, width: '100%' } }) ================================================ FILE: src/containers/Modal/CreateOrEditVaultModalContentV2/CreateOrEditVaultModalContentV2.tsx ================================================ import React, { useEffect, useState } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { Button, Dialog, Form, InputField } from '@tetherto/pearpass-lib-ui-kit' import { useCreateVault, useVault, type Vault } from '@tetherto/pearpass-lib-vault' import { createStyles } from './CreateOrEditVaultModalContentV2.styles' import { useGlobalLoading } from '../../../context/LoadingContext' import { useRouter } from '../../../context/RouterContext' import { useTranslation } from '../../../hooks/useTranslation' import { getDeviceName } from '../../../utils/getDeviceName' import { logger } from '../../../utils/logger' export type CreateOrEditVaultModalContentV2Props = { onClose: () => void onSuccess?: () => void vault?: Vault shouldRedirectToMain?: boolean } export const CreateOrEditVaultModalContentV2 = ({ onClose, onSuccess, vault, shouldRedirectToMain = true }: CreateOrEditVaultModalContentV2Props) => { const isEditMode = !!vault const { t } = useTranslation() const { navigate } = useRouter() const styles = createStyles() const [isLoading, setIsLoading] = useState(false) useGlobalLoading({ isLoading }) const schema = Validator.object({ name: Validator.string().required(t('Name is required')) }) const { addDevice, updateUnprotectedVault, refetch: refetchVault } = useVault() const { createVault } = useCreateVault() const { register, handleSubmit, values } = useForm({ initialValues: { name: vault?.name ?? '' }, validate: (formValues: { name: string }) => schema.validate(formValues) }) useEffect(() => { if (isEditMode) { void refetchVault() } // TODO: refetchVault ref changes on every vault state update; including it // would re-trigger this effect in an infinite loop. // Make it have stable reference by wrapping in useCallback. }, [isEditMode]) const nameField = register('name') const nameOk = values.name.trim().length > 0 const isSaveDisabled = !nameOk || isLoading const submit = async (formValues: { name: string }) => { if (isLoading) { return } if (isEditMode && vault) { try { setIsLoading(true) await updateUnprotectedVault(vault.id, { name: formValues.name }) setIsLoading(false) onSuccess?.() } catch (error) { setIsLoading(false) logger.error( 'CreateOrEditVaultModalContentV2', 'Error renaming vault:', error ) } return } try { setIsLoading(true) await createVault({ name: formValues.name, password: '' }) await addDevice(getDeviceName()) onSuccess?.() if(shouldRedirectToMain) { navigate('vault', { recordType: 'all' }) } setIsLoading(false) } catch (error) { setIsLoading(false) logger.error( 'CreateOrEditVaultModalContentV2', 'Error creating vault:', error ) } } const nameError = nameField.error return ( } >
['style']} testID="createvault-form-v2" > nameField.onChange(v)} variant={nameError ? 'error' : 'default'} errorMessage={nameError || undefined} testID="createvault-name-v2" />
) } ================================================ FILE: src/containers/Modal/CreateVaultModalContent/index.js ================================================ import { useMemo, useState } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { PROTECTED_VAULT_ENABLED } from '@tetherto/pearpass-lib-constants' import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' import { useCreateVault, useVault } from '@tetherto/pearpass-lib-vault' import { checkPasswordStrength } from '@tetherto/pearpass-utils-password-check' import { html } from 'htm/react' import { AccordionContent, AccordionFields, AccordionRow, AccordionSection, Actions, BackButton, BackButtonTitle, CancelButton, ContinueButtonWrapper, FieldGroup, HeaderTitle, InputIconWrapper, InputRow, InputRowLeft, VaultNameRow, InputText, Label, PasswordErrorRow, PasswordRequirements, PasswordRowRight, PasswordStrength, RequirementsItem, RequirementsList, IconOnlyButton, RoundArrowButton, Wrapper } from './styles' import { useGlobalLoading } from '../../../context/LoadingContext' import { useRouter } from '../../../context/RouterContext' import { useTranslation } from '../../../hooks/useTranslation' import { ButtonPrimary, ErrorIcon, EyeClosedIcon, EyeIcon, LockCircleIcon, OkayIcon, YellowErrorIcon, NoticeText, SmallArrowIcon } from '../../../lib-react-components' import { getDeviceName } from '../../../utils/getDeviceName' import { logger } from '../../../utils/logger' import { ModalContent } from '../ModalContent' /** * @param {{ * onClose: () => void * onSuccess?: () => void * }} props */ export const CreateVaultModalContent = ({ onClose, onSuccess }) => { const { t } = useTranslation() const { navigate } = useRouter() const [isPasswordOpen, setIsPasswordOpen] = useState(false) const [isPasswordVisible, setIsPasswordVisible] = useState(false) const [isPasswordConfirmVisible, setIsPasswordConfirmVisible] = useState(false) const [isLoading, setIsLoading] = useState(false) useGlobalLoading({ isLoading }) const schema = Validator.object({ name: Validator.string().required(t('Name is required')), password: Validator.string(), passwordConfirm: Validator.string() }) const { addDevice } = useVault() const { createVault } = useCreateVault() const { register, handleSubmit, setErrors } = useForm({ initialValues: { name: '', password: '', passwordConfirm: '' }, validate: (values) => schema.validate(values) }) const nameField = register('name') const { onChange: onPasswordChange, ...passwordField } = register('password') const { onChange: onPasswordConfirmChange, ...passwordConfirmField } = register('passwordConfirm') const passwordStrengthResult = useMemo( () => checkPasswordStrength(passwordField.value || '', { rules: { length: 12 }, errors: { minLength: t('Password must be at least 12 characters long'), hasLowerCase: t('Password is missing a lowercase letter'), hasUpperCase: t('Password is missing an uppercase letter'), hasNumbers: t('Password is missing a number'), hasSymbols: t('Password is missing a special character') } }), [passwordField.value, t] ) const strengthIcon = useMemo(() => { switch (passwordStrengthResult.strengthType) { case 'error': return ErrorIcon case 'warning': return YellowErrorIcon case 'success': return OkayIcon default: return null } }, [passwordStrengthResult.strengthType]) const submit = async (values) => { if (isLoading) { return } if (values.password) { const strengthResult = checkPasswordStrength(values.password, { rules: { length: 12 }, errors: { minLength: t('Password must be at least 12 characters long'), hasLowerCase: t('Password is missing a lowercase letter'), hasUpperCase: t('Password is missing an uppercase letter'), hasNumbers: t('Password is missing a number'), hasSymbols: t('Password is missing a special character') } }) if (!strengthResult.success) { setErrors({ password: strengthResult.errors?.[0] || t('Password is not strong enough') }) return } if (values.password !== values.passwordConfirm) { setErrors({ passwordConfirm: t('Passwords do not match') }) return } } try { setIsLoading(true) await createVault({ name: values.name, password: values.password }) await addDevice(getDeviceName()) onSuccess?.() navigate('vault', { recordType: 'all' }) setIsLoading(false) } catch (error) { setIsLoading(false) logger.error('CreateVaultModalContent', 'Error creating vault:', error) } } return html` <${ModalContent} onClose=${onClose} onSubmit=${handleSubmit(submit)} showCloseButton=${false} borderColor=${colors.grey400.mode1} borderRadius="20px" headerChildren=${html` <${BackButtonTitle}> <${BackButton} type="button" onClick=${onClose} data-testid="createvault-back" > <${SmallArrowIcon} color=${colors.primary400.mode1} size="20" /> <${HeaderTitle}>${t('Create new Vault')} `} > <${Wrapper}> <${FieldGroup}> <${VaultNameRow}> <${InputRowLeft}> <${InputIconWrapper}> <${LockCircleIcon} size="24" color=${colors.white.mode1} /> <${InputText} placeholder=${t('Insert Vault name...')} autoFocus value=${nameField.value} disabled=${nameField.isDisabled} onChange=${(e) => nameField.onChange?.(e.target.value)} /> ${!!nameField.error?.length && html`<${NoticeText} text=${nameField.error} type="error" />`} ${PROTECTED_VAULT_ENABLED ? html` <${AccordionSection}> <${AccordionRow}> <${Label}>${t('Set Vault Password (optional)')} <${RoundArrowButton} type="button" $isOpen=${isPasswordOpen} onClick=${() => setIsPasswordOpen(!isPasswordOpen)} data-testid="createvault-password-toggle" > <${SmallArrowIcon} color=${colors.primary400.mode1} size="20" /> <${AccordionContent} $isOpen=${isPasswordOpen}> <${AccordionFields}> <${InputRow}> <${InputRowLeft}> <${InputText} placeholder=${t('Create Vault Password (optional)')} value=${passwordField.value} disabled=${passwordField.isDisabled} type=${isPasswordVisible ? 'text' : 'password'} onChange=${(e) => onPasswordChange?.(e.target.value)} /> <${PasswordRowRight}> ${passwordField.value?.length ? html` <${PasswordStrength} strength=${passwordStrengthResult.type} > ${strengthIcon && html`<${strengthIcon} />`} ${t(passwordStrengthResult.strengthText)} ` : null} <${IconOnlyButton} type="button" onClick=${() => setIsPasswordVisible(!isPasswordVisible)} data-testid="createvault-password-visibility" > ${isPasswordVisible ? html` <${EyeClosedIcon} color=${colors.primary400.mode1} /> ` : html` <${EyeIcon} color=${colors.primary400.mode1} /> `} <${InputRow}> <${InputRowLeft}> <${InputText} placeholder=${t('Repeat Vault Password')} value=${passwordConfirmField.value} disabled=${passwordConfirmField.isDisabled} type=${isPasswordConfirmVisible ? 'text' : 'password'} onChange=${(e) => onPasswordConfirmChange?.(e.target.value)} /> <${PasswordRowRight}> <${IconOnlyButton} type="button" onClick=${() => setIsPasswordConfirmVisible( !isPasswordConfirmVisible )} data-testid="createvault-passwordconfirm-visibility" > ${isPasswordConfirmVisible ? html` <${EyeClosedIcon} color=${colors.primary400.mode1} /> ` : html` <${EyeIcon} color=${colors.primary400.mode1} /> `} ${passwordConfirmField.error?.length ? html` <${PasswordErrorRow}> <${ErrorIcon} /> ${passwordConfirmField.error} ` : passwordField.error?.length ? html` <${PasswordErrorRow}> <${ErrorIcon} /> ${passwordField.error} ` : passwordField.value?.length && passwordStrengthResult.errors?.length ? html` <${PasswordErrorRow}> <${ErrorIcon} /> ${passwordStrengthResult.errors[0]} ` : null} <${PasswordRequirements}> ${t( 'Your password must be at least 12 characters long and include at least one of each:' )} <${RequirementsList}> <${RequirementsItem}>${t('Uppercase Letter (A-Z)')} <${RequirementsItem}>${t('Lowercase Letter (a-z)')} <${RequirementsItem}>${t('Number (0-9)')} <${RequirementsItem}> ${t('Special Character (! @ # $...)')} ${t('Note: Avoid common words and personal information.')} ` : null} <${Actions}> <${ContinueButtonWrapper}> <${ButtonPrimary} type="submit" size="lg" testId="createvault-continue" > ${t('Continue')} <${CancelButton} type="button" onClick=${onClose} data-testid="createvault-cancel" > ${t('Cancel')} ` } ================================================ FILE: src/containers/Modal/CreateVaultModalContent/styles.js ================================================ import styled from 'styled-components' export const BackButtonTitle = styled.div` display: flex; align-items: center; gap: 12px; ` export const RoundIconButton = styled.button` display: inline-flex; width: 42px; height: 42px; padding: 9px; justify-content: center; align-items: center; border: 1px solid ${({ theme }) => theme.colors.black.mode1}; border-radius: 30px; cursor: pointer; background: ${({ theme }) => theme.colors.black.mode1}; &:hover { border-color: ${({ theme }) => theme.colors.primary400.mode1}; } ` export const BackButton = styled(RoundIconButton)` & svg { transform: rotate(90deg); } ` export const HeaderTitle = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 20px; font-weight: 700; ` export const Wrapper = styled.div` display: flex; flex-direction: column; gap: 25px; ` export const FieldGroup = styled.div` display: flex; flex-direction: column; gap: 10px; ` export const InputRow = styled.div` display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px; min-height: 62px; border-radius: 10px; background: ${({ theme }) => theme.colors.grey400.mode1}; ` export const VaultNameRow = styled(InputRow)` padding: 14px 16px; ` export const InputRowLeft = styled.div` display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; ` export const InputIconWrapper = styled.div` display: flex; align-items: center; justify-content: center; flex-shrink: 0; ` export const InputText = styled.input` flex: 1; min-width: 0; border: none; background: transparent; color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; font-weight: 700; outline: none; &::placeholder { color: ${({ theme }) => theme.colors.grey100.mode1}; font-weight: 700; } ` export const Label = styled.div` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; font-weight: 700; ` export const AccordionSection = styled.div` display: flex; flex-direction: column; gap: 10px; ` export const AccordionRow = styled.div` display: flex; align-items: center; justify-content: space-between; gap: 10px; ` export const RoundArrowButton = styled(RoundIconButton)` & svg { transform: ${({ $isOpen }) => $isOpen ? 'rotate(0deg)' : 'rotate(-90deg)'}; transition: transform 0.2s ease; } ` export const IconOnlyButton = styled.button` display: inline-flex; width: 42px; height: 42px; padding: 9px; justify-content: center; align-items: center; border: none; background: transparent; border-radius: 30px; cursor: pointer; &:hover { opacity: 0.9; } ` export const AccordionFields = styled.div` display: flex; flex-direction: column; gap: 10px; ` export const AccordionContent = styled.div` display: flex; flex-direction: column; gap: 10px; overflow: hidden; padding-top: ${({ $isOpen }) => ($isOpen ? '10px' : '0')}; max-height: ${({ $isOpen }) => ($isOpen ? '520px' : '0')}; transition: max-height 0.25s ease, padding-top 0.25s ease; ` export const PasswordRowRight = styled.div` display: flex; align-items: center; justify-content: flex-end; gap: 10px; ` export const PasswordStrength = styled.div.withConfig({ shouldForwardProp: (prop) => !['strength'].includes(prop) })` display: flex; align-items: center; gap: 5px; color: ${({ theme, strength }) => { switch (strength) { case 'safe': return theme.colors.primary400.mode1 case 'vulnerable': return theme.colors.errorRed.dark case 'weak': return theme.colors.errorYellow.mode1 default: return theme.colors.white.mode1 } }}; font-family: 'Inter'; font-size: 12px; font-weight: 400; ` export const PasswordErrorRow = styled.div` display: flex; align-items: center; justify-content: flex-start; gap: 5px; width: 100%; font-family: 'Inter'; font-size: 14px; font-weight: 700; color: ${({ theme }) => theme.colors.errorRed.mode1}; ` export const PasswordRequirements = styled.div` width: 100%; color: ${({ theme }) => theme.colors.grey100.mode1}; font-family: 'Inter'; font-size: 12px; font-weight: 500; line-height: normal; ` export const RequirementsList = styled.ul` margin: 0; padding-left: 20px; list-style-type: disc; ` export const RequirementsItem = styled.li` font-size: 12px; ` export const Actions = styled.div` display: flex; align-items: center; justify-content: space-between; gap: 18px; ` export const ContinueButtonWrapper = styled.div` flex: 1; max-width: 260px; & > button { width: 100%; height: 42px; max-width: 260px; padding: 9px 40px; font-size: 14px; font-weight: 700; } ` export const CancelButton = styled.button` flex: 1; max-width: 260px; height: 42px; min-height: 42px; padding: 9px 40px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; cursor: pointer; color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; font-weight: 700; line-height: 17px; &:hover { opacity: 0.9; } ` ================================================ FILE: src/containers/Modal/DecryptFilePassword/index.tsx ================================================ import { html } from 'htm/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { FormModalHeaderWrapper } from '../../../components/FormModalHeaderWrapper' import { useLoadingContext } from '../../../context/LoadingContext' import { useModal } from '../../../context/ModalContext' import { useTranslation } from '../../../hooks/useTranslation' import { ButtonPrimary, PearPassPasswordField } from '../../../lib-react-components' import { logger } from '../../../utils/logger' import { ModalContent } from '../ModalContent' import { Description, Header, Title, UnlockVaultContainer } from '../CreateFileEncryptionPassword/styles' interface props { onSubmit: (password: string) => Promise } /** * * @param {Object} props * @param {(password: string) => Promise} props.onSubmit */ export const DecryptFilePassword = ({ onSubmit }: props) => { const { t } = useTranslation() const { closeModal } = useModal() const { setIsLoading } = useLoadingContext() const schema = Validator.object({ password: Validator.string().required(t('Password is required')) }) const { register, handleSubmit, setErrors } = useForm({ initialValues: { password: '' }, validate: (values: { password: string }) => schema.validate(values) }) const submit = async (values: { password: string }) => { try { setIsLoading(true) await onSubmit?.(values.password) setIsLoading(false) closeModal() } catch (error) { logger.error('DecryptFilePassword', error) setIsLoading(false) setErrors({ password: t('Invalid password or corrupted file') }) } } return html` <${ModalContent} onClose=${closeModal} headerChildren=${html` <${FormModalHeaderWrapper}> <${Header}> <${Title}> ${t('Enter decryption password')} <${Description}> ${t('Enter the password used to encrypt this file.')} `} > <${UnlockVaultContainer} onSubmit=${handleSubmit(submit)}> <${PearPassPasswordField} placeholder=${t('File password')} ...${register('password')} /> <${ButtonPrimary} type="submit"> ${t('Import')} ` } ================================================ FILE: src/containers/Modal/DeleteFolderModalContentV2/DeleteFolderModalContentV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ body: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing16}px`, whiteSpace: 'pre-line' as const } }) ================================================ FILE: src/containers/Modal/DeleteFolderModalContentV2/DeleteFolderModalContentV2.tsx ================================================ import { useState } from 'react' import { UNSUPPORTED } from '@tetherto/pearpass-lib-constants' import { Button, Dialog, Radio, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { useFolders, useRecords } from '@tetherto/pearpass-lib-vault' import { createStyles } from './DeleteFolderModalContentV2.styles' import { useModal } from '../../../context/ModalContext' import { useRouter } from '../../../context/RouterContext' import { useTranslation } from '../../../hooks/useTranslation' interface DeleteFolderModalContentV2Props { folderName: string count: number onClose: () => void } enum DeleteOption { DeleteFolder = 'deleteFolder', DeleteFolderAndItems = 'deleteFolderAndItems' } export const DeleteFolderModalContentV2 = ({ folderName, count, onClose }: DeleteFolderModalContentV2Props) => { const { t } = useTranslation() const { theme } = useTheme() const styles = createStyles() const { closeModal } = useModal() const { data: routerData, navigate } = useRouter() as { data: Record navigate: (page: string, data: Record) => void } const { deleteFolder, data: folderData } = useFolders() const { updateRecords } = useRecords() const [selected, setSelected] = useState( DeleteOption.DeleteFolderAndItems ) const handleClose = () => { onClose() } const navigateAwayIfNeeded = () => { if (routerData?.folder === folderName) { navigate('vault', { recordType: 'all' }) } } const handleDelete = async () => { if (selected === DeleteOption.DeleteFolder) { // Folder entries can include markers without data/type; skip them. const folderRecords = folderData?.customFolders?.[folderName]?.records ?? [] const realRecords = folderRecords.filter( (r: { data?: unknown; type?: unknown }) => !!r.data && !!r.type ) await updateRecords(realRecords.map((r) => ({ ...r, folder: null }))) // Items are now at root; remove the folder marker record. await deleteFolder(folderName) } else { await deleteFolder(folderName) } navigateAwayIfNeeded() closeModal() } const options = [ ...(!UNSUPPORTED ? [ { value: DeleteOption.DeleteFolder, label: t('Delete Folder'), description: t( 'Only the folder will be removed.\nYour items will be moved to the All Folder list.' ) } ] : []), { value: DeleteOption.DeleteFolderAndItems, label: t('Delete Folder and Items'), description: t( 'This will permanently remove the folder and all {count} items inside.\nThis action cannot be undone.', { count } ) } ] const isDeleteFolderOnlySelected = !UNSUPPORTED && selected === DeleteOption.DeleteFolder return ( {isDeleteFolderOnlySelected ? ( ) : ( )} } >
{t('This folder contains {count} items.', { count })} setSelected(value as DeleteOption)} testID="deletefolder-radio-v2" />
) } ================================================ FILE: src/containers/Modal/DeleteRecordsModalContentV2/DeleteRecordsModalContentV2.styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' import { FADE_GRADIENT_HEIGHT } from '../../../constants/layout' import { withAlpha } from '../../../utils/withAlpha' export const createStyles = (colors: ThemeColors) => ({ body: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, width: '100%' }, itemsListHeader: { marginBottom: `${rawTokens.spacing16}px` }, itemRow: { display: 'flex' as const, flexDirection: 'row' as const, alignItems: 'center' as const, gap: `${rawTokens.spacing12}px`, width: '100%' }, itemText: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing4}px`, minWidth: 0, flex: 1 }, itemsListWrapper: { position: 'relative' as const, width: '100%' }, itemsList: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing24}px`, width: '100%', maxHeight: '220px', overflowY: 'auto' as const, paddingLeft: `${rawTokens.spacing12}px` }, fadeGradient: { position: 'absolute' as const, left: 0, right: 0, bottom: 0, height: FADE_GRADIENT_HEIGHT, pointerEvents: 'none' as const, background: `linear-gradient(180deg, ${withAlpha(colors.colorSurfacePrimary, 0)} 0%, ${colors.colorSurfacePrimary} 100%)` }, confirmText: { marginTop: `${rawTokens.spacing16}px`, width: '100%' } }) ================================================ FILE: src/containers/Modal/DeleteRecordsModalContentV2/DeleteRecordsModalContentV2.tsx ================================================ import React, { useRef } from 'react' import { Button, Dialog, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { useRecords } from '@tetherto/pearpass-lib-vault' import { createStyles } from './DeleteRecordsModalContentV2.styles' import { RecordItemIcon } from '../../../components/RecordItemIcon' import { FADE_GRADIENT_HEIGHT } from '../../../constants/layout' import { useGlobalLoading } from '../../../context/LoadingContext' import { useModal } from '../../../context/ModalContext' import { useScrollOverflow } from '../../../hooks/useScrollOverflow' import { useTranslation } from '../../../hooks/useTranslation' import { getRecordSubtitle } from '../../../utils/getRecordSubtitle' import type { VaultRecord } from '../../../utils/groupRecordsByTimePeriod' type DeleteRecordsModalContentV2Props = { records: VaultRecord[] onCompleted?: () => void onConfirm?: () => Promise | void dialogTitle?: string confirmText?: string submitLabel?: string } export const DeleteRecordsModalContentV2 = ({ records, onCompleted, onConfirm, dialogTitle: dialogTitleOverride, confirmText: confirmTextOverride, submitLabel: submitLabelOverride }: DeleteRecordsModalContentV2Props) => { const { t } = useTranslation() const { theme } = useTheme() const styles = createStyles(theme.colors) const { closeModal } = useModal() const { deleteRecords, isLoading } = useRecords({ onCompleted: closeModal }) useGlobalLoading({ isLoading }) const itemsListRef = useRef(null) const hasItemsOverflow = useScrollOverflow(itemsListRef, [records.length]) const count = records.length const isSingle = count === 1 const dialogTitle = dialogTitleOverride ?? (isSingle ? t('Delete 1 Item') : t('Delete {count} Items', { count })) const confirmText = confirmTextOverride ?? (isSingle ? t('Are you sure to delete the selected item?') : t('Are you sure to delete the selected items?')) const selectedLabel = isSingle ? t('Selected Item') : t('Selected Items') const submitLabel = submitLabelOverride ?? (isSingle ? t('Delete Item') : t('Delete Items')) const handleDelete = async () => { if (!count || isLoading) return if (onConfirm) { await onConfirm() } else { await deleteRecords(records.map((r) => r.id)) } onCompleted?.() } return ( } >
{selectedLabel}
{count > 0 && (
{records.map((record) => { const subtitle = getRecordSubtitle(record) const titleText = record.data?.title ?? '' return (
{titleText} {subtitle ? ( {subtitle} ) : null}
) })}
{hasItemsOverflow ? ( )}
{confirmText}
) } ================================================ FILE: src/containers/Modal/DeleteRecordsModalContentV2/index.ts ================================================ export { DeleteRecordsModalContentV2 } from './DeleteRecordsModalContentV2' ================================================ FILE: src/containers/Modal/DeleteVaultModalContent/DeviceList.tsx ================================================ import React, { useMemo } from 'react' import { html } from 'htm/react' import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' import { CheckIcon, ComputerIcon, PhoneIcon } from '../../../lib-react-components' import { CheckIconWrapper, DeviceItem, DevicesList } from './styles' import { useTranslation } from '../../../hooks/useTranslation' import { Device } from './types' export const DeviceList = ({ devices = [], value, readOnly = false, onChange, currentDeviceId, }: { devices: Device[] value: string[] readOnly?: boolean onChange?: (next: string[]) => void currentDeviceId?: string }) => { const { t } = useTranslation() const selected = useMemo(() => new Set(value ?? []), [value]) const toggle = (deviceKey: string) => { if (readOnly) { return } if (currentDeviceId && deviceKey === currentDeviceId) { return } const next = new Set(selected) if (next.has(deviceKey)) next.delete(deviceKey) else next.add(deviceKey) if (currentDeviceId) next.add(currentDeviceId) onChange?.(Array.from(next)) } const renderDeviceIcon = (device: Device, isSelected: boolean, isCurrentDevice: boolean) => { // @TODO: there is no way to tell if the device is mobile, so we are hardcoding it to false for now. const isMobile = false; const BaseIcon = isMobile ? PhoneIcon : ComputerIcon if (readOnly) { return html`<${BaseIcon} width="20" height="20" />` } if (isCurrentDevice || isSelected) { const tickColor = isCurrentDevice ? colors.grey100.mode1 : colors.black.mode1 return html` <${CheckIconWrapper} $isCurrentDevice=${isCurrentDevice}> <${CheckIcon} size="20" color=${tickColor} /> ` } return html`<${BaseIcon} width="20" height="20" />` } return html` <${DevicesList} role="list"> ${devices.map((device) => { const { id, name } = device const isCurrentDevice = id === currentDeviceId const isSelected = selected.has(id) || isCurrentDevice const label = isCurrentDevice ? t('This device') : name return html` <${DeviceItem} key=${id} role="checkbox" aria-checked=${isSelected} aria-disabled=${readOnly || isCurrentDevice} isSelected=${isSelected} isDisabled=${readOnly || isCurrentDevice} onClick=${readOnly ? undefined : () => toggle(id)} > ${renderDeviceIcon(device, isSelected, isCurrentDevice)}
${label}
` })} ` } ================================================ FILE: src/containers/Modal/DeleteVaultModalContent/__tests__/DeleteVaultModalContent.test.tsx ================================================ import React from 'react' import '@testing-library/jest-dom' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { DeleteVaultModalContent } from '../index' import { ModalProvider } from '../../../../context/ModalContext' import { LoadingProvider } from '../../../../context/LoadingContext' let mockProtectedFlag = false jest.mock('@tetherto/pearpass-lib-constants', () => ({ get PROTECTED_VAULT_ENABLED() { return mockProtectedFlag } })) jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (s: string) => s } }) })) jest.mock('@tetherto/pearpass-lib-vault', () => { const actual = jest.requireActual('@tetherto/pearpass-lib-vault') const mockLogIn = jest.fn() const mockAuthorise = jest.fn() return { ...actual, useVault: () => ({ data: { id: 'vault-1', devices: [ { id: 'd1', name: 'Device 1', createdAt: Date.now(), vaultId: 'v1' }, { id: 'd2', name: 'Device 2', createdAt: Date.now(), vaultId: 'v1' } ] }, refetch: jest.fn(), isVaultProtected: jest.fn<() => Promise>().mockResolvedValue(true) }), useUserData: () => ({ logIn: mockLogIn }), authoriseCurrentProtectedVault: mockAuthorise, __testMocks: { mockLogIn, mockAuthorise } } }) const { __testMocks } = require('@tetherto/pearpass-lib-vault') const { mockLogIn, mockAuthorise } = __testMocks jest.mock('../../../../utils/getDeviceName', () => ({ getDeviceName: () => 'Device 1' })) // Basic render helper const renderWithProviders = (ui: React.ReactElement) => render( {ui} ) describe('DeleteVaultModalContent', () => { beforeEach(() => { mockProtectedFlag = false mockLogIn.mockReset() mockAuthorise.mockReset() }) test('renders delete flow copy by default (unprotected flag)', async () => { mockProtectedFlag = false renderWithProviders() expect( screen.getByText('Are you sure you want to delete this vault?') ).toBeInTheDocument() expect( screen.getByText('Select additional devices to delete the vault from') ).toBeInTheDocument() }) test('submits delete flow when PROTECTED_VAULT_ENABLED is false (master password only)', async () => { mockProtectedFlag = false mockLogIn.mockResolvedValueOnce(undefined) mockAuthorise.mockResolvedValueOnce(undefined) renderWithProviders() // Step 1: go to confirm step fireEvent.click(screen.getByText('Continue')) // Only masterPassword is validated when flag is false fireEvent.change(screen.getByPlaceholderText('Insert master password'), { target: { value: 'master-secret' } }) // Submit fireEvent.click(screen.getByText('Delete vault')) await waitFor(() => { expect(mockLogIn).toHaveBeenCalledTimes(1) expect(mockAuthorise).not.toHaveBeenCalled() }) }) test('submits delete flow when PROTECTED_VAULT_ENABLED is true (master + vault passwords)', async () => { mockProtectedFlag = true mockLogIn.mockResolvedValueOnce(undefined) mockAuthorise.mockResolvedValueOnce(undefined) renderWithProviders() // Step 1: go to confirm step fireEvent.click(screen.getByText('Continue')) // Wait for the vault password field to appear (protected vault flow) await waitFor(() => expect( screen.getByPlaceholderText('Insert vault password') ).toBeInTheDocument() ) // Fill in vault & master passwords fireEvent.change(screen.getByPlaceholderText('Insert vault password'), { target: { value: 'vault-secret' } }) fireEvent.change(screen.getByPlaceholderText('Insert master password'), { target: { value: 'master-secret' } }) // Submit fireEvent.click(screen.getByText('Delete vault')) await waitFor(() => { expect(mockLogIn).toHaveBeenCalledTimes(1) expect(mockAuthorise).toHaveBeenCalledTimes(1) }) }) }) ================================================ FILE: src/containers/Modal/DeleteVaultModalContent/__tests__/DeviceList.test.tsx ================================================ import React from 'react' import '@testing-library/jest-dom' import { render, screen, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { DeviceList } from '../DeviceList' import { Device } from '../types' jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (s: string) => s } }) })) const renderWithTheme = (ui: React.ReactElement) => render({ui}) const devices: Device[] = [ { id: 'current-id', name: 'Current device name', createdAt: Date.now(), vaultId: 'vault-1' }, { id: 'other-id', name: 'Other device', createdAt: Date.now(), vaultId: 'vault-1' } ] describe('DeviceList', () => { test('renders current device label as "This device" and keeps it selected', () => { const onChange = jest.fn() renderWithTheme( ) expect(screen.getByText('This device')).toBeInTheDocument() expect(screen.getByText('Other device')).toBeInTheDocument() // Clicking current device should not toggle selection or call onChange fireEvent.click(screen.getByText('This device')) expect(onChange).not.toHaveBeenCalled() }) test('toggles selection for non-current devices and always includes current device in payload', () => { const onChange = jest.fn() renderWithTheme( ) // Click on other device to select it fireEvent.click(screen.getByText('Other device')) expect(onChange).toHaveBeenCalledTimes(1) const [nextSelection] = onChange.mock.calls[0] as [string[]] expect(nextSelection.sort()).toEqual(['current-id', 'other-id'].sort()) }) test('read-only mode uses original device icons and disables interaction', () => { const onChange = jest.fn() renderWithTheme( ) // Items are rendered as checkboxes but disabled const items = screen.getAllByRole('checkbox') items.forEach((item) => { expect(item).toHaveAttribute('aria-disabled', 'true') }) // Clicks should not fire onChange in read-only mode fireEvent.click(screen.getByText('Other device')) expect(onChange).not.toHaveBeenCalled() }) }) ================================================ FILE: src/containers/Modal/DeleteVaultModalContent/index.tsx ================================================ import { useEffect, useMemo, useState } from 'react' import { html } from 'htm/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { authoriseCurrentProtectedVault, useUserData, useVault } from '@tetherto/pearpass-lib-vault' import { DeviceList } from './DeviceList' import { Content, DeleteVaultButton, InputWrapper, ModalActions, ModalTitle, ModalHeaderWrapper, Wrapper, ModalDescription } from './styles' import { useLoadingContext } from '../../../context/LoadingContext' import { useModal } from '../../../context/ModalContext' import { useTranslation } from '../../../hooks/useTranslation' import { ButtonPrimary, ButtonSecondary, DeleteIcon, PearPassPasswordFieldV2 } from '../../../lib-react-components' import { getDeviceName } from '../../../utils/getDeviceName' import { logger } from '../../../utils/logger' import { ModalContent } from '../ModalContent' import { clearBuffer, stringToBuffer } from '@tetherto/pearpass-lib-vault/src/utils/buffer' import {PROTECTED_VAULT_ENABLED} from '@tetherto/pearpass-lib-constants' import { FlowType, Device, DeleteVaultModalContentProps } from './types' export const DeleteVaultModalContent = ({ vaultId, flowType = FlowType.DELETE }: DeleteVaultModalContentProps) => { const isKickOutFlow = flowType === FlowType.KICK_OUT const { t } = useTranslation() const { closeModal } = useModal() const { setIsLoading } = useLoadingContext() const [isProtected, setIsProtected] = useState(false || PROTECTED_VAULT_ENABLED) const [isConfirmStep, setIsConfirmStep] = useState(false) const [selectedDeviceIds, setSelectedDeviceIds] = useState([]) const { data: vaultData, refetch: refetchVault, isVaultProtected } = useVault() const { logIn } = useUserData() useEffect(() => { if (PROTECTED_VAULT_ENABLED) { const checkProtection = async () => { const isProtectedVault = await isVaultProtected(vaultId) setIsProtected(isProtectedVault) } checkProtection() } }, [vaultId, PROTECTED_VAULT_ENABLED]) const currentDeviceName = useMemo(() => getDeviceName(), []) const devices = (vaultData as Record | undefined)?.devices const baseDevices = useMemo( () => devices && Array.isArray(devices) ? (devices as Device[]) : [], [devices] ) const devicesWithCurrent = useMemo(() => { const existingCurrent = baseDevices.find( (d) => d?.name === currentDeviceName ) if (existingCurrent) { // Ensure current device is always shown first in the list. return [ existingCurrent, ...baseDevices.filter((device) => device.id !== existingCurrent.id) ] } // Current device is not listed in vault devices - prepend manually. const currentDevice = { id: 'current-device', name: currentDeviceName, createdAt: Date.now(), vaultId: vaultId ?? vaultData?.id ?? '', } return [currentDevice, ...baseDevices] }, [baseDevices, currentDeviceName, vaultData?.id, vaultId]) const currentDeviceId = useMemo(() => { const existingCurrent = baseDevices.find( (d) => d?.name === currentDeviceName ) return existingCurrent?.id ?? 'current-device' }, [baseDevices, currentDeviceName]) const getSchema = () => Validator.object({ masterPassword: isConfirmStep ? Validator.string().required(t('Master password is required')) : Validator.string(), vaultPassword: isConfirmStep && isProtected ? Validator.string().required(t('Vault password is required')) : Validator.string() }) const { register, handleSubmit, setErrors } = useForm({ initialValues: { masterPassword: '', vaultPassword: '' }, validate: (values: { masterPassword: string; vaultPassword: string }) => getSchema().validate(values) }) const masterPasswordField = register('masterPassword') const vaultPasswordField = register('vaultPassword') useEffect(() => { if (currentDeviceId && !selectedDeviceIds.includes(currentDeviceId)) { setSelectedDeviceIds((prev) => prev.length ? [currentDeviceId, ...prev] : [currentDeviceId] ) } }, [currentDeviceId, selectedDeviceIds]) const handleOptionChange = (nextSelection: string[]) => { setSelectedDeviceIds(nextSelection) } const handleContinue = handleSubmit(() => { setIsConfirmStep(true) }) const onSubmit = async (values: { masterPassword: string vaultPassword: string }) => { if (!values.masterPassword) { setErrors({ masterPassword: t('Master password is required') }) return } const passwordBuffer = stringToBuffer(values.masterPassword) setIsLoading(true) try { await (logIn as unknown as (params: { password: Buffer }) => Promise)({ password: passwordBuffer }) } catch (error) { setErrors({ masterPassword: t('Invalid master password') }) logger.error('DeleteVaultModalContent', 'Error validating master password:', error) clearBuffer(passwordBuffer) setIsLoading(false) return } clearBuffer(passwordBuffer) if (isProtected) { if (!values.vaultPassword) { setErrors({ vaultPassword: t('Vault password is required') }) setIsLoading(false) return } try { await authoriseCurrentProtectedVault(values.vaultPassword) } catch (error) { setErrors({ vaultPassword: t('Invalid vault password') }) logger.error( 'DeleteVaultModalContent', 'Error validating vault password:', error ) setIsLoading(false) return } } // 3. TODO: implement delete / kick-off logic here using selectedDeviceIds. setIsLoading(false) // closeModal() } useEffect(() => { refetchVault() }, []) return html` <${ModalContent} onClose=${closeModal} headerChildren=${html` <${ModalHeaderWrapper}> <${ModalTitle}> ${isKickOutFlow ? '' : t('Are you sure you want to delete this vault?')} <${ModalDescription}> ${isKickOutFlow ? '' : isConfirmStep ? html` ${t( 'Confirm with your passwords to permanently delete this vault from the devices below.' )} ` : html` ${t('This will permanently delete all items in this vault. ')}
${t('This action cannot be undone.')} `} `} > <${Wrapper}> <${Content}> ${!isConfirmStep ? html` <${ModalDescription} marginBottom=${15}> ${isKickOutFlow ? '' : t('Select additional devices to delete the vault from')} <${DeviceList} devices=${devicesWithCurrent} value=${selectedDeviceIds} currentDeviceId=${currentDeviceId} onChange=${handleOptionChange} /> ` : html` <${DeviceList} devices=${devicesWithCurrent.filter((d) => selectedDeviceIds.includes(d.id) )} value=${selectedDeviceIds} readOnly=${true} currentDeviceId=${currentDeviceId} /> <${InputWrapper}> <${PearPassPasswordFieldV2} placeholder=${t('Insert master password')} isDisabled=${false} ...${masterPasswordField} /> ${isProtected && html` <${PearPassPasswordFieldV2} placeholder=${t('Insert vault password')} isDisabled=${false} ...${vaultPasswordField} /> `} `} <${ModalActions}> ${!isConfirmStep ? html` <${ButtonPrimary} onClick=${handleContinue}> ${t('Continue')} <${ButtonSecondary} onClick=${closeModal}> ${t('Cancel')} ` : html` <${DeleteVaultButton} onClick=${handleSubmit(onSubmit)}> <${DeleteIcon} size="20" /> ${isKickOutFlow ? t('') : t('Delete vault')} <${ButtonSecondary} onClick=${() => setIsConfirmStep(false)}> ${t('Back')} `} ` } ================================================ FILE: src/containers/Modal/DeleteVaultModalContent/styles.ts ================================================ import styled from 'styled-components' export const ModalHeaderWrapper = styled.div` display: flex; flex-direction: column; margin-bottom: 10px; ` export const ModalTitle = styled.h2` color: ${({ theme }) => theme.colors.white.mode1}; text-align: left; font-family: Inter; font-size: 20px; font-style: normal; font-weight: 700; line-height: normal; margin-bottom: 10px; ` export const ModalDescription = styled.p.withConfig({ shouldForwardProp: (prop) => prop !== 'marginBottom' })<{ marginBottom?: number }>` color: ${({ theme }) => theme.colors.white.mode1}; text-align: left; font-family: 'Inter'; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; margin-bottom: ${({ marginBottom }) => marginBottom ?? 0}px; ` export const Wrapper = styled.div` display: flex; flex-direction: column; gap: 20px; ` export const Content = styled.div` display: flex; flex-direction: column; gap: 25; width: 100%; ` export const InputWrapper = styled.div` display: flex; flex-direction: column; gap: 10px; width: 100%; margin-bottom: 25px; ` export const InputLabel = styled.label` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-style: normal; font-weight: 500; line-height: normal; ` export const ModalActions = styled.div` display: flex; justify-content: center; gap: 10px; align-items: center; width: 100%; button { flex: 1; } & > button:last-child { border: none; } ` export const DevicesList = styled.div.withConfig({ shouldForwardProp: (prop) => prop !== 'isConfirmStep' })<{ isConfirmStep?: boolean }>` display: flex; flex-direction: column; gap: 10px; width: 100%; margin-bottom: 25px; ` export const DeviceItem = styled.div.withConfig({ shouldForwardProp: (prop) => !['isSelected', 'isDisabled'].includes(prop) })<{ isSelected?: boolean isDisabled?: boolean }>` display: flex; align-items: center; padding: 10px; width: 100%; background-color: ${({ theme, isSelected }) => isSelected ? theme.colors.grey350.mode1 : theme.colors.grey400.mode1}; border-radius: 10px; color: ${({ theme }) => theme.colors.white.mode1} !important; gap: 10px; font-family: 'Inter'; font-size: 14px; font-style: normal; font-weight: 700; line-height: normal; cursor: ${({ isDisabled }) => (isDisabled ? 'not-allowed' : 'pointer')}; opacity: ${({ isDisabled }) => (isDisabled ? 0.7 : 1)}; border: 1px solid ${({ theme, isSelected, isDisabled }) => isSelected && !isDisabled ? theme.colors.primary400.mode1 : 'transparent'}; &:hover { border-color: ${({ theme, isDisabled }) => isDisabled ? 'transparent' : theme.colors.primary400.mode1}; } ` export const CheckIconWrapper = styled.div.withConfig({ shouldForwardProp: (prop) => prop !== '$isCurrentDevice' })<{ $isCurrentDevice?: boolean }>` display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 8px; background-color: ${({ theme, $isCurrentDevice }) => $isCurrentDevice ? theme.colors.grey300.mode1 : theme.colors.primary400.mode1}; ` export const DeleteVaultButton = styled.button` box-sizing: border-box; background: ${({ theme }) => theme.colors.errorRed.mode1}; color: ${({ theme }) => theme.colors.white.mode1}; padding: 10px 15px; border: none; cursor: pointer; border-radius: 10px; font-size: 14px; font-family: 'Inter'; font-weight: 600; line-height: 17px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; & svg path { fill: ${({ theme }) => theme.colors.white.mode1}; } &:hover { background: ${({ theme }) => theme.colors.errorRed.mode1}; } &:active { background: ${({ theme }) => theme.colors.errorRed.mode1}; } ` ================================================ FILE: src/containers/Modal/DeleteVaultModalContent/types.ts ================================================ enum FlowType { DELETE = 'delete', KICK_OUT = 'kickOut' } type DeleteVaultModalContentProps = { vaultId?: string flowType?: FlowType } interface Device { id: string name: string createdAt: number vaultId: string } export { FlowType } export type { Device, DeleteVaultModalContentProps } ================================================ FILE: src/containers/Modal/DisplayPictureModalContent/index.js ================================================ import { html } from 'htm/react' import { useModal } from '../../../context/ModalContext' import { ModalContent } from '../ModalContent' import { Content, HeaderContainer, Name } from './styles' export const DisplayPictureModalContent = ({ url, name }) => { const { closeModal } = useModal() return html` <${ModalContent} onClose=${closeModal} headerChildren=${html` <${HeaderContainer}> <${Name}>${name} `} > <${Content}> ${name} ` } ================================================ FILE: src/containers/Modal/DisplayPictureModalContent/styles.js ================================================ import styled from 'styled-components' export const Content = styled.div` display: flex; align-items: center; justify-content: center; width: 100%; border-radius: 10px; overflow: hidden; img { max-width: 100%; max-height: 447px; object-fit: contain; border-radius: 10px; } ` export const HeaderContainer = styled.div` display: flex; align-items: center; flex: 1; justify-content: space-between; flex-shrink: 0; width: 100%; ` export const Name = styled.p` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-style: normal; font-weight: 400; line-height: normal; ` export const ShareIconWrapper = styled.div` margin-left: auto; display: flex; padding: 2.5px; justify-content: center; align-items: center; cursor: pointer; border-radius: 50%; background: ${({ theme }) => theme.colors.black.dark}; flex-shrink: 0; ` ================================================ FILE: src/containers/Modal/DisplayPictureModalContentV2/DisplayPictureModalContentV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ body: { display: 'flex' as const, alignItems: 'center' as const, justifyContent: 'center' as const, width: '100%', borderRadius: `${rawTokens.radius8}px`, overflow: 'hidden' as const }, image: { maxWidth: '100%', maxHeight: '600px', objectFit: 'contain' as const, borderRadius: `${rawTokens.radius8}px` } }) ================================================ FILE: src/containers/Modal/DisplayPictureModalContentV2/DisplayPictureModalContentV2.tsx ================================================ import React from 'react' import { Button, Dialog } from '@tetherto/pearpass-lib-ui-kit' import { Download } from '@tetherto/pearpass-lib-ui-kit/icons' import { createStyles } from './DisplayPictureModalContentV2.styles' import { useModal } from '../../../context/ModalContext' import { useTranslation } from '../../../hooks/useTranslation' export interface DisplayPictureModalContentV2Props { url: string name: string } export const DisplayPictureModalContentV2 = ({ url, name }: DisplayPictureModalContentV2Props) => { const { t } = useTranslation() const styles = createStyles() const { closeModal } = useModal() const handleDownload = () => { const a = document.createElement('a') a.href = url a.download = name document.body.appendChild(a) a.click() document.body.removeChild(a) } return ( } >
{name}
) } ================================================ FILE: src/containers/Modal/ExtensionPairingModalContent/ExtensionPairingModalContentV2.styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = (colors: ThemeColors) => ({ body: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing16}px` }, instructionsBox: { display: 'flex' as const, alignItems: 'center' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, padding: `${rawTokens.spacing16}px`, backgroundColor: colors.colorSurfaceHover, border: `1px solid ${colors.colorBorderPrimary}`, borderRadius: `${rawTokens.radius8}px` }, instructionRow: { display: 'flex' as const, alignItems: 'center' as const, gap: `${rawTokens.spacing4}px`, flexWrap: 'wrap' as const } }) ================================================ FILE: src/containers/Modal/ExtensionPairingModalContent/ExtensionPairingModalContentV2.tsx ================================================ import React from 'react' import { Button, Dialog, InputField, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { ContentCopy, Extension, PearpassLogo } from '@tetherto/pearpass-lib-ui-kit/icons' import { createStyles } from './ExtensionPairingModalContentV2.styles' import { useModal } from '../../../context/ModalContext' import { useTranslation } from '../../../hooks/useTranslation' type ExtensionPairingModalContentV2Props = { onCopy: () => void pairingToken: string | null loadingPairing: boolean } export const ExtensionPairingModalContentV2 = ({ onCopy, pairingToken, loadingPairing }: ExtensionPairingModalContentV2Props) => { const { t } = useTranslation() const { closeModal } = useModal() const { theme } = useTheme() const styles = createStyles(theme.colors) return ( } >
{t('1. Open your browser')}
{t('2. Click the')} {t('icon in the toolbar')}
{t('3. Find and click')} {/* @ts-ignore */} {t('PearPass')} {t('in the extensions list')}
{t('4. If prompted, pin the extension')} {t('5. Paste this pairing code and confirm')}
) } ================================================ FILE: src/containers/Modal/ExtensionPairingModalContent/index.js ================================================ import { useLingui } from '@lingui/react' import { html } from 'htm/react' import { ModalActions, ModalContainer, ModalContent, ModalDescription, ModalHeader, ModalTitle } from './styles' import { AlertBox } from '../../../components/AlertBox' import { ListItemContainer, ListItemDate, ListItemDescription, ListItemInfo, ListItemName } from '../../../components/ListItem/styles' import { useModal } from '../../../context/ModalContext' import { ButtonSecondary } from '../../../lib-react-components' import { Description } from '../../../pages/SettingsView/ExportTab/styles' export const ExtensionPairingModalContent = ({ onCopy, pairingToken, loadingPairing, copyFeedback, tokenCreationDate, fingerprint }) => { const { i18n } = useLingui() const { closeModal } = useModal() const formatCreationDate = (isoDate) => { if (!isoDate) return '' try { const date = new Date(isoDate) return i18n._('Created on {date} at {time}', { date: date.toLocaleDateString(), time: date.toLocaleTimeString() }) } catch { return '' } } return html` <${ModalContainer}> <${ModalHeader}> <${ModalTitle}> ${i18n._('Extension Pairing')} <${ModalDescription}> ${i18n._( 'Click below to copy the pairing token to your clipboard, then paste it in your browser extension to establish secure communication.' )} <${ModalContent}> <${ListItemContainer} onClick=${onCopy} style=${{ cursor: pairingToken && !loadingPairing ? 'pointer' : 'default', transition: 'background-color 0.2s', borderRadius: '8px' }} onMouseEnter=${(e) => { if (pairingToken && !loadingPairing) { e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.05)' } }} onMouseLeave=${(e) => { e.currentTarget.style.backgroundColor = 'transparent' }} > <${ListItemInfo}> <${ListItemDescription}> <${ListItemName} style=${{ fontFamily: 'monospace', fontSize: '16px', fontWeight: 'bold' }} >${loadingPairing ? i18n._('Loading...') : pairingToken || i18n._('Unavailable')} <${ListItemDate} >${copyFeedback || formatCreationDate(tokenCreationDate)} <${AlertBox} message=${i18n._( 'Security Note: Only enter this token in the official PearPass browser extension. Never share it with anyone or enter it on websites.' )} /> <${Description} style=${{ display: 'block', marginTop: '8px', fontSize: '12px', color: '#666' }} > ${i18n._('Fingerprint (for verification): ')}${loadingPairing ? '' : fingerprint ? fingerprint.slice(0, 16) + '...' : ''} <${ModalActions}> <${ButtonSecondary} onClick=${closeModal}> ${i18n._('Close')} ` } ================================================ FILE: src/containers/Modal/ExtensionPairingModalContent/styles.js ================================================ import styled from 'styled-components' export const ModalContainer = styled.div` position: relative; display: flex; width: 480px; padding: 20px; flex-direction: column; justify-content: center; align-items: flex-start; gap: 15px; border-radius: 20px; border: 1px ${({ theme }) => theme.colors.grey300.mode1}; background: ${({ theme }) => theme.colors.grey500.mode1}; box-shadow: 5px 5px 10px 0 rgba(0, 0, 0, 0.25); ` export const ModalHeader = styled.div` display: flex; flex-direction: column; align-items: center; gap: 10px; align-self: stretch; ` export const ModalTitle = styled.p` color: ${({ theme }) => theme.colors.white.mode1}; text-align: center; font-family: 'Inter'; font-size: 20px; font-style: normal; font-weight: 600; line-height: normal; ` export const ModalDescription = styled.p` color: ${({ theme }) => theme.colors.white.mode1}; text-align: center; font-family: 'Inter'; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; ` export const ModalContent = styled.div` display: flex; flex-direction: column; align-items: flex-start; gap: 10px; align-self: stretch; ` export const ModalActions = styled.div` display: flex; justify-content: center; align-items: flex-start; gap: 25px; align-self: stretch; ` ================================================ FILE: src/containers/Modal/GeneratePasswordModalContentV2/GeneratePasswordModalContentV2.styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = (colors: ThemeColors) => ({ body: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing24}px`, width: '100%' }, section: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing12}px` }, groupedCard: { border: `1px solid ${colors.colorBorderPrimary}`, borderRadius: `${rawTokens.spacing8}px`, overflow: 'hidden' as const, backgroundColor: colors.colorSurfacePrimary }, generatedPasswordBlock: { display: 'flex' as const, flexDirection: 'column' as const, alignItems: 'center' as const, justifyContent: 'center' as const, gap: `${rawTokens.spacing16}px`, padding: `${rawTokens.spacing24}px ${rawTokens.spacing12}px`, borderBottom: `1px solid ${colors.colorBorderPrimary}`, textAlign: 'center' as const, wordBreak: 'break-all' as const }, optionRow: { padding: `${rawTokens.spacing12}px`, cursor: 'pointer' as const }, optionRowDivider: { borderBottom: `1px solid ${colors.colorBorderPrimary}` }, singleRowCard: { border: `1px solid ${colors.colorBorderPrimary}`, borderRadius: `${rawTokens.spacing8}px`, backgroundColor: colors.colorSurfacePrimary, padding: `${rawTokens.spacing20}px ${rawTokens.spacing12}px` }, sliderRow: { display: 'flex' as const, flexDirection: 'row' as const, alignItems: 'center' as const, gap: `${rawTokens.spacing12}px` }, sliderLabel: { width: 72, flexShrink: 0 }, slider: { flex: 1, minWidth: 0 }, settingRow: { display: 'flex' as const, flexDirection: 'row' as const, alignItems: 'center' as const, justifyContent: 'space-between' as const, gap: `${rawTokens.spacing12}px`, padding: `${rawTokens.spacing12}px` } }) ================================================ FILE: src/containers/Modal/GeneratePasswordModalContentV2/GeneratePasswordModalContentV2.tsx ================================================ import React, { useMemo, useState } from 'react' import { checkPassphraseStrength, checkPasswordStrength } from '@tetherto/pearpass-utils-password-check' import { PassType } from '../../../shared/types' import { generatePassphrase, generatePassword } from '@tetherto/pearpass-utils-password-generator' import { Button, Dialog, PasswordIndicator, Radio, Slider, Text, Title, ToggleSwitch, useTheme } from '@tetherto/pearpass-lib-ui-kit' import type { PasswordIndicatorVariant } from '@tetherto/pearpass-lib-ui-kit' import { ContentCopy } from '@tetherto/pearpass-lib-ui-kit/icons' import { createStyles } from './GeneratePasswordModalContentV2.styles' import { useModal } from '../../../context/ModalContext' import { useToast } from '../../../context/ToastContext' import { useTranslation } from '../../../hooks/useTranslation' // @ts-ignore - JS module without type declarations import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard.electron' const PASSWORD_OPTIONS = { password: 'password', passphrase: 'passphrase' } as const type PasswordOption = (typeof PASSWORD_OPTIONS)[keyof typeof PASSWORD_OPTIONS] type PasswordRules = { specialCharacters: boolean characters: number } type PassphraseRules = { capitalLetters: boolean symbols: boolean numbers: boolean words: number } const STRENGTH_TO_INDICATOR: Record = { vulnerable: 'vulnerable', weak: 'decent', safe: 'strong' } export type GeneratePasswordModalContentV2Props = { onPasswordInsert?: (pass: string, type: PassType) => void } const renderHighlightedPassword = ( text: string, primaryColor: string, secondaryColor: string ) => { const parts = text.split(/(\d+|[^a-zA-Z\d\s])/g) return parts.map((part, index) => { if (!part) return null if (/^\d+$/.test(part)) { return ( {part} ) } if (/[^a-zA-Z\d\s]/.test(part)) { return ( {part} ) } return {part} }) } export const GeneratePasswordModalContentV2 = ({ onPasswordInsert }: GeneratePasswordModalContentV2Props) => { const { t } = useTranslation() const { closeModal } = useModal() const { setToast } = useToast() const { theme } = useTheme() const styles = createStyles(theme.colors) const { copyToClipboard } = useCopyToClipboard({ onCopy: () => setToast({ message: t('Copied to clipboard') }) }) const [selectedOption, setSelectedOption] = useState( PASSWORD_OPTIONS.password ) const [selectedRules, setSelectedRules] = useState<{ password: PasswordRules passphrase: PassphraseRules }>({ password: { specialCharacters: true, characters: 8 }, passphrase: { capitalLetters: true, symbols: true, numbers: true, words: 8 } }) const generatedValue = useMemo(() => { if (selectedOption === PASSWORD_OPTIONS.passphrase) { return ( generatePassphrase( selectedRules.passphrase.capitalLetters, selectedRules.passphrase.symbols, selectedRules.passphrase.numbers, selectedRules.passphrase.words ) as string[] ).join('-') } return generatePassword(selectedRules.password.characters, { includeSpecialChars: selectedRules.password.specialCharacters, lowerCase: true, upperCase: true, numbers: true }) as string }, [selectedOption, selectedRules]) const strength = useMemo(() => { if (selectedOption === PASSWORD_OPTIONS.passphrase) { return checkPassphraseStrength(generatedValue.split('-')) } return checkPasswordStrength(generatedValue) }, [generatedValue, selectedOption]) const indicatorVariant: PasswordIndicatorVariant = STRENGTH_TO_INDICATOR[(strength as { type: string }).type] ?? 'vulnerable' const isAllPassphraseRulesSelected = selectedRules.passphrase.capitalLetters && selectedRules.passphrase.symbols && selectedRules.passphrase.numbers const handlePasswordRuleChange = ( key: keyof PasswordRules, value: boolean | number ) => { setSelectedRules((prev) => ({ ...prev, password: { ...prev.password, [key]: value } })) } const handlePassphraseRuleChange = ( key: keyof PassphraseRules, value: boolean | number ) => { setSelectedRules((prev) => ({ ...prev, passphrase: { ...prev.passphrase, [key]: value } })) } const handlePassphraseToggle = (rule: 'all' | keyof PassphraseRules) => { if (rule === 'all') { const nextValue = !isAllPassphraseRulesSelected setSelectedRules((prev) => ({ ...prev, passphrase: { ...prev.passphrase, capitalLetters: nextValue, symbols: nextValue, numbers: nextValue } })) return } handlePassphraseRuleChange(rule, !selectedRules.passphrase[rule]) } const handlePrimaryAction = () => { if (onPasswordInsert) { onPasswordInsert( generatedValue, selectedOption === PASSWORD_OPTIONS.passphrase ? PassType.PassPhrase : PassType.Password ) closeModal() return } copyToClipboard(generatedValue) closeModal() } const passphraseRules: { key: 'all' | keyof PassphraseRules label: string value: boolean }[] = [ { key: 'all', label: t('Select all'), value: isAllPassphraseRulesSelected }, { key: 'capitalLetters', label: t('Capital letters'), value: selectedRules.passphrase.capitalLetters }, { key: 'symbols', label: t('Symbols'), value: selectedRules.passphrase.symbols }, { key: 'numbers', label: t('Numbers'), value: selectedRules.passphrase.numbers } ] return ( } >
{t('Generated Password')}
{renderHighlightedPassword( generatedValue, theme.colors.colorPrimary, theme.colors.colorTextSecondary )}
{[ { key: PASSWORD_OPTIONS.passphrase, label: t('Memorable Password'), description: t( 'Memorable password using random words, numbers, and symbols.' ) }, { key: PASSWORD_OPTIONS.password, label: t('Random Characters'), description: t( 'A fully random mix of letters, numbers, and symbols.' ) } ].map((option, index, options) => (
setSelectedOption(option.key)} style={{ ...styles.optionRow, ...(index < options.length - 1 ? styles.optionRowDivider : {}) }} > setSelectedOption(option.key)} />
))}
{t('Password Length')}
{selectedOption === PASSWORD_OPTIONS.passphrase ? `${selectedRules.passphrase.words} ${t('Words')}` : `${selectedRules.password.characters} ${t('Chars')}`}
{ if (selectedOption === PASSWORD_OPTIONS.passphrase) { handlePassphraseRuleChange('words', value) return } handlePasswordRuleChange('characters', value) }} />
{t('Password settings')}
{selectedOption === PASSWORD_OPTIONS.passphrase ? ( passphraseRules.map((rule, index, rules) => (
{rule.label} handlePassphraseToggle(rule.key)} aria-label={rule.label} />
)) ) : (
{t('Special character (!&*)')} handlePasswordRuleChange( 'specialCharacters', !selectedRules.password.specialCharacters ) } aria-label={t('Special character toggle')} />
)}
) } ================================================ FILE: src/containers/Modal/GeneratePasswordSideDrawerContent/PassphraseChecker/index.js ================================================ import { checkPassphraseStrength } from '@tetherto/pearpass-utils-password-check' import { html } from 'htm/react' import { useTranslation } from '../../../../hooks/useTranslation' import { HighlightString, NoticeText } from '../../../../lib-react-components' import { PasswordWrapper } from '../styles' /** * @param {{ * pass: Array * rules: { * capitalLetters: boolean, * symbols: boolean, * numbers: boolean, * words: number * } * }} props */ export const PassphraseChecker = ({ pass }) => { const { t } = useTranslation() const { strengthText, strengthType } = checkPassphraseStrength(pass) return html` <${PasswordWrapper}> <${HighlightString} testId=${`passphrasecheck-text-${pass}`} text=${pass && pass.join('-')} /> <${NoticeText} testId=${`passphrasecheck-strength-${strengthType}`} text=${t(strengthText)} type=${strengthType} /> ` } ================================================ FILE: src/containers/Modal/GeneratePasswordSideDrawerContent/PassphraseGenerator/index..js ================================================ import { useLingui } from '@lingui/react' import { html } from 'htm/react' import { Slider } from '../../../../lib-react-components' import { RuleSelector } from '../RuleSelector' import { SliderContainer, SliderLabel, SliderWrapper, SwitchWrapper } from '../styles' /** * @param {{ * onRuleChange: (optionName: string, value: any) => void * rules: { * capitalLetters: boolean, * symbols: boolean, * numbers: boolean, * words: number * } * }} props */ export const PassphraseGenerator = ({ onRuleChange, rules }) => { const { i18n } = useLingui() const ruleOptions = [ { name: 'all', label: i18n._('Select All') }, { name: 'capitalLetters', label: i18n._('Capital Letters') }, { name: 'symbols', label: i18n._('Symbols') }, { name: 'numbers', label: i18n._('Numbers') } ] const handleRuleChange = (newRules) => { onRuleChange('passphrase', { ...rules, ...newRules }) } const handleSliderValueChange = (value) => { onRuleChange('passphrase', { ...rules, words: value }) } const selectableRules = { ...rules } delete selectableRules.words return html` <${SliderWrapper}> <${SliderLabel}> ${rules.words} ${' '} ${i18n._('words')} <${SliderContainer}> <${Slider} value=${rules.words} onChange=${handleSliderValueChange} min=${6} max=${36} step=${1} /> <${SwitchWrapper}> <${RuleSelector} rules=${ruleOptions} selectedRules=${selectableRules} setRules=${handleRuleChange} /> ` } ================================================ FILE: src/containers/Modal/GeneratePasswordSideDrawerContent/PasswordChecker/index.js ================================================ import { checkPasswordStrength } from '@tetherto/pearpass-utils-password-check' import { html } from 'htm/react' import { useTranslation } from '../../../../hooks/useTranslation' import { HighlightString, NoticeText } from '../../../../lib-react-components' import { PasswordWrapper } from '../styles' /** * @param {{ * pass: string * rules: { * specialCharacters: boolean, * characters: number * } * }} props */ export const PasswordChecker = ({ pass }) => { const { t } = useTranslation() const { strengthText, strengthType } = checkPasswordStrength(pass) return html` <${PasswordWrapper}> <${HighlightString} testId=${`passwordcheck-text-${pass}`} text=${pass} /> <${NoticeText} testId=${`passwordcheck-strength-${strengthType}`} text=${t(strengthText)} type=${strengthType} /> ` } ================================================ FILE: src/containers/Modal/GeneratePasswordSideDrawerContent/PasswordGenerator/index.js ================================================ import { useLingui } from '@lingui/react' import { html } from 'htm/react' import { Slider } from '../../../../lib-react-components' import { RuleSelector } from '../RuleSelector' import { SliderContainer, SliderLabel, SliderWrapper, SwitchWrapper } from '../styles' /** * @param {{ * onRuleChange: (optionName: string, value: any) => void * rules: { * specialCharacters: boolean, * characters: number * } * }} props */ export const PasswordGenerator = ({ onRuleChange, rules }) => { const { i18n } = useLingui() const ruleOptions = [ { name: 'specialCharacters', label: i18n._('Special character') + ' (!&*)' } ] const handleRuleChange = (newRules) => { onRuleChange('password', { ...rules, ...newRules }) } const handleSliderValueChange = (value) => { onRuleChange('password', { ...rules, characters: value }) } const selectableRules = { ...rules } delete selectableRules.characters return html` <${SliderWrapper} data-testid="passwordgenerator-characterslider-container"> <${SliderLabel}> ${rules.characters} ${' '} ${i18n._('characters')} <${SliderContainer}> <${Slider} testId=${`passwordgenerator-characterSlider-${rules.characters}`} value=${rules.characters} onChange=${handleSliderValueChange} min=${4} max=${32} step=${1} /> <${SwitchWrapper}> <${RuleSelector} rules=${ruleOptions} selectedRules=${selectableRules} setRules=${handleRuleChange} /> ` } ================================================ FILE: src/containers/Modal/GeneratePasswordSideDrawerContent/RuleSelector/index.js ================================================ import { html } from 'htm/react' import { SwitchWithLabel } from '../../../../components/SwitchWithLabel' /** * @param {{ * rules: Array<{ * label: string, * name: string * }> * setRules: () => void, * selectedRules: Record, * isSwitchFirst?: boolean, * stretch?: boolean * }} props */ export const RuleSelector = ({ rules, selectedRules, setRules, isSwitchFirst = false, stretch = true }) => { const isAllRuleSelected = Object.values(selectedRules).every( (value) => value === true ) const handleSwitchToggle = (ruleName) => { const updatedRules = { ...selectedRules } if (ruleName === 'all') { Object.keys(updatedRules).forEach((rule) => { updatedRules[rule] = !isAllRuleSelected }) } else { updatedRules[ruleName] = !updatedRules[ruleName] } setRules(updatedRules) } return html`${rules.map( (rule) => html`<${SwitchWithLabel} label=${rule.label} description=${rule.description} isSwitchFirst=${isSwitchFirst} isOn=${selectedRules[rule.name] || isAllRuleSelected} onChange=${() => handleSwitchToggle(rule.name)} isLabelBold stretch=${stretch} testId=${rule.testId || `ruleselector-switchwithlabel-${rule.name}`} />` )} ` } ================================================ FILE: src/containers/Modal/GeneratePasswordSideDrawerContent/index.js ================================================ import { useMemo, useState } from 'react' import { useLingui } from '@lingui/react' import { generatePassphrase, generatePassword } from '@tetherto/pearpass-utils-password-generator' import { html } from 'htm/react' import { RadioSelect } from '../../../components/RadioSelect' import { useModal } from '../../../context/ModalContext' import { ModalHeader } from '../ModalHeader' import { PassphraseChecker } from './PassphraseChecker' import { PassphraseGenerator } from './PassphraseGenerator/index.' import { PasswordChecker } from './PasswordChecker' import { PasswordGenerator } from './PasswordGenerator' import { HeaderButtonWrapper, RadioWrapper, Wrapper } from './styles' import { useToast } from '../../../context/ToastContext' import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard.electron' import { ButtonLittle, CopyIcon } from '../../../lib-react-components' /** * @param {{ * onPasswordInsert: (pass: string) => void * }} props */ export const GeneratePasswordSideDrawerContent = ({ onPasswordInsert }) => { const { i18n } = useLingui() const { closeModal } = useModal() const { setToast } = useToast() const { copyToClipboard } = useCopyToClipboard({ onCopy: () => { setToast({ message: i18n._('Copied to clipboard'), icon: CopyIcon }) } }) const [selectedOption, setSelectedOption] = useState('password') const [selectedRules, setSelectedRules] = useState({ password: { specialCharacters: true, characters: 8 }, passphrase: { capitalLetters: true, symbols: true, numbers: true, words: 8 } }) const pass = useMemo(() => { if (selectedOption === 'passphrase') { return generatePassphrase( selectedRules.passphrase.capitalLetters, selectedRules.passphrase.symbols, selectedRules.passphrase.numbers, selectedRules.passphrase.words ) } return generatePassword(selectedRules.password.characters, { includeSpecialChars: selectedRules.password.specialCharacters, lowerCase: true, upperCase: true, numbers: true }) }, [selectedOption, selectedRules]) const radioOptions = [ { label: i18n._('Password'), value: 'password' }, { label: i18n._('Passphrase'), value: 'passphrase' } ] const handleRuleChange = (optionName, value) => { setSelectedRules((prevRules) => ({ ...prevRules, [optionName]: value })) } const handleCopyAndClose = () => { const copyText = selectedOption === 'passphrase' ? pass.join('-') : pass copyToClipboard(copyText) closeModal() } const handleInsertPassword = () => { const passText = selectedOption === 'passphrase' ? pass.join('-') : pass onPasswordInsert(passText) closeModal() } return html` <${Wrapper}> <${ModalHeader} onClose=${closeModal}> <${HeaderButtonWrapper}> ${onPasswordInsert ? html`<${ButtonLittle} testId="passwordGenerator-button-insertpassword" onClick=${handleInsertPassword} > ${i18n._('Insert password')} ` : html`<${ButtonLittle} testId="passwordGenerator-button-copyandclose" onClick=${handleCopyAndClose} > ${i18n._('Copy and close')} `} ${selectedOption === 'passphrase' ? html` <${PassphraseChecker} pass=${pass} />` : html` <${PasswordChecker} pass=${pass} />`} <${RadioWrapper}> <${RadioSelect} title=${i18n._('Type')} options=${radioOptions} selectedOption=${selectedOption} onChange=${setSelectedOption} /> ${selectedOption === 'passphrase' ? html` <${PassphraseGenerator} onRuleChange=${handleRuleChange} rules=${selectedRules.passphrase} />` : html`<${PasswordGenerator} onRuleChange=${handleRuleChange} rules=${selectedRules.password} />`} ` } ================================================ FILE: src/containers/Modal/GeneratePasswordSideDrawerContent/styles.js ================================================ import styled from 'styled-components' export const Wrapper = styled.div` width: 100%; height: 100%; overflow-y: auto; padding: 20px; ` export const HeaderChildrenWrapper = styled.div` flex: 1; display: flex; justify-content: flex-end; ` export const CloseIconWrapper = styled.div` margin-left: auto; display: flex; padding: 2.5px; justify-content: center; align-items: center; cursor: pointer; border-radius: 50%; background: ${({ theme }) => theme.colors.black.dark}; flex-shrink: 0; ` export const HeaderButtonWrapper = styled.div` display: flex; justify-content: flex-end; ` export const PasswordWrapper = styled.div` display: flex; flex-direction: column; align-items: center; margin-top: 42px; min-height: 20px; gap: 8px; font-family: 'Inter'; font-size: 14px; font-weight: 400; text-align: center; & > div > div { font-size: 10px; } ` export const RadioWrapper = styled.div` margin-top: 32px; ` export const SliderWrapper = styled.div` display: flex; align-items: center; justify-content: space-between; margin-top: 10px; padding: 10px 0; border-top: 1px solid ${({ theme }) => theme.colors.grey300.mode1}; border-bottom: 1px solid ${({ theme }) => theme.colors.grey300.mode1}; ` export const SliderContainer = styled.div` width: 240px; ` export const SliderLabel = styled.div` flex: 1; color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; font-weight: 400; ` export const SwitchWrapper = styled.div` display: flex; flex-direction: column; align-items: center; margin-top: 9px; gap: 9px; ` ================================================ FILE: src/containers/Modal/ImportItemOrVaultModalContentV2/ImportItemOrVaultModalContentV2.styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = (colors: ThemeColors) => ({ divider: { width: '100%', height: 1, backgroundColor: colors.colorBorderPrimary, flexShrink: 0, border: 'none', padding: 0, margin: 0 }, bodyColumn: { display: 'flex' as const, flexDirection: 'column' as const, width: '100%', alignItems: 'stretch' as const }, inputSection: { marginTop: rawTokens.spacing12, boxSizing: 'border-box' as const, }, sectionLabel: { color: colors.colorTextSecondary, fontFamily: rawTokens.fontPrimary, fontSize: rawTokens.fontSize12, fontWeight: rawTokens.weightMedium, marginBottom: rawTokens.spacing8 }, pairingHint:{ marginTop: rawTokens.spacing12, } }) ================================================ FILE: src/containers/Modal/ImportItemOrVaultModalContentV2/index.tsx ================================================ import os from 'os' import type { ChangeEvent } from 'react' import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { Button, Dialog, InputField, useTheme, Text } from '@tetherto/pearpass-lib-ui-kit' import { ContentPaste } from '@tetherto/pearpass-lib-ui-kit/icons' import { useVault, usePair } from '@tetherto/pearpass-lib-vault' import { useModal } from '../../../context/ModalContext' import { useToast } from '../../../context/ToastContext' import { useAutoLockPreferences } from '../../../hooks/useAutoLockPreferences' import { useGlobalLoading } from '../../../context/LoadingContext' import { useTranslation } from '../../../hooks/useTranslation' import { createStyles } from './ImportItemOrVaultModalContentV2.styles' import { ImportVaultPreviewModalContent } from '../ImportVaultPreviewModalContent' export const ImportItemOrVaultModalContentV2 = () => { const { t } = useTranslation() const { theme } = useTheme() const { colors } = theme const { setToast } = useToast() const { closeModal, setModal } = useModal() const [shareLink, setShareLink] = useState('') const { refetch: refetchVault, addDevice } = useVault() const { pairActiveVault, isLoading: isPairing, cancelPairActiveVault } = usePair() const { setShouldBypassAutoLock } = useAutoLockPreferences() const shareLinkInputRef = useRef(null) useEffect(() => { setShouldBypassAutoLock(true) return () => setShouldBypassAutoLock(false) }, [setShouldBypassAutoLock]) useGlobalLoading({ isLoading: isPairing }) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && isPairing) { cancelPairActiveVault() } } window.addEventListener('keydown', handleKeyDown) return () => { window.removeEventListener('keydown', handleKeyDown) } }, [cancelPairActiveVault, isPairing]) const handleLoadVault = useCallback( async (code: string) => { try { const vaultId = await pairActiveVault(code) if (!vaultId) { throw new Error('Vault ID is empty') } await refetchVault(vaultId) await addDevice( os.hostname() + ' ' + os.platform() + ' ' + os.release() ) setModal(, { replace: true }) } catch { setShareLink('') setToast({ message: t('Something went wrong, please check invite code') }) } }, [pairActiveVault, refetchVault, addDevice, setModal, setToast, t] ) const handleChange = (value: string) => { if (isPairing) { return } setShareLink(value) } const processPastedText = useCallback( (pastedText: string) => { const text = (pastedText ?? '').trim() if (!text) { return } setShareLink(text) setTimeout(() => { if (!isPairing) { void handleLoadVault(text) } }, 0) }, [isPairing, handleLoadVault] ) const handlePaste = useCallback( (e: ClipboardEvent) => { const pastedText = e.clipboardData?.getData('text') processPastedText(pastedText ?? '') }, [processPastedText] ) useLayoutEffect(() => { const el = shareLinkInputRef.current if (!el) { return } const listener = (event: Event) => { handlePaste(event as ClipboardEvent) } el.addEventListener('paste', listener) return () => { el.removeEventListener('paste', listener) } }, [handlePaste]) const handlePasteClick = async () => { try { const pastedText = await navigator.clipboard.readText() processPastedText(pastedText) } catch { setToast({ message: t('Failed to paste from clipboard') }) } } const handleContinue = () => { const trimmed = shareLink.trim() if (!trimmed || isPairing) { return } void handleLoadVault(trimmed) } const styles = createStyles(colors) const canContinue = Boolean(shareLink.trim()) && !isPairing return ( } >
{t('Share Link')}
) => handleChange(e.target.value) } value={shareLink} testID="import-share-link-input" rightSlot={
{isPairing && (
{t('Click Escape to cancel pairing')}
)}
) } ================================================ FILE: src/containers/Modal/ImportVaultPreviewModalContent/ImportVaultPreviewModalContent.styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = (colors: ThemeColors) => ({ bodyColumn: { display: 'flex' as const, flexDirection: 'column' as const, width: '100%', alignItems: 'stretch' as const, flex: '1 1 auto' as const, minHeight: 0 }, vaultPanel: { borderWidth: 1, borderStyle: 'solid' as const, borderColor: colors.colorBorderPrimary, borderRadius: rawTokens.radius8, backgroundColor: colors.colorSurfacePrimary, overflow: 'hidden' as const, boxSizing: 'border-box' as const, marginTop: `${rawTokens.spacing12}px`, }, lockBadge: { width: 32, height: 32, borderRadius: rawTokens.radius8, backgroundColor: colors.colorSurfaceHover, display: 'flex' as const, alignItems: 'center' as const, justifyContent: 'center' as const, flexShrink: 0 }, recordsScroll: { maxHeight: 280, overflowY: 'auto' as const, boxSizing: 'border-box' as const }, recordsListWrapper: { margin: rawTokens.spacing12, borderWidth: 1, borderStyle: 'solid' as const, borderColor: colors.colorBorderPrimary, borderRadius: rawTokens.radius8, boxSizing: 'border-box' as const }, chevronWrap: { display: 'inline-flex' as const, transition: 'transform 0.15s ease' } }) ================================================ FILE: src/containers/Modal/ImportVaultPreviewModalContent/index.tsx ================================================ import { useCallback, useMemo, useState } from 'react' import { generateAvatarInitials } from '@tetherto/pear-apps-utils-avatar-initials' import { useRecords, useVault } from '@tetherto/pearpass-lib-vault' import { Button, Dialog, ListItem, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { ExpandMore, LockOutlined } from '@tetherto/pearpass-lib-ui-kit/icons' import { RecordAvatar } from '../../../components/RecordAvatar' import { RECORD_COLOR_BY_TYPE } from '../../../constants/recordColorByType' import { createStyles } from './ImportVaultPreviewModalContent.styles' import { useGlobalLoading } from '../../../context/LoadingContext' import { useModal } from '../../../context/ModalContext' import { useRouter } from '../../../context/RouterContext' import { useTranslation } from '../../../hooks/useTranslation' import { VaultRecord } from '../../../shared/types' function loginWebsiteUrl(record: VaultRecord): string { if (record.type !== 'login') { return '' } const first = record.data?.websites?.[0] if (typeof first === 'string') { return first } if (first && typeof first === 'object' && typeof first.website === 'string') { return first.website } return '' } function getRecordSubtitle(record: VaultRecord): string | undefined { const d = record.data if (!d) { return undefined } if (record.type === 'login') { if (typeof d.username === 'string' && d.username) { return d.username } if (typeof d.email === 'string' && d.email) { return d.email } const url = loginWebsiteUrl(record) if (url) { return url } } return undefined } export const ImportVaultPreviewModalContent = () => { const { t } = useTranslation() const { theme } = useTheme() const { colors } = theme const { closeModal } = useModal() const { navigate } = useRouter() const { data: vaultData } = useVault() const [isVaultExpanded, setIsVaultExpanded] = useState(true) const { data: records, isLoading } = useRecords({ shouldSkip: false, variables: { filters: { searchPattern: '', type: '', folder: '', isFavorite: false }, sort: { key: 'updatedAt', direction: 'desc' } } }) useGlobalLoading({ isLoading }) const styles = createStyles(colors) const recordList = useMemo( () => (Array.isArray(records) ? records : []), [records] ) const hasRecords = recordList.length > 0 const vaultSubtitle = useMemo(() => { const n = recordList.length return `${n} ${t('Items')}` }, [recordList.length, t]) const handleContinue = useCallback(() => { navigate('vault', { recordType: 'all' }) closeModal() }, [navigate, closeModal]) const vaultName = vaultData?.name ?? vaultData?.id ?? t('Vault') const lockIcon = (
) const chevron = (
) const recordsInner = recordList.map((record) => { const recordType = record.type as keyof typeof RECORD_COLOR_BY_TYPE const avatarColor = RECORD_COLOR_BY_TYPE[recordType] ?? RECORD_COLOR_BY_TYPE.custom const domain = loginWebsiteUrl(record) return ( } title={record.data?.title ?? record.type} subtitle={getRecordSubtitle(record)} showDivider={false} testID={`import-vault-preview-record-${record.id}`} /> ) }) const recordsBody = (
{recordsInner}
) return ( {t('Continue')} } >
{t('Vault Found')}
setIsVaultExpanded((v) => !v) : undefined } testID="import-vault-preview-toggle" /> {hasRecords && isVaultExpanded && (
{recordsBody}
)}
) } ================================================ FILE: src/containers/Modal/ModalContent/index.js ================================================ import { html } from 'htm/react' import { Wrapper } from './styles' import { ModalHeader } from '../ModalHeader' /** * @param {{ * onClose: () => void * headerChildren: import('react').ReactNode * children: import('react').ReactNode * onSubmit?: () => void * showCloseButton?: boolean * borderColor?: string * borderRadius?: string * closeButtonDataId?: string * }} props */ export const ModalContent = ({ onClose, onSubmit, headerChildren, children, showCloseButton = true, borderColor, borderRadius, closeButtonDataId }) => html` <${Wrapper} $borderColor=${borderColor} $borderRadius=${borderRadius}> <${onSubmit ? 'form' : 'div'} onSubmit=${(e) => { e.preventDefault() onSubmit?.() }} > <${ModalHeader} onClose=${onClose} showCloseButton=${showCloseButton} closeButtonDataId=${closeButtonDataId} > ${headerChildren}
${children}
` ================================================ FILE: src/containers/Modal/ModalContent/styles.js ================================================ import styled from 'styled-components' export const Wrapper = styled.div` width: 640px; max-height: 85vh; overflow-y: auto; padding: 20px; border-radius: ${({ $borderRadius }) => $borderRadius ?? '10px'}; border: 1px solid ${({ theme, $borderColor }) => $borderColor ?? theme.colors.grey300.dark}; background: ${({ theme }) => theme.colors.grey500.dark}; box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25); position: relative; ` ================================================ FILE: src/containers/Modal/ModalHeader/index.js ================================================ import { html } from 'htm/react' import { Header, HeaderChildrenWrapper } from './styles' import { ButtonRoundIcon, XIcon } from '../../../lib-react-components' /** * @param {{ * onClose: () => void * children: import('react').ReactNode * showCloseButton?: boolean * closeButtonDataId?: string * }} props */ export const ModalHeader = ({ onClose, children, showCloseButton = true, closeButtonDataId }) => html` <${Header}> <${HeaderChildrenWrapper}> ${children} ${showCloseButton && html`<${ButtonRoundIcon} onClick=${onClose} startIcon=${XIcon} testId="modalheader-button-close" dataId=${closeButtonDataId} />`} ` ================================================ FILE: src/containers/Modal/ModalHeader/styles.js ================================================ import styled from 'styled-components' export const Header = styled.div` display: flex; align-items: center; margin-bottom: 15px; gap: 10px; ` export const HeaderChildrenWrapper = styled.div` flex: 1; ` export const CloseIconWrapper = styled.div` margin-left: auto; display: flex; padding: 4px; justify-content: center; align-items: center; cursor: pointer; border-radius: 50%; background: ${({ theme }) => theme.colors.black.dark}; flex-shrink: 0; ` ================================================ FILE: src/containers/Modal/ModifyMasterVaultModalContent/index.js ================================================ import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { useUserData } from '@tetherto/pearpass-lib-vault' import { stringToBuffer, clearBuffer } from '@tetherto/pearpass-lib-vault/src/utils/buffer' import { validatePasswordChange } from '@tetherto/pearpass-utils-password-check' import { html } from 'htm/react' import { Content, InputLabel, InputWrapper, ModalActions, ModalTitle } from './styles' import { useLoadingContext } from '../../../context/LoadingContext' import { useModal } from '../../../context/ModalContext' import { useTranslation } from '../../../hooks/useTranslation.js' import { ButtonPrimary, ButtonSecondary, PearPassPasswordField } from '../../../lib-react-components' import { logger } from '../../../utils/logger' import { ModalContent } from '../ModalContent' export const ModifyMasterVaultModalContent = () => { const { t } = useTranslation() const { closeModal } = useModal() const { updateMasterPassword } = useUserData() const { setIsLoading } = useLoadingContext() const errors = { minLength: t(`Password must be at least 8 characters long`), hasLowerCase: t('Password must contain at least one lowercase letter'), hasUpperCase: t('Password must contain at least one uppercase letter'), hasNumbers: t('Password must contain at least one number'), hasSymbols: t('Password must contain at least one special character') } const schema = Validator.object({ currentPassword: Validator.string().required(t('Invalid password')), newPassword: Validator.string().required(t('Password is required')), repeatPassword: Validator.string().required(t('Password is required')) }) const { register, handleSubmit, setErrors, setValue } = useForm({ initialValues: { currentPassword: '', newPassword: '', repeatPassword: '' }, validate: (values) => schema.validate(values) }) const onSubmit = async (values) => { const { currentPassword, newPassword, repeatPassword } = values const result = validatePasswordChange({ currentPassword, newPassword, repeatPassword, messages: { newPasswordMustDiffer: t( 'New password must be different from the current password' ), passwordsDontMatch: t('Passwords do not match') }, config: { errors } }) if (!result.success) { setErrors({ [result.field]: result.error }) if (result.field === 'newPassword') { setValue('repeatPassword', '') } return } const newPasswordBuffer = stringToBuffer(values.newPassword) const currentPasswordBuffer = stringToBuffer(values.currentPassword) try { setIsLoading(true) await updateMasterPassword({ newPassword: newPasswordBuffer, currentPassword: currentPasswordBuffer }) setIsLoading(false) closeModal() } catch (error) { setIsLoading(false) logger.error( 'ModifyMasterVaultModalContent', 'Error updating master password:', error ) setErrors({ currentPassword: t('Invalid password') }) } finally { clearBuffer(newPasswordBuffer) clearBuffer(currentPasswordBuffer) } } return html` <${ModalContent} onClose=${closeModal} headerChildren=${html` <${ModalTitle}> ${t('Update master password')} `} > <${Content}> <${InputWrapper}> <${InputLabel}> ${t('Insert old password')} <${PearPassPasswordField} ...${register('currentPassword')} testId="master-password-current-input" /> <${InputWrapper}> <${InputLabel}> ${t('Create new password')} <${PearPassPasswordField} ...${register('newPassword')} testId="master-password-new-input" /> <${InputWrapper}> <${InputLabel}> ${t('Repeat new password')} <${PearPassPasswordField} ...${register('repeatPassword')} testId="master-password-repeat-input" /> <${ModalActions}> <${ButtonPrimary} testId="master-password-save-button" onClick=${handleSubmit(onSubmit)} > ${t('Save')} <${ButtonSecondary} testId="master-password-cancel-button" onClick=${closeModal} > ${t('Cancel')} ` } ================================================ FILE: src/containers/Modal/ModifyMasterVaultModalContent/styles.js ================================================ import styled from 'styled-components' export const ModalContentWrapper = styled.div` position: relative; display: flex; flex-direction: column; gap: 15px; padding: 20px; width: 480px; background-color: ${({ theme }) => theme.colors.grey500.mode1}; border-radius: 20px; border: 1px solid ${({ theme }) => theme.colors.grey400.mode1}; box-shadow: 5px 5px 10px 0px rgba(0, 0, 0, 0.25); z-index: 10; ` export const ModalHeader = styled.div` display: flex; justify-content: center; align-items: center; width: 100%; ` export const ModalTitle = styled.h2` color: ${({ theme }) => theme.colors.white.mode1}; text-align: center; font-family: Inter; font-size: 20px; font-style: normal; font-weight: 600; line-height: normal; ` export const CloseButton = styled.div` position: absolute; top: 20px; right: 20px; cursor: pointer; ` export const Content = styled.div` display: flex; flex-direction: column; gap: 20px; width: 100%; ` export const InputWrapper = styled.div` display: flex; flex-direction: column; gap: 10px; width: 100%; ` export const InputLabel = styled.label` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-style: normal; font-weight: 500; line-height: normal; ` export const ModalActions = styled.div` display: flex; justify-content: center; gap: 25px; align-items: center; width: 100%; ` ================================================ FILE: src/containers/Modal/ModifyVaultModalContent/index.js ================================================ import { useEffect, useState, useMemo } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { PROTECTED_VAULT_ENABLED } from '@tetherto/pearpass-lib-constants' import { useVault } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { RadioSelect } from '../../../components/RadioSelect' import { useLoadingContext } from '../../../context/LoadingContext' import { useModal } from '../../../context/ModalContext' import { useTranslation } from '../../../hooks/useTranslation' import { ButtonPrimary, ButtonSecondary, PearPassInputField, PearPassPasswordField } from '../../../lib-react-components' import { logger } from '../../../utils/logger' import { ModalContent } from '../ModalContent' import { Content, InputLabel, InputWrapper, ModalActions, ModalTitle, Wrapper } from './styles' const UPDATE_MODE = { NAME: 'name', PASSWORD: 'password' } export const ModifyVaultModalContent = ({ vaultId, vaultName }) => { const { t } = useTranslation() const { closeModal } = useModal() const { isVaultProtected, updateUnprotectedVault, updateProtectedVault, refetch: refetchVault } = useVault() const [isProtected, setIsProtected] = useState(false) const [selectedOption, setSelectedOption] = useState(UPDATE_MODE.NAME) const { setIsLoading } = useLoadingContext() const radioOptions = useMemo( () => PROTECTED_VAULT_ENABLED ? [ { label: t('Change Vault Name'), value: UPDATE_MODE.NAME }, { label: t('Change Vault Password'), value: UPDATE_MODE.PASSWORD } ] : [], [t, PROTECTED_VAULT_ENABLED] ) const getInitialValues = (option) => { if (option === UPDATE_MODE.PASSWORD) { return { currentPassword: '', newPassword: '', repeatPassword: '' } } else { return { name: vaultName, currentPassword: '' } } } const getSchema = () => { if (selectedOption === UPDATE_MODE.PASSWORD) { return Validator.object({ currentPassword: isProtected ? Validator.string().required(t`Current password is required`) : Validator.string(), newPassword: Validator.string().required(t`New password is required`), repeatPassword: Validator.string().required(t`Please repeat password`) }) } else { return Validator.object({ name: Validator.string().required(t`Name is required`), currentPassword: isProtected ? Validator.string().required(t`Current password is required`) : Validator.string() }) } } const { register, handleSubmit, setErrors, setValues } = useForm({ initialValues: getInitialValues(selectedOption), validate: (values) => getSchema().validate(values) }) const handleOptionChange = (option) => { setSelectedOption(option) setValues(getInitialValues(option)) setErrors({}) } const onSubmit = async (values) => { if (values.newPassword !== values.repeatPassword) { setErrors({ repeatPassword: t('Passwords do not match') }) return } if (isProtected && !values.currentPassword?.length) { setErrors({ currentPassword: t('Current password is required') }) return } try { setIsLoading(true) const name = selectedOption === UPDATE_MODE.NAME ? values.name : vaultName const password = selectedOption === UPDATE_MODE.PASSWORD ? values.newPassword : undefined if (isProtected) { await updateProtectedVault(vaultId, { name, password, currentPassword: values.currentPassword }) } else { await updateUnprotectedVault(vaultId, { name, password }) } setIsLoading(false) closeModal() } catch (error) { setIsLoading(false) logger.error('ModifyVaultModalContent', 'Error updating vault:', error) setErrors({ currentPassword: t('Invalid password') }) } } useEffect(() => { const checkProtection = async () => { const result = await isVaultProtected(vaultId) setIsProtected(result) } checkProtection() }, [vaultId]) useEffect(() => { refetchVault() }, []) return html` <${ModalContent} onClose=${closeModal} headerChildren=${html` <${ModalTitle}> ${t('Modify Vault')} `} > <${Wrapper}> <${RadioSelect} options=${radioOptions} selectedOption=${selectedOption} onChange=${handleOptionChange} /> <${Content}> ${selectedOption === UPDATE_MODE.NAME && html` <${InputWrapper}> <${InputLabel}> ${t('Insert vault name')} <${PearPassInputField} ...${register('name')} /> `} ${isProtected && PROTECTED_VAULT_ENABLED && html` <${InputWrapper}> <${InputLabel}> ${t('Insert old password')} <${PearPassPasswordField} ...${register('currentPassword')} /> `} ${selectedOption === UPDATE_MODE.PASSWORD && html` <${InputWrapper}> <${InputLabel}> ${t('Create new password')} <${PearPassPasswordField} ...${register('newPassword')} /> <${InputWrapper}> <${InputLabel}> ${t('Repeat new password')} <${PearPassPasswordField} ...${register('repeatPassword')} /> `} <${ModalActions}> <${ButtonPrimary} onClick=${handleSubmit(onSubmit)}> ${t('Continue')} <${ButtonSecondary} onClick=${closeModal}> ${t('Cancel')} ` } ================================================ FILE: src/containers/Modal/ModifyVaultModalContent/styles.js ================================================ import styled from 'styled-components' export const ModalTitle = styled.h2` color: ${({ theme }) => theme.colors.white.mode1}; text-align: center; font-family: Inter; font-size: 20px; font-style: normal; font-weight: 600; line-height: normal; ` export const Wrapper = styled.div` display: flex; flex-direction: column; gap: 20px; ` export const Content = styled.div` display: flex; flex-direction: column; gap: 20px; width: 100%; ` export const InputWrapper = styled.div` display: flex; flex-direction: column; gap: 10px; width: 100%; ` export const InputLabel = styled.label` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-style: normal; font-weight: 500; line-height: normal; ` export const ModalActions = styled.div` display: flex; justify-content: center; gap: 25px; align-items: center; width: 100%; ` ================================================ FILE: src/containers/Modal/MoveFolderModalContent/index.js ================================================ import React from 'react' import { useLingui } from '@lingui/react' import { useRecords, useFolders } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { FolderList, HeaderWrapper } from './styles' import { useGlobalLoading } from '../../../context/LoadingContext' import { useModal } from '../../../context/ModalContext' import { ButtonFolder, ButtonSingleInput, NewFolderIcon } from '../../../lib-react-components' import { isV2 } from '../../../utils/designVersion' import { sortByName } from '../../../utils/sortByName' import { CreateFolderModalContent } from '../CreateFolderModalContent' import { CreateFolderModalContentV2 } from '../CreateFolderModalContentV2/CreateFolderModalContentV2' import { ModalContent } from '../ModalContent' /** * @param {{ * records: { * id: string * folder?: string * }[] * onCompleted?: () => void * }} props */ export const MoveFolderModalContent = ({ records, onCompleted }) => { const { i18n } = useLingui() const { closeModal, setModal } = useModal() const { updateFolder, isLoading: isUpdating } = useRecords({ onCompleted: closeModal }) const { data: folders, isLoading: isLoadingFolders } = useFolders() const isLoading = isUpdating || isLoadingFolders useGlobalLoading({ isLoading }) const filteredFolders = React.useMemo(() => { const excludedFolder = records?.length === 1 ? records[0].folder : null const customFolders = Object.values(folders?.customFolders ?? {}) return sortByName( customFolders.filter((folder) => folder.name !== excludedFolder) ) }, [folders, records]) const handleMove = async (folderName) => { const recordIds = records.map((record) => record.id) await updateFolder(recordIds, folderName) onCompleted?.() } const handleCreateClick = () => { isV2() ? setModal( handleMove(folderData.folder)} /> ) : setModal(html` <${CreateFolderModalContent} onCreate=${(folderData) => handleMove(folderData.folder)} /> `) } return html` <${React.Fragment}> <${ModalContent} onClose=${closeModal} headerChildren=${html` <${HeaderWrapper}> ${i18n._('Select a folder or create a new folder')} `} > <${FolderList}> ${filteredFolders.map( (folder) => html` <${ButtonFolder} key=${folder.name} onClick=${() => handleMove(folder.name)} > ${folder.name} ` )} <${ButtonSingleInput} startIcon=${NewFolderIcon} onClick=${() => handleCreateClick()} > ${i18n._('Create new folder')} ` } ================================================ FILE: src/containers/Modal/MoveFolderModalContent/styles.js ================================================ import styled from 'styled-components' export const HeaderWrapper = styled.div` color: ${({ theme }) => theme.colors.grey100.mode1}; font-family: 'Inter'; font-size: 12px; font-weight: 400; ` export const FolderList = styled.div` display: flex; flex-wrap: wrap; gap: 15px; margin-bottom: 15px; ` ================================================ FILE: src/containers/Modal/MoveFolderModalContentV2/MoveFolderModalContentV2.styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' import { FADE_GRADIENT_HEIGHT } from '../../../constants/layout' import { withAlpha } from '../../../utils/withAlpha' export const createStyles = (colors: ThemeColors) => ({ body: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, width: '100%' }, itemsListHeader: { marginBottom: `${rawTokens.spacing16}px`, }, itemRow: { display: 'flex' as const, flexDirection: 'row' as const, alignItems: 'center' as const, gap: `${rawTokens.spacing12}px`, width: '100%' }, itemText: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing4}px`, minWidth: 0, flex: 1 }, itemsListWrapper: { position: 'relative' as const, width: '100%' }, itemsList: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing24}px`, width: '100%', maxHeight: '220px', overflowY: 'auto' as const, paddingLeft: `${rawTokens.spacing12}px`, }, fadeGradient: { position: 'absolute' as const, left: 0, right: 0, bottom: 0, height: FADE_GRADIENT_HEIGHT, pointerEvents: 'none' as const, background: `linear-gradient(180deg, ${withAlpha(colors.colorSurfacePrimary, 0)} 0%, ${colors.colorSurfacePrimary} 100%)` }, destinationHint: { marginTop: `${rawTokens.spacing16}px`, width: '100%' }, chipRow: { display: 'flex' as const, flexDirection: 'row' as const, flexWrap: 'wrap' as const, gap: `${rawTokens.spacing12}px`, width: '100%', maxHeight: '100px', overflowY: 'auto' as const, } }) ================================================ FILE: src/containers/Modal/MoveFolderModalContentV2/MoveFolderModalContentV2.tsx ================================================ import React, { useEffect, useMemo, useRef, useState } from 'react' // @ts-ignore - JS module without type declarations import { generateAvatarInitials } from '@tetherto/pear-apps-utils-avatar-initials' import { Button, Dialog, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { useFolders, useRecords } from '@tetherto/pearpass-lib-vault' import { createStyles } from './MoveFolderModalContentV2.styles' import { RECORD_COLOR_BY_TYPE } from '../../../constants/recordColorByType' import { FADE_GRADIENT_HEIGHT } from '../../../constants/layout' import { useModal } from '../../../context/ModalContext' import { useGlobalLoading } from '../../../context/LoadingContext' import { useScrollOverflow } from '../../../hooks/useScrollOverflow' import { useTranslation } from '../../../hooks/useTranslation' import { Folder, Layers } from '@tetherto/pearpass-lib-ui-kit/icons' import { RecordAvatar } from '../../../components/RecordAvatar' const CHIP_ID_ALL = '__all__' export type MoveFolderRecord = Record & { id: string folder?: string | null type: string data?: { title?: string username?: string email?: string websites?: string[] [key: string]: unknown } } export type MoveFolderModalContentV2Props = { records: MoveFolderRecord[] onCompleted?: () => void } type FolderOption = { id: string label: string icon: React.ReactNode } function getRecordSubtitle(record: MoveFolderRecord): string { if (["login", 'identity'].includes(record.type)) { const u = record.data?.username const e = record.data?.email const w = record.data?.websites?.[0] return String(u || e || w || '') } if (record.folder) { return String(record.folder) } return '' } export const MoveFolderModalContentV2 = ({ records, onCompleted }: MoveFolderModalContentV2Props) => { const { t } = useTranslation() const { theme } = useTheme() const styles = createStyles(theme.colors) const { closeModal } = useModal() const { data: folders, isLoading: isLoadingFolders } = useFolders() const { updateFolder, updateRecords, isLoading: isUpdating } = useRecords({ onCompleted: closeModal }) const isLoading = isUpdating || isLoadingFolders useGlobalLoading({ isLoading }) const iconColor = theme.colors.colorTextPrimary const folderOptions = useMemo(() => { const customFolders = Object.values( (folders?.customFolders ?? {}) as Record ) const customOptions = customFolders .sort((a, b) => a.name.localeCompare(b.name)) .map(({ name }) => ({ id: name, label: name, icon: })) return [ { id: CHIP_ID_ALL, label: t('All Items'), icon: }, ...customOptions ] }, [folders, iconColor, t]) // Preselect the chip the records currently sit at const defaultSelectedId = useMemo(() => { if (records.length === 0) return null const firstFolder = records[0].folder if (!records.every((r) => r.folder === firstFolder)) return null if (!firstFolder) return CHIP_ID_ALL return folders?.customFolders?.[firstFolder] ? firstFolder : null }, [records, folders]) const [selectedId, setSelectedId] = useState(defaultSelectedId) useEffect(() => { setSelectedId(defaultSelectedId) }, [defaultSelectedId]) const atDestination = !!selectedId && records.length > 0 && (selectedId === CHIP_ID_ALL ? records.every((r) => !r.folder) : records.every((r) => r.folder === selectedId)) const isMoveDisabled = isLoading || !selectedId || atDestination const itemsListRef = useRef(null) const hasItemsOverflow = useScrollOverflow(itemsListRef, [records.length]) const isSingle = records.length === 1 const moveDialogTitle = isSingle ? t('Move 1 item') : t('Move {count} items', { count: records.length }) const moveSubmitLabel = isSingle ? t('Move item') : t('Move items') const selectedItemsLabel = isSingle ? t('Selected Item') : t('Selected Items') const destinationHintLabel = isSingle ? t('Choose the destination folder of this item') : t('Choose the destination folder of these items') const handleMove = async () => { if (!selectedId || isMoveDisabled) return if (selectedId === CHIP_ID_ALL) { await updateRecords(records.map((r) => ({ ...r, folder: null }))) } else { await updateFolder(records.map((r) => r.id), selectedId) } onCompleted?.() } const { body, itemRow, itemText, itemsList, itemsListWrapper, fadeGradient, destinationHint, chipRow, itemsListHeader } = styles return ( } >
{selectedItemsLabel}
{records.length > 0 ? (
{records.map((record, index) => { const domain = record.type === 'login' ? record.data?.websites?.[0] ?? null : null const subtitle = getRecordSubtitle(record) const titleText = record.data?.title ?? '' return (
{titleText} {subtitle ? ( {subtitle} ) : null}
) })}
{hasItemsOverflow ? ( ) : null}
{destinationHintLabel}
{folderOptions.map((opt) => { const { id, label, icon } = opt const selected = id === selectedId return ( ) })}
) } ================================================ FILE: src/containers/Modal/PairedDevicesModalContent/index.tsx ================================================ import React, { useMemo } from 'react' import { formatDate } from '@tetherto/pear-apps-utils-date' import { Button, Dialog, ListItem, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { Devices, LaptopMac, LaptopWindows, PhoneIphone, Tablet } from '@tetherto/pearpass-lib-ui-kit/icons' import { useVault } from '@tetherto/pearpass-lib-vault' import { createStyles } from './styles' import { useModal } from '../../../context/ModalContext' import { useTranslation } from '../../../hooks/useTranslation' const getDeviceDisplayName = ( deviceName: string | undefined, t: (value: string) => string ): string => { if (!deviceName) return t('Unknown Device') const lowerName = deviceName.toLowerCase() if (lowerName.startsWith('ios')) return t('iPhone') if (lowerName.startsWith('android')) return t('Android') return deviceName } const getDeviceIcon = (deviceName?: string) => { if (!deviceName) return Devices const lowerName = deviceName.toLowerCase() if (lowerName.startsWith('ios') || lowerName.includes('iphone')) return PhoneIphone if (lowerName.startsWith('android')) return PhoneIphone if (lowerName.includes('ipad') || lowerName.includes('tablet')) return Tablet if ( lowerName.includes('mac') || lowerName.includes('imac') || lowerName.includes('darwin') || lowerName.includes('macbook') ) return LaptopMac if (lowerName.includes('windows')) return LaptopWindows return Devices } export const PairedDevicesModalContent = () => { const { t } = useTranslation() const { closeModal } = useModal() const { theme } = useTheme() const styles = createStyles(theme.colors) const { data: vaultData } = useVault() const devices = useMemo( () => (Array.isArray(vaultData?.devices) ? vaultData.devices : []), [vaultData] ) return ( {t('Understood')} } > {devices.length === 0 ? (
{t('No devices synced yet')}
) : (
{devices.map((device, index) => { const deviceName = getDeviceDisplayName(device.name, t) const DeviceIcon = getDeviceIcon(device.name) const createdAt = device.createdAt ? formatDate(device.createdAt, 'dd-mmm-yyyy', ' ') : null return (
} title={deviceName} subtitle={ createdAt ? `${t('Paired on')} ${createdAt}` : undefined } testID={`see-devices-item-${device.id ?? index}`} /> ) })}
)} ) } ================================================ FILE: src/containers/Modal/PairedDevicesModalContent/styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = (colors: ThemeColors) => ({ list: { display: 'flex' as const, flexDirection: 'column' as const }, iconWrap: { width: '32px', height: '32px', borderRadius: `${rawTokens.radius8}px`, display: 'flex' as const, alignItems: 'center' as const, justifyContent: 'center' as const, flexShrink: 0, backgroundColor: colors.colorSurfaceHover }, emptyState: { display: 'flex' as const, justifyContent: 'center' as const, alignItems: 'center' as const, padding: `${rawTokens.spacing24}px` } }) ================================================ FILE: src/containers/Modal/SideDrawer/index.js ================================================ import { useRef } from 'react' import { html } from 'htm/react' import { SideDrawerWrapper } from './styles' import { BASE_TRANSITION_DURATION } from '../../../constants/transitions' import { useAnimatedVisibility } from '../../../hooks/useAnimatedVisibility' /** * @param {{ * children: import('react').ReactNode * isOpen: boolean * }} props */ export const SideDrawer = ({ children, isOpen }) => { const nodeRef = useRef(null) const { isShown, isRendered } = useAnimatedVisibility({ isOpen: isOpen, transitionDuration: BASE_TRANSITION_DURATION, nodeRef, propertyName: 'transform' }) if (!isRendered) { return null } return html` <${SideDrawerWrapper} ref=${nodeRef} isShown=${isShown}> ${children} ` } ================================================ FILE: src/containers/Modal/SideDrawer/styles.js ================================================ import styled from 'styled-components' import { BASE_TRANSITION_DURATION } from '../../../constants/transitions' export const SideDrawerWrapper = styled.div.withConfig({ shouldForwardProp: (prop) => !['isShown'].includes(prop) })` position: fixed; bottom: 0; right: 0; width: 400px; height: calc(100% - var(--title-bar-height)); background: ${({ theme }) => theme.colors.grey500.mode1}; box-shadow: -4px 4px 4px 0px rgba(0, 0, 0, 0.25); transform: ${({ isShown }) => isShown ? 'translateX(0)' : 'translateX(100%)'}; transition: transform ${BASE_TRANSITION_DURATION}ms ease-in-out; ` ================================================ FILE: src/containers/Modal/UnsavedChangesModalContent/UnsavedChangesModalContent.tsx ================================================ import { Button, Dialog, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { useModal } from '../../../context/ModalContext' import { useTranslation } from '../../../hooks/useTranslation' type UnsavedChangesModalContentProps = { description?: string onSave: () => void | Promise onDiscard: () => void } export const UnsavedChangesModalContent = ({ description, onSave, onDiscard }: UnsavedChangesModalContentProps) => { const { t } = useTranslation() const { theme } = useTheme() const { closeModal } = useModal() return ( } > {description ?? t( 'You have unsaved changes. Would you like to save them before leaving?' )} ) } ================================================ FILE: src/containers/Modal/UnsavedChangesModalContent/index.ts ================================================ export { UnsavedChangesModalContent } from './UnsavedChangesModalContent' ================================================ FILE: src/containers/Modal/UpdateRequiredModalContent/index.js ================================================ import { useLingui } from '@lingui/react' import { useCountDown } from '@tetherto/pear-apps-lib-ui-react-hooks' import { html } from 'htm/react' import { ButtonWrapper, Container, Description, Timer, TimerTitle, TimerWrapper, Title } from './styles' import { useModal } from '../../../context/ModalContext' import { ButtonPrimary } from '../../../lib-react-components' /** * @param {{ * onUpdate: () => void * }} props */ export const UpdateRequiredModalContent = ({ onUpdate }) => { const { closeModal } = useModal() const { i18n } = useLingui() const handleUpdateApp = () => { onUpdate?.() closeModal() } const expireTime = useCountDown({ initialSeconds: 120, onFinish: handleUpdateApp }) return html` <${Container}> <${Title}> ${i18n._('Update Required')} <${Description}> ${i18n._( 'Find out what exciting features and updates await you in this version.' )} <${TimerWrapper}> <${TimerTitle}> ${i18n._('App will restart in:')} <${Timer}> ${expireTime} <${ButtonWrapper}> <${ButtonPrimary} onClick=${handleUpdateApp}> ${i18n._('Update App')} ` } ================================================ FILE: src/containers/Modal/UpdateRequiredModalContent/styles.js ================================================ import styled from 'styled-components' export const Container = styled.div` position: relative; display: flex; width: 380px; padding: 20px; flex-direction: column; align-items: flex-start; gap: 8px; border-radius: 10px; border: 1px solid ${({ theme }) => theme.colors.grey300.mode1}; background: ${({ theme }) => theme.colors.grey400.mode1}; box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.25); ` export const Title = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-style: normal; font-weight: 600; line-height: normal; letter-spacing: -0.32px; ` export const Description = styled.span` color: ${({ theme }) => theme.colors.grey100.mode1}; font-family: 'Inter'; font-size: 14px; font-style: normal; font-weight: 400; line-height: 150%; /* 21px */ letter-spacing: -0.28px; ` export const ButtonWrapper = styled.div` display: flex; width: 100%; & > * { width: 100%; } ` export const TimerWrapper = styled.div` width: 100%; display: flex; justify-content: space-between; align-items: center; ` export const TimerTitle = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; font-style: normal; font-weight: 600; line-height: 150%; /* 21px */ letter-spacing: -0.28px; ` export const Timer = styled.span` color: ${({ theme }) => theme.colors.primary400.mode1}; font-family: 'Inter'; font-size: 14px; font-style: normal; font-weight: 400; line-height: 150%; /* 21px */ letter-spacing: -0.28px; ` ================================================ FILE: src/containers/Modal/UpdateRequiredModalContentV2/UpdateRequiredModalContentV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ body: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing16}px`, width: '100%' }, timerRow: { display: 'flex' as const, flexDirection: 'row' as const, alignItems: 'center' as const, justifyContent: 'space-between' as const, width: '100%' }, footer: { display: 'flex' as const, justifyContent: 'flex-end' as const, width: '100%' } }) ================================================ FILE: src/containers/Modal/UpdateRequiredModalContentV2/UpdateRequiredModalContentV2.tsx ================================================ import React from 'react' import { useCountDown } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Button, Dialog, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { createStyles } from './UpdateRequiredModalContentV2.styles' import { useModal } from '../../../context/ModalContext' import { useTranslation } from '../../../hooks/useTranslation' export type UpdateRequiredModalContentV2Props = { onUpdate: () => void } export const UpdateRequiredModalContentV2 = ({ onUpdate }: UpdateRequiredModalContentV2Props) => { const { t } = useTranslation() const { closeModal } = useModal() const { theme } = useTheme() const styles = createStyles() const handleUpdateApp = () => { onUpdate?.() closeModal() } const expireTime = useCountDown({ initialSeconds: 120, onFinish: handleUpdateApp }) return (
} >
{t( 'A newer version of PearPass is available. Please update to the latest version to continue using the app.' )}
{t('App will restart in:')} {expireTime}
) } ================================================ FILE: src/containers/Modal/UploadFilesModalContentV2/UploadFilesModalContentV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ body: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing12}px`, width: '100%' } }) ================================================ FILE: src/containers/Modal/UploadFilesModalContentV2/UploadFilesModalContentV2.tsx ================================================ import React, { useState } from 'react' import { Button, Dialog, UploadField } from '@tetherto/pearpass-lib-ui-kit' import type { UploadedFile } from '@tetherto/pearpass-lib-ui-kit' import { createStyles } from './UploadFilesModalContentV2.styles' import { useModal } from '../../../context/ModalContext' import { useTranslation } from '../../../hooks/useTranslation' export type UploadFilesModalContentV2Props = { type?: 'file' | 'image' accepts?: string onFilesSelected?: (files: File[]) => void } export const UploadFilesModalContentV2 = ({ type = 'file', accepts, onFilesSelected }: UploadFilesModalContentV2Props) => { const { t } = useTranslation() const { closeModal } = useModal() const styles = createStyles() const [files, setFiles] = useState([]) const isTypeImage = type === 'image' const acceptedFormats = accepts ? accepts.split(',').map((a) => a.trim()).filter(Boolean) : undefined const handleSubmit = () => { if (!files.length) return onFilesSelected?.(files.map((f) => f.file)) closeModal() } return ( } >
) } ================================================ FILE: src/containers/Modal/UploadFilesModalContentV2/index.ts ================================================ export { UploadFilesModalContentV2 } from './UploadFilesModalContentV2' export type { UploadFilesModalContentV2Props } from './UploadFilesModalContentV2' ================================================ FILE: src/containers/Modal/UploadImageModalContent/index.js ================================================ import { useLingui } from '@lingui/react' import { html } from 'htm/react' import { ContentWrapper, HeaderWrapper } from './styles' import { FileUploadContent } from '../../../components/FileUploadContent' import { useModal } from '../../../context/ModalContext' import { CommonFileIcon, ImageIcon } from '../../../lib-react-components' import { ModalContent } from '../ModalContent' /** * @component * @param {Object} props * @param {'file'|'image'} props.type * @param {string} props.accepts * @param {boolean} [props.closeOnChange=true] * @returns {JSX.Element} */ export const UploadFilesModalContent = ({ accepts, type, onFilesSelected, closeOnChange = true }) => { const isTypeImage = type === 'image' const { i18n } = useLingui() const { closeModal } = useModal() const handleFileChange = (files) => { if (files && files.length > 0) { onFilesSelected?.(files) } if (closeOnChange) { closeModal() } } return html` <${ModalContent} onClose=${closeModal} headerChildren=${html` <${HeaderWrapper}> ${isTypeImage ? html` <${ImageIcon} size="21" /> ${i18n._('Upload picture')}` : html` <${CommonFileIcon} size="21" /> ${i18n._('Upload file')} `} `} > <${ContentWrapper}> <${FileUploadContent} accepts=${accepts} isTypeImage=${isTypeImage} handleFileChange=${handleFileChange} /> ` } ================================================ FILE: src/containers/Modal/UploadImageModalContent/styles.js ================================================ import styled from 'styled-components' export const HeaderWrapper = styled.div` display: flex; gap: 8px; color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-weight: 500; ` export const ContentWrapper = styled.div` display: flex; flex-direction: column; align-items: flex-start; gap: 16px; ` ================================================ FILE: src/containers/Modal/VaultPasswordFormModalContent/index.js ================================================ import { useMemo } from 'react' import { useLingui } from '@lingui/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { Validator } from '@tetherto/pear-apps-utils-validator' import { html } from 'htm/react' import { Description, Header, Title, UnlockVaultContainer } from './styles' import { FormModalHeaderWrapper } from '../../../components/FormModalHeaderWrapper' import { useLoadingContext } from '../../../context/LoadingContext' import { useModal } from '../../../context/ModalContext' import { ButtonPrimary, PearPassPasswordField } from '../../../lib-react-components' import { logger } from '../../../utils/logger' import { ModalContent } from '../ModalContent' /** * * @param {Object} props * @param {Object} props.vault * @param {string} props.vault.id * @param {string} [props.vault.name] * @param {(password: string) => Promise | void} [props.onSubmit] */ export const VaultPasswordFormModalContent = ({ vault, onSubmit }) => { const { i18n } = useLingui() const { closeModal } = useModal() const { setIsLoading } = useLoadingContext() const schema = Validator.object({ password: Validator.string().required(i18n._('Password is required')) }) const { register, handleSubmit, setErrors } = useForm({ initialValues: { password: '' }, validate: (values) => schema.validate(values) }) const submit = async (values) => { if (!vault.id) { return } try { setIsLoading(true) await onSubmit?.(values.password) setIsLoading(false) } catch (error) { logger.error('VaultPasswordFormModalContent', error) setIsLoading(false) setErrors({ password: i18n._('Invalid password') }) } } const titles = useMemo( () => ({ title: i18n._('Enter Your Vault Password'), description: i18n._( 'Unlock your {vaultName} Vault to access your stored passwords.', { vaultName: vault.name ?? vault.id } ) }), [] ) return html` <${ModalContent} onClose=${closeModal} headerChildren=${html` <${FormModalHeaderWrapper}> <${Header}> <${Title}> ${titles.title} <${Description}> ${titles.description} `} > <${UnlockVaultContainer} onSubmit=${handleSubmit(submit)}> <${PearPassPasswordField} ...${register('password')} /> <${ButtonPrimary} type="submit"> ${i18n._('Unlock Vault')} ` } ================================================ FILE: src/containers/Modal/VaultPasswordFormModalContent/styles.js ================================================ import styled from 'styled-components' export const Header = styled.div` display: flex; flex-direction: column; align-items: flex-start; gap: 10px; ` export const Title = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-style: normal; font-weight: 400; line-height: normal; ` export const Description = styled.span` color: ${({ theme }) => theme.colors.grey100.mode1}; font-family: 'Inter'; font-size: 12px; font-style: normal; font-weight: 400; line-height: normal; ` export const VaultsContainer = styled.div` display: flex; flex-direction: column; align-items: center; gap: 15px; ` export const UnlockVaultContainer = styled.form` display: flex; flex-direction: column; align-items: self-start; gap: 20px; ` ================================================ FILE: src/containers/Modal/index.js ================================================ import { ModalWrapper } from './styles' export { ModalWrapper } ================================================ FILE: src/containers/Modal/styles.js ================================================ import styled from 'styled-components' export const ModalWrapper = styled.div` position: fixed; top: 0; left: 0; width: 100vw; height: 100%; display: flex; justify-content: center; align-items: center; z-index: 500; ` export const DropdownsWrapper = styled.div` display: flex; gap: 20px; ` ================================================ FILE: src/containers/MultiSelectActionsBar/MultiSelectActionsBar.styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' import { HEADER_MIN_HEIGHT } from '../../constants/layout' export const createStyles = (colors: ThemeColors) => ({ container: { display: 'flex' as const, alignItems: 'center' as const, justifyContent: 'space-between' as const, gap: `${rawTokens.spacing8}px`, height: `${HEADER_MIN_HEIGHT}px`, paddingInline: `${rawTokens.spacing12}px`, borderBottom: `1px solid ${colors.colorBorderPrimary}`, backgroundColor: colors.colorSurfacePrimary, boxSizing: 'border-box' as const, flexShrink: 0 }, label: { overflow: 'hidden' as const, textOverflow: 'ellipsis' as const, whiteSpace: 'nowrap' as const, minWidth: 0 }, actions: { display: 'flex' as const, alignItems: 'center' as const, gap: `${rawTokens.spacing4}px`, flexShrink: 0 }, buttonWrapper: { position: 'relative' as const, display: 'inline-flex' as const }, tooltip: { // Right-anchor so the tooltip grows leftward into the screen — the actions // bar sits at the right edge, and centered tooltips overflow the viewport. position: 'absolute' as const, top: '100%', right: 0, transform: 'translateY(6px)', zIndex: 10, pointerEvents: 'none' as const, whiteSpace: 'nowrap' as const }, destructiveDivider: { width: '1px', height: '12px', backgroundColor: colors.colorBorderPrimary, marginInline: `${rawTokens.spacing4}px`, flexShrink: 0 } }) ================================================ FILE: src/containers/MultiSelectActionsBar/MultiSelectActionsBar.test.tsx ================================================ import React from 'react' import '@testing-library/jest-dom' import { fireEvent, render, screen } from '@testing-library/react' jest.mock('../../hooks/useTranslation', () => ({ useTranslation: () => ({ t: (key: string, values?: Record) => { if (!values) return key return Object.entries(values).reduce( (acc, [k, v]) => acc.replace(`{${k}}`, String(v)), key ) } }) })) jest.mock('@tetherto/pearpass-lib-ui-kit', () => { const React = require('react') return { useTheme: () => ({ theme: { colors: { colorTextPrimary: '#fff', colorBorderPrimary: '#222', colorSurfacePrimary: '#000' } } }), rawTokens: new Proxy({}, { get: () => 0 }), Button: ({ children, onClick, disabled, ...rest }: { children?: React.ReactNode onClick?: () => void disabled?: boolean [key: string]: unknown }) => React.createElement( 'button', { type: 'button', onClick, disabled, ...rest }, children ), Snackbar: ({ text, testID }: { text: string; testID?: string }) => React.createElement( 'div', { 'data-testid': testID ?? 'snackbar' }, text ), Text: ({ children, ...rest }: { children?: React.ReactNode [key: string]: unknown }) => React.createElement('span', { ...rest }, children) } }) jest.mock('@tetherto/pearpass-lib-ui-kit/icons', () => { const React = require('react') const Icon = (name: string) => () => React.createElement('span', { 'data-icon': name }) return { DriveFileMoveOutlined: Icon('DriveFileMoveOutlined'), StarFilled: Icon('StarFilled'), StarOutlined: Icon('StarOutlined'), TrashOutlined: Icon('TrashOutlined') } }) import { MultiSelectActionsBar } from './MultiSelectActionsBar' describe('MultiSelectActionsBar', () => { const baseProps = { selectedCount: 3, allSelectedFavorited: false, canMove: true, onMove: jest.fn(), onToggleFavorite: jest.fn(), onDelete: jest.fn() } beforeEach(() => { jest.clearAllMocks() }) it('renders count label and all three action buttons', () => { render() expect(screen.getByTestId('multi-select-count').textContent).toBe( '3 Items selected' ) expect(screen.getByTestId('multi-select-move')).toBeInTheDocument() expect(screen.getByTestId('multi-select-favorite')).toBeInTheDocument() expect(screen.getByTestId('multi-select-delete')).toBeInTheDocument() }) it('uses singular label when exactly one item is selected', () => { render() expect(screen.getByTestId('multi-select-count').textContent).toBe( '1 Item selected' ) }) it('disables all action buttons when no records are selected', () => { render() expect(screen.getByTestId('multi-select-move')).toBeDisabled() expect(screen.getByTestId('multi-select-favorite')).toBeDisabled() expect(screen.getByTestId('multi-select-delete')).toBeDisabled() }) it('disables only the move button when no folders exist', () => { render() expect(screen.getByTestId('multi-select-move')).toBeDisabled() expect(screen.getByTestId('multi-select-favorite')).not.toBeDisabled() expect(screen.getByTestId('multi-select-delete')).not.toBeDisabled() }) it('invokes the matching handlers when action buttons are clicked', () => { const onMove = jest.fn() const onToggleFavorite = jest.fn() const onDelete = jest.fn() render( ) fireEvent.click(screen.getByTestId('multi-select-move')) fireEvent.click(screen.getByTestId('multi-select-favorite')) fireEvent.click(screen.getByTestId('multi-select-delete')) expect(onMove).toHaveBeenCalledTimes(1) expect(onToggleFavorite).toHaveBeenCalledTimes(1) expect(onDelete).toHaveBeenCalledTimes(1) }) it('shows a snackbar tooltip on hover over the move button', () => { render() expect( screen.queryByTestId('multi-select-move-tooltip') ).not.toBeInTheDocument() const move = screen.getByTestId('multi-select-move') const wrapper = move.parentElement as HTMLElement fireEvent.mouseEnter(wrapper) expect(screen.getByTestId('multi-select-move-tooltip').textContent).toBe( 'Move to Another Folder' ) fireEvent.mouseLeave(wrapper) expect( screen.queryByTestId('multi-select-move-tooltip') ).not.toBeInTheDocument() }) it('swaps favorite tooltip wording based on allSelectedFavorited', () => { const { rerender } = render() const wrapper = screen.getByTestId('multi-select-favorite') .parentElement as HTMLElement fireEvent.mouseEnter(wrapper) expect(screen.getByTestId('multi-select-favorite-tooltip').textContent).toBe( 'Add to Favorites' ) fireEvent.mouseLeave(wrapper) rerender() const wrapper2 = screen.getByTestId('multi-select-favorite') .parentElement as HTMLElement fireEvent.mouseEnter(wrapper2) expect( screen.getByTestId('multi-select-favorite-tooltip').textContent ).toBe('Remove from Favorites') }) }) ================================================ FILE: src/containers/MultiSelectActionsBar/MultiSelectActionsBar.tsx ================================================ import React, { useState } from 'react' import { Button, Snackbar, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { DriveFileMoveOutlined, StarFilled, StarOutlined, TrashOutlined } from '@tetherto/pearpass-lib-ui-kit/icons' import { createStyles } from './MultiSelectActionsBar.styles' import { useTranslation } from '../../hooks/useTranslation' type MultiSelectActionsBarProps = { selectedCount: number allSelectedFavorited: boolean canMove: boolean onMove: () => void onToggleFavorite: () => void onDelete: () => void } type HoverButtonProps = { tooltip: string tooltipTestId: string children: React.ReactNode wrapperStyle: React.CSSProperties tooltipStyle: React.CSSProperties } const HoverButton = ({ tooltip, tooltipTestId, children, wrapperStyle, tooltipStyle }: HoverButtonProps) => { const [isHovered, setIsHovered] = useState(false) return ( setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onFocus={() => setIsHovered(true)} onBlur={() => setIsHovered(false)} > {children} {isHovered && (
)}
) } export const MultiSelectActionsBar = ({ selectedCount, allSelectedFavorited, canMove, onMove, onToggleFavorite, onDelete }: MultiSelectActionsBarProps) => { const { t } = useTranslation() const { theme } = useTheme() const styles = createStyles(theme.colors) const hasSelection = selectedCount > 0 const isMoveDisabled = !hasSelection || !canMove const isFavoriteDisabled = !hasSelection const isDeleteDisabled = !hasSelection const FavoriteIcon = allSelectedFavorited ? StarFilled : StarOutlined const iconColor = (disabled: boolean) => disabled ? theme.colors.colorTextDisabled : theme.colors.colorTextPrimary const moveIconStyle = { color: iconColor(isMoveDisabled) } const favoriteIconStyle = { color: iconColor(isFavoriteDisabled) } const deleteIconStyle = { color: iconColor(isDeleteDisabled) } const selectedLabel = selectedCount === 1 ? t('1 Item selected') : t('{count} Items selected', { count: selectedCount }) const favoriteLabel = allSelectedFavorited ? t('Remove from Favorites') : t('Add to Favorites') return (
{selectedLabel}
) } ================================================ FILE: src/containers/MultiSelectActionsBar/index.ts ================================================ export { MultiSelectActionsBar } from './MultiSelectActionsBar' ================================================ FILE: src/containers/PassPhrase/PassPhraseSettings.js ================================================ import { useLingui } from '@lingui/react' import { PASSPHRASE_TYPE_OPTIONS } from '@tetherto/pearpass-lib-constants' import { html } from 'htm/react' import { PassPraseSettingsContainer, PassPraseSettingsRandomWordContainer, PassPraseSettingsRandomWordText, SwitchWrapper } from './styles' import { RadioSelect } from '../../components/RadioSelect' import { SwitchWithLabel } from '../../components/SwitchWithLabel' /** * @param {{ * selectedType: number, * setSelectedType: (value: number) => void, * withRandomWord: boolean, * setWithRandomWord: (value: boolean) => void, * isDisabled: boolean, * testId?: string * }} props */ export const PassPhraseSettings = ({ selectedType, setSelectedType, withRandomWord, setWithRandomWord, isDisabled, testId }) => { const { i18n } = useLingui() return html` <${PassPraseSettingsContainer} data-testid=${testId}> <${RadioSelect} title=${i18n._('Type')} options=${PASSPHRASE_TYPE_OPTIONS.map((option) => ({ label: i18n._('{count} words', { count: option.value }), value: option.value }))} selectedOption=${selectedType} onChange=${(value) => setSelectedType(value)} optionStyle=${{ fontSize: 12, fontWeight: 400 }} titleStyle=${{ fontSize: 12, marginBottom: 5, fontWeight: 500 }} disabled=${isDisabled} /> <${PassPraseSettingsRandomWordContainer}> <${PassPraseSettingsRandomWordText}>${i18n._('+1 random word')} <${SwitchWrapper}> <${SwitchWithLabel} testId="passphrase-random-word-toggle" isOn=${withRandomWord} onChange=${(value) => setWithRandomWord(value)} disabled=${isDisabled} /> ` } ================================================ FILE: src/containers/PassPhrase/PassPhraseV2.styles.ts ================================================ import { rawTokens, type ThemeColors } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = ( colors: ThemeColors, { hasError }: { hasError: boolean } ) => ({ section: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing12}px`, width: '100%' }, groupContainer: { borderWidth: 1, borderStyle: 'solid' as const, borderRadius: `${rawTokens.spacing8}px`, overflow: 'hidden' as const, backgroundColor: colors.colorSurfacePrimary, borderColor: hasError ? colors.colorSurfaceDestructiveElevated : colors.colorBorderPrimary }, optionSection: { padding: `${rawTokens.spacing12}px`, display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing12}px` }, headerRow: { display: 'flex' as const, flexDirection: 'row' as const, alignItems: 'flex-start' as const, gap: `${rawTokens.spacing12}px` }, headerInfo: { flex: 1 }, grid: { display: 'flex' as const, flexDirection: 'row' as const, flexWrap: 'wrap' as const, gap: `${rawTokens.spacing12}px` }, wordInputWrapper: { width: `calc(50% - ${rawTokens.spacing12 / 2}px)` }, optionSectionWithBorder: { borderTop: `1px solid ${colors.colorBorderPrimary}` }, copyIconWrapper:{ display: "flex", justifyContent: 'space-between', alignItems: "center" } }) ================================================ FILE: src/containers/PassPhrase/PassPhraseV2.tsx ================================================ import React, { useEffect, useRef, useState } from 'react' import { DEFAULT_SELECTED_TYPE, PASSPHRASE_WORD_COUNTS, VALID_WORD_COUNTS } from '@tetherto/pearpass-lib-constants' import { Button, FieldError, InputField, Radio, useTheme, Text } from '@tetherto/pearpass-lib-ui-kit' import { ContentCopy, ContentPaste } from '@tetherto/pearpass-lib-ui-kit/icons' import { useCopyToClipboard } from '../../hooks/useCopyToClipboard.electron' import { usePasteFromClipboard } from '../../hooks/usePasteFromClipboard' import { useToast } from '../../context/ToastContext' import { useTranslation } from '../../hooks/useTranslation' import { createStyles } from './PassPhraseV2.styles' type PassPhraseV2Props = { error?: string isCreateOrEdit?: boolean onChange?: (value: string) => void value?: string } const parsePassphraseText = (text: string): string[] => text .trim() .split(/[-\s]+/) .map((word) => word.trim()) .filter((word) => word.length > 0) const isValidRange = (wordCount: number): boolean => !wordCount || VALID_WORD_COUNTS.includes(wordCount) const getWordLabel = (index: number): string => { const position = index + 1 const remainder10 = position % 10 const remainder100 = position % 100 let suffix = 'th' if (remainder10 === 1 && remainder100 !== 11) suffix = 'st' else if (remainder10 === 2 && remainder100 !== 12) suffix = 'nd' else if (remainder10 === 3 && remainder100 !== 13) suffix = 'rd' return `${position}${suffix} Word` } const getSelectedTypeForWords = (wordCount: number): number => { if ( wordCount === PASSPHRASE_WORD_COUNTS.STANDARD_24 || wordCount === PASSPHRASE_WORD_COUNTS.WITH_RANDOM_24 ) { return PASSPHRASE_WORD_COUNTS.STANDARD_24 } if ( wordCount === PASSPHRASE_WORD_COUNTS.STANDARD_12 || wordCount === PASSPHRASE_WORD_COUNTS.WITH_RANDOM_12 ) { return PASSPHRASE_WORD_COUNTS.STANDARD_12 } return DEFAULT_SELECTED_TYPE } export const PassPhraseV2 = ({ error, isCreateOrEdit = false, onChange, value = '' }: PassPhraseV2Props) => { const { t } = useTranslation() const { theme } = useTheme() const { copyToClipboard } = useCopyToClipboard() const { pasteFromClipboard } = usePasteFromClipboard() const { setToast } = useToast() const lastCommittedValueRef = useRef(value) const initialWords = parsePassphraseText(value) const [selectedType, setSelectedType] = useState( getSelectedTypeForWords(initialWords.length) ) const [passphraseWords, setPassphraseWords] = useState(initialWords) const detectAndUpdateSettings = (words: string[]) => { setSelectedType(getSelectedTypeForWords(words.length)) } useEffect(() => { if (value === lastCommittedValueRef.current) return if (!value?.trim().length) { setPassphraseWords([]) lastCommittedValueRef.current = value return } const words = parsePassphraseText(value) setPassphraseWords(words) detectAndUpdateSettings(words) lastCommittedValueRef.current = value }, [value]) const handlePasteFromClipboard = async () => { const pastedText = await pasteFromClipboard() if (!pastedText) return const words = parsePassphraseText(pastedText) if (!isValidRange(words.length)) { setToast({ message: t('Only 12 or 24 words are allowed') }) return } setPassphraseWords(words) detectAndUpdateSettings(words) lastCommittedValueRef.current = pastedText onChange?.(pastedText) } const expandedWords = Array.from( { length: Math.max(selectedType, passphraseWords.length || selectedType) }, (_, index) => passphraseWords[index] ?? '' ) const handleWordChange = (index: number, nextValue: string) => { const sanitized = nextValue.replace(/\s+/g, '').trim() const nextWords = [...expandedWords] nextWords[index] = sanitized setPassphraseWords(nextWords) const serialized = nextWords.filter(Boolean).join(' ') lastCommittedValueRef.current = serialized onChange?.(serialized) } const handleTypeSelect = (wordCount: number) => { setSelectedType(wordCount) if (passphraseWords.length > wordCount) { const nextWords = passphraseWords.slice(0, wordCount) setPassphraseWords(nextWords) const serialized = nextWords.filter(Boolean).join(' ') lastCommittedValueRef.current = serialized onChange?.(serialized) } } const optionsToRender = isCreateOrEdit ? [PASSPHRASE_WORD_COUNTS.STANDARD_12, PASSPHRASE_WORD_COUNTS.STANDARD_24] : [selectedType] const detailWords = passphraseWords.length ? passphraseWords : parsePassphraseText(value) const styles = createStyles(theme.colors, { hasError: !!error }) return (
{!isCreateOrEdit ? (
{t(`Recovery Phrase`)}
{detailWords.map((word, inputIndex) => (
))}
) : ( optionsToRender.map((wordCount: number, index: number) => { const isSelected = selectedType === wordCount const description = t( `Paste or enter ${wordCount} words. Optional +1 works only when pasted` ) const borderStyle: React.CSSProperties = index > 0 ? styles.optionSectionWithBorder : {} return (
handleTypeSelect(wordCount) : undefined } disabled={!isCreateOrEdit} />
{isSelected ? (
{expandedWords.map((word, inputIndex) => (
handleWordChange(inputIndex, e.target.value) } readOnly={!isCreateOrEdit} testID={`passphrase-word-input-${inputIndex}`} />
))}
) : null}
) }) )}
{!!error?.length && {error}}
) } ================================================ FILE: src/containers/PassPhrase/__tests__/PassPhrase.test.js ================================================ import React from 'react' import '@testing-library/jest-dom' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { PassPhrase } from '../' // i18n returns key jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (s) => s } }) })) // Styles jest.mock('../styles', () => ({ Container: ({ children }) =>
{children}
, PassPhraseHeader: ({ children }) => (
{children}
), HeaderText: ({ children }) =>

{children}

, PasteButton: ({ children, onClick, type, ...props }) => ( ), CopyPasteText: ({ children }) => ( {children} ), PassPhraseContainer: ({ children }) => (
{children}
), ErrorContainer: ({ children }) => (
{children}
), ErrorText: ({ children }) =>
{children}
})) // Icons and shared components jest.mock('../../../lib-react-components', () => ({ CopyIcon: () => , PasteIcon: () => , PassPhraseIcon: () => , ErrorIcon: () => })) // Badge item capture const mockBadgeCalls = [] jest.mock('../../../components/BadgeTextItem', () => ({ BadgeTextItem: (props) => { mockBadgeCalls.push(props) return (
#{props.count}:{props.word}
) } })) // PassPhraseSettings capture const mockSettingsCalls = [] jest.mock('../PassPhraseSettings', () => ({ PassPhraseSettings: (props) => { mockSettingsCalls.push(props) return
} })) // Hooks const mockCopy = jest.fn() jest.mock('../../../hooks/useCopyToClipboard.electron', () => ({ useCopyToClipboard: () => ({ copyToClipboard: mockCopy }) })) const mockPaste = jest.fn() jest.mock('../../../hooks/usePasteFromClipboard', () => ({ usePasteFromClipboard: () => ({ pasteFromClipboard: mockPaste }) })) const mockSetToast = jest.fn() jest.mock('../../../context/ToastContext', () => ({ useToast: () => ({ setToast: mockSetToast }) })) describe('PassPhrase (container)', () => { const renderComponent = (props = {}) => render( ) beforeEach(() => { mockBadgeCalls.length = 0 mockSettingsCalls.length = 0 mockCopy.mockReset() mockPaste.mockReset() mockSetToast.mockReset() }) test('renders header and no words initially', () => { renderComponent() expect(screen.getByTestId('pp-icon')).toBeInTheDocument() expect(screen.getByTestId('title')).toHaveTextContent('Recovery phrase') expect(screen.getByTestId('words').children.length).toBe(0) }) test('renders BadgeTextItem for each word in value', () => { renderComponent({ value: 'alpha@ !beta ,.gamma' }) expect(screen.getAllByTestId('badge-item')).toHaveLength(3) expect(mockBadgeCalls.map((c) => c.word)).toEqual([ 'alpha@', '!beta', ',.gamma' ]) expect(mockBadgeCalls.map((c) => c.count)).toEqual([1, 2, 3]) }) test('copy button copies when not create/edit', () => { renderComponent({ value: 'one two' }) fireEvent.click(screen.getByTestId('passphrase-button-copy')) expect(mockCopy).toHaveBeenCalledWith('one two') }) test('paste button pastes, updates list, and calls onChange in create/edit', async () => { const onChange = jest.fn() const value = 'fo1o-1bar-3bam,.z-q#ux-quux-corge-gra.!ult-garpl&&!@y-waldo-!red-pl@ugh-%@xyzzy' mockPaste.mockResolvedValueOnce(value) // 12 words renderComponent({ isCreateOrEdit: true, onChange, value: '' }) fireEvent.click(screen.getByTestId('passphrase-button-paste')) await waitFor(() => expect(onChange).toHaveBeenCalledWith(value)) expect(mockPaste).toHaveBeenCalledTimes(1) // After paste we should have badges for words const splittedVal = value.split('-') expect(mockBadgeCalls.length).toBe(splittedVal.length) mockBadgeCalls.forEach((c) => { expect(c.word).toBe(splittedVal[c.count - 1]) }) }) test('renders settings in create/edit, disabled when words present', async () => { mockPaste.mockResolvedValueOnce('a b c d e f g h i j k l') // 12 words renderComponent({ isCreateOrEdit: true }) // Initially with zero words: settings shown and not disabled expect(screen.getByTestId('settings')).toBeInTheDocument() expect(mockSettingsCalls[0].isDisabled).toBe(false) fireEvent.click(screen.getByTestId('passphrase-button-paste')) await waitFor(() => expect(mockBadgeCalls.length).toBe(12)) const lastSettings = mockSettingsCalls[mockSettingsCalls.length - 1] expect(lastSettings.isDisabled).toBe(true) }) test('shows error text when error prop is provided', () => { renderComponent({ error: 'Oops!' }) expect(screen.getByTestId('error-text')).toHaveTextContent('Oops!') }) test('13 words: switch is on and radio selected to 12', () => { const thirteen = 'one two three four five six seven eight nine ten eleven twelve rand' renderComponent({ isCreateOrEdit: true, value: thirteen }) // Last settings props reflect current state const last = mockSettingsCalls[mockSettingsCalls.length - 1] expect(last.withRandomWord).toBe(true) expect(last.selectedType).toBe(12) }) test('25 words: switch is on and radio selected to 24', () => { const twentyFive = 'a-b-c-d-!-@-g-h-i-j-k-l-(-)-o-p-q-r-s-3-u-v-w-x-y' renderComponent({ isCreateOrEdit: true, value: twentyFive }) const last = mockSettingsCalls[mockSettingsCalls.length - 1] expect(last.withRandomWord).toBe(true) expect(last.selectedType).toBe(24) }) test('after paste: settings are disabled', async () => { mockPaste.mockResolvedValueOnce( 'one two three four five six seven eight nine ten eleven twelve' ) renderComponent({ isCreateOrEdit: true }) fireEvent.click(screen.getByTestId('passphrase-button-paste')) await waitFor(() => expect(mockBadgeCalls.length).toBe(12)) const last = mockSettingsCalls[mockSettingsCalls.length - 1] expect(last.isDisabled).toBe(true) }) test('shows error toast when pasting invalid word count', async () => { const onChange = jest.fn() mockPaste.mockResolvedValueOnce('one-two-three-four-five') renderComponent({ isCreateOrEdit: true, onChange, value: '' }) fireEvent.click(screen.getByTestId('passphrase-button-paste')) await waitFor(() => { expect(mockSetToast).toHaveBeenCalledWith({ message: 'Only 12 or 24 words are allowed', icon: expect.anything() }) }) expect(onChange).not.toHaveBeenCalled() expect(mockBadgeCalls.length).toBe(0) }) }) ================================================ FILE: src/containers/PassPhrase/__tests__/PassPhraseSettings.test.js ================================================ import React from 'react' import { render, screen, fireEvent } from '@testing-library/react' import { PASSPHRASE_WORD_COUNTS } from '@tetherto/pearpass-lib-constants' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import '@testing-library/jest-dom' import { PassPhraseSettings } from '../PassPhraseSettings' jest.mock('../styles', () => ({ PassPraseSettingsContainer: ({ children }) => (
{children}
), PassPraseSettingsRandomWordContainer: ({ children }) => (
{children}
), PassPraseSettingsRandomWordText: ({ children }) => (
{children}
), SwitchWrapper: ({ children }) => (
{children}
) })) // Mock i18n to handle translation with interpolation jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (key, values) => { if (values?.count !== undefined) { return `${values.count} words` } return key } } }) })) // Capture props passed into children const mockRadioSelectSpy = jest.fn() const mockSwitchWithLabelSpy = jest.fn() jest.mock('../../../components/RadioSelect', () => ({ RadioSelect: (props) => { mockRadioSelectSpy(props) return ( ) } })) jest.mock('../../../components/SwitchWithLabel', () => ({ SwitchWithLabel: (props) => { mockSwitchWithLabelSpy(props) return ( ) } })) describe('PassPhraseSettings', () => { const renderComponent = (overrideProps = {}) => { const defaultProps = { selectedType: PASSPHRASE_WORD_COUNTS.STANDARD_12, setSelectedType: jest.fn(), withRandomWord: false, setWithRandomWord: jest.fn(), isDisabled: false } const props = { ...defaultProps, ...overrideProps } const utils = render( ) return { ...utils, props } } beforeEach(() => { mockRadioSelectSpy.mockClear() mockSwitchWithLabelSpy.mockClear() }) test('renders and passes correct props to RadioSelect', () => { const { props } = renderComponent() const radio = screen.getByTestId('radio-select') expect(radio).toBeInTheDocument() expect(radio).toHaveTextContent('Type') const passed = mockRadioSelectSpy.mock.calls[0][0] expect(passed.selectedOption).toBe(props.selectedType) expect(passed.disabled).toBe(false) expect(passed.options.map((o) => o.value)).toEqual([ PASSPHRASE_WORD_COUNTS.STANDARD_12, PASSPHRASE_WORD_COUNTS.STANDARD_24 ]) expect(passed.options.map((o) => o.label)).toEqual([ `${PASSPHRASE_WORD_COUNTS.STANDARD_12} words`, `${PASSPHRASE_WORD_COUNTS.STANDARD_24} words` ]) }) test('invokes setSelectedType when RadioSelect triggers onChange', () => { const { props } = renderComponent() fireEvent.click(screen.getByTestId('radio-select')) expect(props.setSelectedType).toHaveBeenCalledWith( PASSPHRASE_WORD_COUNTS.STANDARD_24 ) }) test('renders and toggles SwitchWithLabel', () => { const { props } = renderComponent({ withRandomWord: false }) const passed = mockSwitchWithLabelSpy.mock.calls[0][0] expect(passed.isOn).toBe(false) expect(passed.disabled).toBe(false) fireEvent.click(screen.getByTestId('switch-with-label')) expect(props.setWithRandomWord).toHaveBeenCalledWith(true) }) test('forwards disabled to children', () => { renderComponent({ isDisabled: true }) const radioProps = mockRadioSelectSpy.mock.calls[0][0] const switchProps = mockSwitchWithLabelSpy.mock.calls[0][0] expect(radioProps.disabled).toBe(true) expect(switchProps.disabled).toBe(true) }) }) ================================================ FILE: src/containers/PassPhrase/index.js ================================================ import { useState, useEffect } from 'react' import { PASSPHRASE_WORD_COUNTS, VALID_WORD_COUNTS, DEFAULT_SELECTED_TYPE } from '@tetherto/pearpass-lib-constants' import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' import { html } from 'htm/react' import { PassPhraseSettings } from './PassPhraseSettings' import { Container, PassPhraseHeader, HeaderText, PasteButton, CopyPasteText, PassPhraseContainer, ErrorContainer, ErrorText } from './styles' import { BadgeTextItem } from '../../components/BadgeTextItem' import { CopyButton } from '../../components/CopyButton' import { useToast } from '../../context/ToastContext' import { usePasteFromClipboard } from '../../hooks/usePasteFromClipboard' import { useTranslation } from '../../hooks/useTranslation' import { PassPhraseIcon, ErrorIcon, PasteIcon } from '../../lib-react-components' /** * @param {{ * isCreateOrEdit: boolean, * onChange: (value: string) => void, * value: string, * error: string, * testId?: string * }} props */ export const PassPhrase = ({ isCreateOrEdit, onChange, value, error, testId }) => { const { t } = useTranslation() const { setToast } = useToast() const [selectedType, setSelectedType] = useState(DEFAULT_SELECTED_TYPE) const [withRandomWord, setWithRandomWord] = useState(false) const [passphraseWords, setPassphraseWords] = useState([]) const { pasteFromClipboard } = usePasteFromClipboard() const parsePassphraseText = (text) => text .trim() .split(/[-\s]+/) .map((word) => word.trim()) .filter((word) => word.length > 0) const detectAndUpdateSettings = (words) => { const wordCount = words.length if ( wordCount === PASSPHRASE_WORD_COUNTS.STANDARD_12 || wordCount === PASSPHRASE_WORD_COUNTS.WITH_RANDOM_12 ) { setSelectedType(PASSPHRASE_WORD_COUNTS.STANDARD_12) setWithRandomWord(wordCount === PASSPHRASE_WORD_COUNTS.WITH_RANDOM_12) } else if ( wordCount === PASSPHRASE_WORD_COUNTS.STANDARD_24 || wordCount === PASSPHRASE_WORD_COUNTS.WITH_RANDOM_24 ) { setSelectedType(PASSPHRASE_WORD_COUNTS.STANDARD_24) setWithRandomWord(wordCount === PASSPHRASE_WORD_COUNTS.WITH_RANDOM_24) } } const isValidRange = (wordCount) => !wordCount || VALID_WORD_COUNTS.includes(wordCount) const handlePasteFromClipboard = async () => { const pastedText = await pasteFromClipboard() if (pastedText) { const words = parsePassphraseText(pastedText) if (!isValidRange(words.length)) { setToast({ message: t('Only 12 or 24 words are allowed'), icon: ErrorIcon }) return } setPassphraseWords(words) detectAndUpdateSettings(words) if (onChange) { onChange(pastedText) } } } useEffect(() => { if (value) { const words = parsePassphraseText(value) setPassphraseWords(words) detectAndUpdateSettings(words) } }, [value]) const isCreateOrEditWithValidRange = isCreateOrEdit && isValidRange(passphraseWords.length) return html` <${Container} data-testid=${testId}> <${PassPhraseHeader}> <${PassPhraseIcon} /> <${HeaderText}>${t('Recovery phrase 2')} <${PassPhraseContainer} data-testid="passphrase-words-container"> ${passphraseWords.map( (word, i) => html`<${BadgeTextItem} testId=${`passphrase-word-${i + 1}`} key=${`${word}-${i}`} count=${i + 1} word=${word || ''} />` )} ${isCreateOrEdit ? html` <${PasteButton} type="button" data-testid="passphrase-button-paste" withExtraBottomSpace=${!isCreateOrEditWithValidRange} onClick=${handlePasteFromClipboard} > <${PasteIcon} color=${colors.primary400?.mode1} /> <${CopyPasteText}>${t('Paste from clipboard')} ` : html`
<${CopyButton} value=${value} testId="passphrase-button-copy" text=${t('Copy')} color=${colors.primary400?.mode1} withExtraBottomSpace=${!isCreateOrEditWithValidRange} />
`} ${isCreateOrEditWithValidRange && html`<${PassPhraseSettings} testId="passphrase-settings" selectedType=${selectedType} setSelectedType=${setSelectedType} withRandomWord=${withRandomWord} setWithRandomWord=${setWithRandomWord} isDisabled=${!!passphraseWords.length} />`} ${!!error?.length && html`<${ErrorContainer}> <${ErrorIcon} size="10" /> <${ErrorText}>${error} `} ` } ================================================ FILE: src/containers/PassPhrase/styles.js ================================================ import styled from 'styled-components' export const PassPraseSettingsContainer = styled.div` font-size: 12px; background-color: ${({ theme }) => theme.colors.grey350.mode1}; border-radius: 10px; padding: 10px; gap: 15px; display: flex; flex-direction: column; ` export const PassPraseSettingsTitle = styled.div` font-size: 14px; font-weight: 400; color: ${({ theme }) => theme.colors.white.mode1}; font-family: Inter; ` export const PassPraseSettingsRandomWordContainer = styled.div` display: flex; flex-direction: row; justify-content: space-between; align-items: center; ` export const PassPraseSettingsRandomWordText = styled.div` font-size: 12px; font-weight: 400; color: ${({ theme }) => theme.colors.white.mode1}; font-family: Inter; ` export const SwitchWrapper = styled.div` transform: scale(1.2); ` export const Container = styled.div` background-color: ${({ theme }) => theme.colors.grey400?.mode1}; border: 1px solid ${({ theme }) => theme.colors.grey100?.mode1}; border-radius: 10px; padding: 10px; display: flex; flex-direction: column; gap: 20px; ` export const PassPhraseHeader = styled.div` display: flex; flex-direction: row; align-items: center; gap: 10px; ` export const HeaderText = styled.span` color: ${({ theme }) => theme.colors.white?.mode1}; font-family: Inter; font-size: 12px; font-weight: 400; ` export const PasteButton = styled.button` display: flex; flex-direction: row; justify-content: center; align-items: center; gap: 10px; color: ${({ theme }) => theme.colors.primary400?.mode1}; background: transparent; border: none; cursor: pointer; margin-bottom: ${({ withExtraBottomSpace }) => withExtraBottomSpace ? '10px' : '0'}; ` export const CopyPasteText = styled.span` color: ${({ theme }) => theme.colors.primary400?.mode1}; font-family: Inter; font-size: 14px; text-align: center; display: inline-flex; ` export const PassPhraseContainer = styled.div` display: flex; flex-direction: row; flex-wrap: wrap; row-gap: 15px; justify-content: space-around; ` export const ErrorContainer = styled.div` display: flex; flex-direction: row; align-items: center; gap: 5px; ` export const ErrorText = styled.span` color: ${({ theme }) => theme.colors.categoryIdentity.mode1}; font-family: Inter; font-size: 10px; font-weight: 500; ` ================================================ FILE: src/containers/RecordDetails/CreditCardDetailsForm/CreditCardDetailsFormV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ container: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, width: '100%' } }) ================================================ FILE: src/containers/RecordDetails/CreditCardDetailsForm/CreditCardDetailsFormV2.tsx ================================================ import { useEffect, useMemo } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { AttachmentField, InputField, MultiSlotInput, PasswordField } from '@tetherto/pearpass-lib-ui-kit' import { html } from 'htm/react' import { ATTACHMENTS_FIELD_KEY } from '../../../constants/formFields' import { useModal } from '../../../context/ModalContext' import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard.electron' import { useGetMultipleFiles } from '../../../hooks/useGetMultipleFiles' import { useTranslation } from '../../../hooks/useTranslation' import { DisplayPictureModalContentV2 } from '../../Modal/DisplayPictureModalContentV2/DisplayPictureModalContentV2' import { createStyles } from './CreditCardDetailsFormV2.styles' import { toReadOnlyFieldProps } from './utils' type Attachment = { id?: string tempId?: string name: string buffer?: ArrayBuffer | Uint8Array } type CustomField = { type: string name?: string note?: string } type CreditCardRecord = { id: string folder?: string attachments?: Attachment[] data: { title?: string name?: string number?: string expireDate?: string securityCode?: string pinCode?: string note?: string customFields?: CustomField[] } } type CreditCardDetailsFormV2Props = { initialRecord?: CreditCardRecord selectedFolder?: string } type CreditCardDetailsFormValues = { name: string number: string expireDate: string securityCode: string pinCode: string note: string customFields: CustomField[] folder?: string attachments: Attachment[] } const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'] const getExtension = (filename?: string) => filename?.split('.').pop()?.toLowerCase() ?? '' export const CreditCardDetailsFormV2 = ({ initialRecord, selectedFolder }: CreditCardDetailsFormV2Props) => { const { t } = useTranslation() const styles = createStyles() const { setModal } = useModal() const { copyToClipboard } = useCopyToClipboard() const initialValues = useMemo( () => ({ name: initialRecord?.data?.name ?? '', number: initialRecord?.data?.number ?? '', expireDate: initialRecord?.data?.expireDate ?? '', securityCode: initialRecord?.data?.securityCode ?? '', pinCode: initialRecord?.data?.pinCode ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [] }), [initialRecord, selectedFolder] ) const { register, setValues, values, setValue } = useForm({ initialValues }) useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) useEffect(() => { setValues(initialValues) }, [initialValues, setValues]) const hasName = !!values.name?.length const hasNumber = !!values.number?.length const hasExpireDate = !!values.expireDate?.length const hasSecurityCode = !!values.securityCode?.length const hasPinCode = !!values.pinCode?.length const hasNote = !!values.note?.length const hasCustomFields = !!values.customFields?.length const hasAttachments = !!values.attachments?.length const handleAttachmentPress = (attachment: Attachment) => { if (!attachment?.buffer || !attachment?.name) return const blob = new Blob([attachment.buffer as BlobPart]) const url = URL.createObjectURL(blob) const isImage = IMAGE_EXTENSIONS.includes(getExtension(attachment.name)) if (isImage) { setModal( html`<${DisplayPictureModalContentV2} url=${url} name=${attachment.name} />` ) return } const a = document.createElement('a') a.href = url a.download = attachment.name document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } return (
{(hasName || hasNumber || hasExpireDate || hasSecurityCode || hasPinCode) && ( {hasName && ( )} {hasNumber && ( )} {hasExpireDate && ( )} {hasSecurityCode && ( )} {hasPinCode && ( )} )} {hasAttachments && ( {(values.attachments as Attachment[]).map((attachment, index) => ( handleAttachmentPress(attachment)} /> ))} )} {hasNote && ( )} {hasCustomFields && ( {(values.customFields as CustomField[]).map((field, index) => ( )}
) } ================================================ FILE: src/containers/RecordDetails/CreditCardDetailsForm/index.js ================================================ import React, { useEffect } from 'react' import { useLingui } from '@lingui/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { html } from 'htm/react' import { CopyButton } from '../../../components/CopyButton' import { FormGroup } from '../../../components/FormGroup' import { FormWrapper } from '../../../components/FormWrapper' import { InputFieldNote } from '../../../components/InputFieldNote' import { ATTACHMENTS_FIELD_KEY } from '../../../constants/formFields' import { useGetMultipleFiles } from '../../../hooks/useGetMultipleFiles' import { CalendarIcon, CreditCardIcon, InputField, NineDotsIcon, PasswordField, UserIcon } from '../../../lib-react-components' import { AttachmentField } from '../../AttachmentField' import { CustomFields } from '../../CustomFields' /** * @param {{ * initialRecord: { * data: { * title: string * name: string * number: string * expireDate: string * securityCode: string * pinCode: string * note: string * customFields: { * type: string * name: string * }[] * attachments: { id: string, name: string}[] * } * } * selectedFolder?: string * }} props */ export const CreditCardDetailsForm = ({ initialRecord, selectedFolder }) => { const { i18n } = useLingui() const initialValues = React.useMemo( () => ({ name: initialRecord?.data?.name ?? '', number: initialRecord?.data?.number ?? '', expireDate: initialRecord?.data?.expireDate ?? '', securityCode: initialRecord?.data?.securityCode ?? '', pinCode: initialRecord?.data?.pinCode ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [] }), [initialRecord, selectedFolder] ) const { register, registerArray, setValues, values, setValue } = useForm({ initialValues }) const { value: list, registerItem } = registerArray('customFields') useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) useEffect(() => { setValues(initialValues) }, [initialValues, setValues]) return html` <${FormWrapper}> <${FormGroup}> ${!!values?.name?.length && html` <${InputField} label=${i18n._('Full name')} placeholder=${i18n._('Full name')} variant="outline" icon=${UserIcon} isDisabled ...${register('name')} additionalItems=${html` <${CopyButton} value=${values.name} /> `} /> `} ${!!values?.number?.length && html` <${InputField} label=${i18n._('Number on card')} placeholder="1234 1234 1234 1234 " variant="outline" icon=${CreditCardIcon} isDisabled ...${register('number')} value=${values.number.replace(/(.{4})/g, '$1 ').trim()} additionalItems=${html` <${CopyButton} value=${values.number.replace(/\s/g, '')} /> `} /> `} ${!!values?.expireDate?.length && html` <${InputField} label=${i18n._('Date of expire')} placeholder="MM YY" variant="outline" icon=${CalendarIcon} isDisabled ...${register('expireDate')} additionalItems=${html` <${CopyButton} value=${values.expireDate} /> `} /> `} ${!!values?.securityCode?.length && html` <${PasswordField} label=${i18n._('Security code')} placeholder="123" variant="outline" icon=${CreditCardIcon} isDisabled ...${register('securityCode')} additionalItems=${html` <${CopyButton} value=${values.securityCode} /> `} /> `} ${!!values?.pinCode?.length && html` <${PasswordField} label=${i18n._('Pin code')} placeholder="1234" variant="outline" icon=${NineDotsIcon} isDisabled ...${register('pinCode')} additionalItems=${html` <${CopyButton} value=${values.pinCode} /> `} /> `} ${values?.attachments?.length > 0 && html` <${FormGroup}> ${values.attachments.map( (attachment) => html` <${AttachmentField} label=${i18n._('File')} attachment=${attachment} /> ` )} `} <${FormGroup}> ${!!values?.note?.length && html` <${InputFieldNote} isDisabled ...${register('note')} additionalItems=${html` <${CopyButton} value=${values.note} /> `} /> `} <${CustomFields} areInputsDisabled=${true} customFields=${list} register=${registerItem} /> ` } ================================================ FILE: src/containers/RecordDetails/CreditCardDetailsForm/utils.ts ================================================ type FieldRegistration = { name: string value: string onChange?: unknown error?: string } export const toReadOnlyFieldProps = (field: FieldRegistration) => ({ name: field.name, value: field.value }) ================================================ FILE: src/containers/RecordDetails/CustomDetailsForm/CustomDetailsFormV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ container: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, width: '100%' }, section: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing12}px` } }) ================================================ FILE: src/containers/RecordDetails/CustomDetailsForm/CustomDetailsFormV2.tsx ================================================ import { useEffect, useMemo } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { AttachmentField, InputField, MultiSlotInput, PasswordField, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { html } from 'htm/react' import { ATTACHMENTS_FIELD_KEY } from '../../../constants/formFields' import { useModal } from '../../../context/ModalContext' import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard.electron' import { useGetMultipleFiles } from '../../../hooks/useGetMultipleFiles' import { useTranslation } from '../../../hooks/useTranslation' import { DisplayPictureModalContentV2 } from '../../Modal/DisplayPictureModalContentV2/DisplayPictureModalContentV2' import { createStyles } from './CustomDetailsFormV2.styles' type Attachment = { id?: string tempId?: string name: string buffer?: ArrayBuffer | Uint8Array } type CustomField = { type: string name?: string note?: string } type CustomRecord = { id: string folder?: string attachments?: Attachment[] data: { title?: string note?: string customFields?: CustomField[] } } type CustomDetailsFormV2Props = { initialRecord?: CustomRecord selectedFolder?: string } type CustomDetailsFormValues = { note: string customFields: CustomField[] folder?: string attachments: Attachment[] } const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'] const getExtension = (filename?: string) => filename?.split('.').pop()?.toLowerCase() ?? '' export const CustomDetailsFormV2 = ({ initialRecord, selectedFolder }: CustomDetailsFormV2Props) => { const { t } = useTranslation() const { theme } = useTheme() const styles = createStyles() const { setModal } = useModal() const { copyToClipboard } = useCopyToClipboard() const initialValues = useMemo( () => ({ note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [] }), [initialRecord, selectedFolder] ) const { setValues, values, setValue } = useForm({ initialValues }) useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) useEffect(() => { setValues(initialValues) }, [initialValues, setValues]) const hasCustomFields = !!values.customFields?.length const hasAttachments = !!values.attachments?.length const hasNote = !!(values.note as string)?.length const handleAttachmentPress = (attachment: Attachment) => { if (!attachment?.buffer || !attachment?.name) return const blob = new Blob([attachment.buffer as BlobPart]) const url = URL.createObjectURL(blob) const isImage = IMAGE_EXTENSIONS.includes(getExtension(attachment.name)) if (isImage) { setModal( html`<${DisplayPictureModalContentV2} url=${url} name=${attachment.name} />` ) return } const a = document.createElement('a') a.href = url a.download = attachment.name document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } return (
{(hasAttachments || hasCustomFields || hasNote) && (
{t('Additional')} {hasNote && ( )} {hasAttachments && ( {(values.attachments as Attachment[]).map((attachment, index) => ( handleAttachmentPress(attachment)} /> ))} )} {hasCustomFields && ( {(values.customFields as CustomField[]).map((field, index) => ( )}
)}
) } ================================================ FILE: src/containers/RecordDetails/CustomDetailsForm/index.js ================================================ import React, { useEffect } from 'react' import { useLingui } from '@lingui/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { html } from 'htm/react' import { FormGroup } from '../../../components/FormGroup' import { FormWrapper } from '../../../components/FormWrapper' import { ATTACHMENTS_FIELD_KEY } from '../../../constants/formFields' import { useGetMultipleFiles } from '../../../hooks/useGetMultipleFiles' import { AttachmentField } from '../../AttachmentField' import { CustomFields } from '../../CustomFields' /** * * @param {{ * initialRecord: { * data: { * title: string * customFields: { * note: string * type: string * }[] * attachments: { id: string, name: string}[] * } * } * selectedFolder?: string * }} props * @returns */ export const CustomDetailsForm = ({ initialRecord, selectedFolder }) => { const { i18n } = useLingui() const initialValues = React.useMemo( () => ({ customFields: initialRecord?.data?.customFields || [], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [] }), [initialRecord, selectedFolder] ) const { registerArray, setValues, setValue, values } = useForm({ initialValues }) const { value: list, registerItem } = registerArray('customFields') useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) useEffect(() => { setValues(initialValues) }, [initialValues, setValues]) return html` <${FormWrapper} data-id="custom-details"> <${CustomFields} areInputsDisabled=${true} customFields=${list} register=${registerItem} /> ${values?.attachments?.length > 0 && html` <${FormGroup}> ${values.attachments.map( (attachment) => html` <${AttachmentField} label=${i18n._('File')} attachment=${attachment} /> ` )} `} ` } ================================================ FILE: src/containers/RecordDetails/IdentityDetailsForm/IdentityDetailsFormV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ container: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, width: '100%' }, section: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing12}px` } }) ================================================ FILE: src/containers/RecordDetails/IdentityDetailsForm/IdentityDetailsFormV2.tsx ================================================ import { useEffect, useMemo } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { DATE_FORMAT } from '@tetherto/pearpass-lib-constants' import { AttachmentField, InputField, MultiSlotInput, PasswordField, Text } from '@tetherto/pearpass-lib-ui-kit' import { html } from 'htm/react' import { ATTACHMENTS_FIELD_KEY } from '../../../constants/formFields' import { useModal } from '../../../context/ModalContext' import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard.electron' import { useGetMultipleFiles } from '../../../hooks/useGetMultipleFiles' import { useTranslation } from '../../../hooks/useTranslation' import { DisplayPictureModalContentV2 } from '../../Modal/DisplayPictureModalContentV2/DisplayPictureModalContentV2' import { createStyles } from './IdentityDetailsFormV2.styles' import { toReadOnlyFieldProps } from './utils' type Attachment = { id?: string tempId?: string name: string buffer?: ArrayBuffer | Uint8Array } type CustomField = { type: string name?: string note?: string } type IdentityRecord = { id: string folder?: string attachments?: Attachment[] data: { title?: string fullName?: string email?: string phoneNumber?: string address?: string zip?: string city?: string region?: string country?: string note?: string customFields?: CustomField[] passportFullName?: string passportNumber?: string passportIssuingCountry?: string passportDateOfIssue?: string passportExpiryDate?: string passportNationality?: string passportDob?: string passportGender?: string passportPicture?: Attachment[] idCardNumber?: string idCardDateOfIssue?: string idCardExpiryDate?: string idCardIssuingCountry?: string idCardPicture?: Attachment[] drivingLicenseNumber?: string drivingLicenseDateOfIssue?: string drivingLicenseExpiryDate?: string drivingLicenseIssuingCountry?: string drivingLicensePicture?: Attachment[] } } type FileFieldName = | 'attachments' | 'passportPicture' | 'idCardPicture' | 'drivingLicensePicture' type AttachmentSource = { attachment: Attachment fieldName: FileFieldName } const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'] const getExtension = (filename?: string) => filename?.split('.').pop()?.toLowerCase() ?? '' const getAttachmentKey = ( attachment: Pick, fieldName: FileFieldName, index: number ) => { if (attachment.id) return `id:${attachment.id}` if (attachment.name) return `name:${attachment.name}` return `${fieldName}:${index}` } const buildIdentityAttachmentSources = (values: { attachments?: Attachment[] passportPicture?: Attachment[] idCardPicture?: Attachment[] drivingLicensePicture?: Attachment[] }): AttachmentSource[] => { const sources: AttachmentSource[] = [] const indexByKey = new Map() const groups: Array<{ fieldName: FileFieldName; items: Attachment[] }> = [ { fieldName: 'attachments', items: values.attachments ?? [] }, { fieldName: 'passportPicture', items: values.passportPicture ?? [] }, { fieldName: 'idCardPicture', items: values.idCardPicture ?? [] }, { fieldName: 'drivingLicensePicture', items: values.drivingLicensePicture ?? [] } ] groups.forEach(({ fieldName, items }) => { items.forEach((attachment, index) => { const key = getAttachmentKey(attachment, fieldName, index) const existing = indexByKey.get(key) if (existing === undefined) { indexByKey.set(key, sources.length) sources.push({ attachment, fieldName }) return } const prev = sources[existing] if (!prev.attachment.buffer && attachment.buffer) { sources[existing] = { ...prev, attachment: { ...prev.attachment, ...attachment } } } }) }) return sources } type IdentityDetailsFormV2Props = { initialRecord?: IdentityRecord selectedFolder?: string } type IdentityDetailsFormValues = { fullName: string email: string phoneNumber: string address: string zip: string city: string region: string country: string note: string customFields: CustomField[] folder?: string passportFullName: string passportNumber: string passportIssuingCountry: string passportDateOfIssue: string passportExpiryDate: string passportNationality: string passportDob: string passportGender: string passportPicture: Attachment[] idCardNumber: string idCardDateOfIssue: string idCardExpiryDate: string idCardIssuingCountry: string idCardPicture: Attachment[] drivingLicenseNumber: string drivingLicenseDateOfIssue: string drivingLicenseExpiryDate: string drivingLicenseIssuingCountry: string drivingLicensePicture: Attachment[] attachments: Attachment[] } export const IdentityDetailsFormV2 = ({ initialRecord, selectedFolder }: IdentityDetailsFormV2Props) => { const { t } = useTranslation() const styles = createStyles() const { setModal } = useModal() const { copyToClipboard } = useCopyToClipboard() const initialValues = useMemo( () => ({ fullName: initialRecord?.data?.fullName ?? '', email: initialRecord?.data?.email ?? '', phoneNumber: initialRecord?.data?.phoneNumber ?? '', address: initialRecord?.data?.address ?? '', zip: initialRecord?.data?.zip ?? '', city: initialRecord?.data?.city ?? '', region: initialRecord?.data?.region ?? '', country: initialRecord?.data?.country ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder, passportFullName: initialRecord?.data?.passportFullName ?? '', passportNumber: initialRecord?.data?.passportNumber ?? '', passportIssuingCountry: initialRecord?.data?.passportIssuingCountry ?? '', passportDateOfIssue: initialRecord?.data?.passportDateOfIssue ?? '', passportExpiryDate: initialRecord?.data?.passportExpiryDate ?? '', passportNationality: initialRecord?.data?.passportNationality ?? '', passportDob: initialRecord?.data?.passportDob ?? '', passportGender: initialRecord?.data?.passportGender ?? '', passportPicture: initialRecord?.data?.passportPicture ?? [], idCardNumber: initialRecord?.data?.idCardNumber ?? '', idCardDateOfIssue: initialRecord?.data?.idCardDateOfIssue ?? '', idCardExpiryDate: initialRecord?.data?.idCardExpiryDate ?? '', idCardIssuingCountry: initialRecord?.data?.idCardIssuingCountry ?? '', idCardPicture: initialRecord?.data?.idCardPicture ?? [], drivingLicenseNumber: initialRecord?.data?.drivingLicenseNumber ?? '', drivingLicenseDateOfIssue: initialRecord?.data?.drivingLicenseDateOfIssue ?? '', drivingLicenseExpiryDate: initialRecord?.data?.drivingLicenseExpiryDate ?? '', drivingLicenseIssuingCountry: initialRecord?.data?.drivingLicenseIssuingCountry ?? '', drivingLicensePicture: initialRecord?.data?.drivingLicensePicture ?? [], attachments: initialRecord?.attachments ?? [] }), [initialRecord, selectedFolder] ) const { register, setValues, values, setValue } = useForm({ initialValues }) useGetMultipleFiles({ fieldNames: [ ATTACHMENTS_FIELD_KEY, 'passportPicture', 'idCardPicture', 'drivingLicensePicture' ], updateValues: setValue, initialRecord }) useEffect(() => { setValues(initialValues) }, [initialValues, setValues]) const hasPersonalInformation = !!values.fullName?.length || !!values.email?.length || !!values.phoneNumber?.length const hasAddress = !!values.address?.length || !!values.zip?.length || !!values.city?.length || !!values.region?.length || !!values.country?.length const hasPassport = !!values.passportFullName?.length || !!values.passportNumber?.length || !!values.passportIssuingCountry?.length || !!values.passportDateOfIssue?.length || !!values.passportExpiryDate?.length || !!values.passportNationality?.length || !!values.passportDob?.length || !!values.passportGender?.length const hasIdCard = !!values.idCardNumber?.length || !!values.idCardDateOfIssue?.length || !!values.idCardExpiryDate?.length || !!values.idCardIssuingCountry?.length const hasDrivingLicense = !!values.drivingLicenseNumber?.length || !!values.drivingLicenseDateOfIssue?.length || !!values.drivingLicenseExpiryDate?.length || !!values.drivingLicenseIssuingCountry?.length const attachmentSources = useMemo( () => buildIdentityAttachmentSources({ attachments: values.attachments, passportPicture: values.passportPicture, idCardPicture: values.idCardPicture, drivingLicensePicture: values.drivingLicensePicture }), [ values.attachments, values.passportPicture, values.idCardPicture, values.drivingLicensePicture ] ) const hasAttachments = attachmentSources.length > 0 const hasNote = !!values.note?.length const hasCustomFields = !!values.customFields?.length const handleAttachmentPress = (attachment: Attachment) => { if (!attachment?.buffer || !attachment?.name) return const blob = new Blob([attachment.buffer as BlobPart]) const url = URL.createObjectURL(blob) const isImage = IMAGE_EXTENSIONS.includes(getExtension(attachment.name)) if (isImage) { setModal( html`<${DisplayPictureModalContentV2} url=${url} name=${attachment.name} />` ) return } const a = document.createElement('a') a.href = url a.download = attachment.name document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } return (
{hasPersonalInformation && (
{t('Personal Information')} {!!values.fullName?.length && ( )} {!!values.email?.length && ( )} {!!values.phoneNumber?.length && ( )}
)} {hasAddress && (
{t('Address')} {!!values.address?.length && ( )} {!!values.zip?.length && ( )} {!!values.city?.length && ( )} {!!values.region?.length && ( )} {!!values.country?.length && ( )}
)} {hasPassport && (
{t('Passport')} {!!values.passportFullName?.length && ( )} {!!values.passportNumber?.length && ( )} {!!values.passportIssuingCountry?.length && ( )} {!!values.passportDateOfIssue?.length && ( )} {!!values.passportExpiryDate?.length && ( )} {!!values.passportNationality?.length && ( )} {!!values.passportDob?.length && ( )} {!!values.passportGender?.length && ( )}
)} {hasIdCard && (
{t('Identity Card')} {!!values.idCardNumber?.length && ( )} {!!values.idCardDateOfIssue?.length && ( )} {!!values.idCardExpiryDate?.length && ( )} {!!values.idCardIssuingCountry?.length && ( )}
)} {hasDrivingLicense && (
{t('Driving License')} {!!values.drivingLicenseNumber?.length && ( )} {!!values.drivingLicenseDateOfIssue?.length && ( )} {!!values.drivingLicenseExpiryDate?.length && ( )} {!!values.drivingLicenseIssuingCountry?.length && ( )}
)} {hasAttachments && (
{t('Attachments')} {attachmentSources.map(({ attachment }, index) => ( handleAttachmentPress(attachment)} /> ))}
)} {(hasNote || hasCustomFields) && (
{t('Additional')} {hasNote && ( )} {hasCustomFields && ( {(values.customFields as CustomField[]).map((field, index) => ( )}
)}
) } ================================================ FILE: src/containers/RecordDetails/IdentityDetailsForm/index.js ================================================ import React, { useEffect } from 'react' import { useLingui } from '@lingui/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { DATE_FORMAT } from '@tetherto/pearpass-lib-constants' import { html } from 'htm/react' import { CopyButton } from '../../../components/CopyButton' import { FormGroup } from '../../../components/FormGroup' import { FormWrapper } from '../../../components/FormWrapper' import { InputFieldNote } from '../../../components/InputFieldNote' import { ATTACHMENTS_FIELD_KEY } from '../../../constants/formFields' import { useGetMultipleFiles } from '../../../hooks/useGetMultipleFiles' import { EmailIcon, InputField, PhoneIcon, UserIcon } from '../../../lib-react-components' import { AttachmentField } from '../../AttachmentField' import { CustomFields } from '../../CustomFields' import { ImagesField } from '../../ImagesField' /** * @param {{ * initialRecord: { * data: { * title: string * fullName: string * email: string * phoneNumber: string * address: string * zip: string * city: string * region: string * country: string * note: string * customFields: { * note: string * type: string * }[] * attachments: { id: string, name: string}[] * } * } * selectedFolder?: string * }} props */ export const IdentityDetailsForm = ({ initialRecord, selectedFolder }) => { const { i18n } = useLingui() const initialValues = React.useMemo( () => ({ fullName: initialRecord?.data?.fullName ?? '', email: initialRecord?.data?.email ?? '', phoneNumber: initialRecord?.data?.phoneNumber ?? '', address: initialRecord?.data?.address ?? '', zip: initialRecord?.data?.zip ?? '', city: initialRecord?.data?.city ?? '', region: initialRecord?.data?.region ?? '', country: initialRecord?.data?.country ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields || [], folder: selectedFolder ?? initialRecord?.folder, passportFullName: initialRecord?.data?.passportFullName ?? '', passportNumber: initialRecord?.data?.passportNumber ?? '', passportIssuingCountry: initialRecord?.data?.passportIssuingCountry ?? '', passportDateOfIssue: initialRecord?.data?.passportDateOfIssue ?? '', passportExpiryDate: initialRecord?.data?.passportExpiryDate ?? '', passportNationality: initialRecord?.data?.passportNationality ?? '', passportDob: initialRecord?.data?.passportDob ?? '', passportGender: initialRecord?.data?.passportGender ?? '', passportPicture: initialRecord?.data?.passportPicture ?? [], idCardNumber: initialRecord?.data?.idCardNumber ?? '', idCardDateOfIssue: initialRecord?.data?.idCardDateOfIssue ?? '', idCardExpiryDate: initialRecord?.data?.idCardExpiryDate ?? '', idCardIssuingCountry: initialRecord?.data?.idCardIssuingCountry ?? '', idCardPicture: initialRecord?.data?.idCardPicture ?? [], drivingLicenseNumber: initialRecord?.data?.drivingLicenseNumber ?? '', drivingLicenseDateOfIssue: initialRecord?.data?.drivingLicenseDateOfIssue ?? '', drivingLicenseExpiryDate: initialRecord?.data?.drivingLicenseExpiryDate ?? '', drivingLicenseIssuingCountry: initialRecord?.data?.drivingLicenseIssuingCountry ?? '', drivingLicensePicture: initialRecord?.data?.drivingLicensePicture ?? [], attachments: initialRecord?.attachments ?? [] }), [initialRecord, selectedFolder] ) const { register, registerArray, setValues, values, setValue } = useForm({ initialValues }) const { value: list, registerItem } = registerArray('customFields') useGetMultipleFiles({ fieldNames: [ ATTACHMENTS_FIELD_KEY, 'passportPicture', 'idCardPicture', 'drivingLicensePicture' ], updateValues: setValue, initialRecord }) useEffect(() => { setValues(initialValues) }, [initialValues, setValues]) const hasFullName = !!values?.fullName?.length const hasEmail = !!values?.email?.length const hasPhoneNumber = !!values?.phoneNumber?.length const hasAddress = !!values?.address?.length const hasZip = !!values?.zip?.length const hasCity = !!values?.city?.length const hasRegion = !!values?.region?.length const hasCountry = !!values?.country?.length const hasNote = !!values?.note?.length const hasCustomFields = !!list.length const hasPassportFullName = !!values?.passportFullName?.length const hasPassportNumber = !!values?.passportNumber?.length const hasPassportIssuingCountry = !!values?.passportIssuingCountry?.length const hasPassportDateOfIssue = !!values?.passportDateOfIssue?.length const hasPassportExpiryDate = !!values?.passportExpiryDate?.length const hasPassportNationality = !!values?.passportNationality?.length const hasPassportDob = !!values?.passportDob?.length const hasPassportGender = !!values?.passportGender?.length const hasPassportPicture = !!values?.passportPicture?.length const hasIdCardNumber = !!values?.idCardNumber?.length const hasIdCardDateOfIssue = !!values?.idCardDateOfIssue?.length const hasIdCardExpiryDate = !!values?.idCardExpiryDate?.length const hasIdCardIssuingCountry = !!values?.idCardIssuingCountry?.length const hasIdCardPicture = !!values?.idCardPicture?.length const hasDrivingLicenseNumber = !!values?.drivingLicenseNumber?.length const hasDrivingLicenseDateOfIssue = !!values?.drivingLicenseDateOfIssue?.length const hasDrivingLicenseExpiryDate = !!values?.drivingLicenseExpiryDate?.length const hasDrivingLicenseIssuingCountry = !!values?.drivingLicenseIssuingCountry?.length const hasDrivingLicensePicture = !!values?.drivingLicensePicture?.length const hasPassport = hasPassportFullName || hasPassportNumber || hasPassportIssuingCountry || hasPassportDateOfIssue || hasPassportExpiryDate || hasPassportNationality || hasPassportDob || hasPassportGender || hasPassportPicture const hasIdCard = hasIdCardNumber || hasIdCardDateOfIssue || hasIdCardExpiryDate || hasIdCardIssuingCountry || hasIdCardPicture const hasDrivingLicense = hasDrivingLicenseNumber || hasDrivingLicenseDateOfIssue || hasDrivingLicenseExpiryDate || hasDrivingLicenseIssuingCountry || hasDrivingLicensePicture return html` <${FormWrapper}> ${(hasFullName || hasEmail || hasPhoneNumber) && html` <${FormGroup} testId="identitydetails-section-personalinfo" title=${i18n._('Personal information')} isCollapse > ${!!values?.fullName?.length && html` <${InputField} testId="identitydetails-field-fullname" label=${i18n._('Full name')} placeholder=${i18n._('Full name')} variant="outline" icon=${UserIcon} isDisabled ...${register('fullName')} additionalItems=${html` <${CopyButton} value=${values.fullName} /> `} /> `} ${!!values?.email?.length && html` <${InputField} testId="identitydetails-field-email" label=${i18n._('Email')} placeholder=${i18n._('Insert email')} variant="outline" icon=${EmailIcon} isDisabled ...${register('email')} additionalItems=${html` <${CopyButton} value=${values.email} /> `} />`} ${!!values?.phoneNumber?.length && html` <${InputField} testId="identitydetails-field-phonenumber" label=${i18n._('Phone number ')} placeholder=${i18n._('Phone number ')} variant="outline" icon=${PhoneIcon} isDisabled ...${register('phoneNumber')} additionalItems=${html` <${CopyButton} value=${values.phoneNumber} /> `} /> `} `} ${(hasAddress || hasZip || hasCity || hasRegion || hasCountry) && html` <${FormGroup} testId="identitydetails-section-address" title=${i18n._('Detail of address')} isCollapse > ${!!values?.address?.length && html` <${InputField} testId="identitydetails-field-address" label=${i18n._('Address')} placeholder=${i18n._('Address')} variant="outline" isDisabled ...${register('address')} additionalItems=${html` <${CopyButton} value=${values.address} /> `} /> `} ${!!values?.zip?.length && html` <${InputField} testId="identitydetails-field-zip" label=${i18n._('ZIP')} placeholder=${i18n._('Insert zip')} variant="outline" isDisabled ...${register('zip')} additionalItems=${html` <${CopyButton} value=${values.zip} /> `} /> `} ${!!values?.city?.length && html` <${InputField} testId="identitydetails-field-city" label=${i18n._('City')} placeholder=${i18n._('City')} variant="outline" isDisabled ...${register('city')} additionalItems=${html` <${CopyButton} value=${values.city} /> `} /> `} ${!!values?.region?.length && html` <${InputField} testId="identitydetails-field-region" label=${i18n._('Region')} placeholder=${i18n._('Region')} variant="outline" isDisabled ...${register('region')} additionalItems=${html` <${CopyButton} value=${values.region} /> `} /> `} ${!!values?.country?.length && html` <${InputField} testId="identitydetails-field-country" label=${i18n._('Country')} placeholder=${i18n._('Country')} variant="outline" isDisabled ...${register('country')} additionalItems=${html` <${CopyButton} value=${values.country} /> `} /> `} `} ${hasPassport && html` <${FormGroup} testId="identitydetails-section-passport" title=${i18n._('Passport')} isCollapse >
${hasPassportFullName && html` <${InputField} testId="identitydetails-field-passportfullname" label=${i18n._('Full name')} placeholder=${i18n._('John Smith')} variant="outline" isDisabled ...${register('passportFullName')} additionalItems=${html` <${CopyButton} value=${values.passportFullName} /> `} /> `} ${hasPassportNumber && html` <${InputField} testId="identitydetails-field-passportnumber" label=${i18n._('Passport number')} placeholder=${i18n._('Insert numbers')} variant="outline" isDisabled ...${register('passportNumber')} additionalItems=${html` <${CopyButton} value=${values.passportNumber} /> `} /> `} ${hasPassportIssuingCountry && html` <${InputField} testId="identitydetails-field-passportissuingcountry" label=${i18n._('Issuing country')} placeholder=${i18n._('Insert country')} variant="outline" isDisabled ...${register('passportIssuingCountry')} additionalItems=${html` <${CopyButton} value=${values.passportIssuingCountry} /> `} /> `} ${hasPassportDateOfIssue && html` <${InputField} testId="identitydetails-field-passportdateofissue" label=${i18n._('Date of issue')} placeholder=${DATE_FORMAT} variant="outline" isDisabled ...${register('passportDateOfIssue')} additionalItems=${html` <${CopyButton} value=${values.passportDateOfIssue} /> `} /> `} ${hasPassportExpiryDate && html` <${InputField} testId="identitydetails-field-passportexpirydate" label=${i18n._('Expiry date')} placeholder=${DATE_FORMAT} variant="outline" isDisabled ...${register('passportExpiryDate')} additionalItems=${html` <${CopyButton} value=${values.passportExpiryDate} /> `} /> `} ${hasPassportNationality && html` <${InputField} testId="identitydetails-field-passportnationality" label=${i18n._('Nationality')} placeholder=${i18n._('Insert your nationality')} variant="outline" isDisabled ...${register('passportNationality')} additionalItems=${html` <${CopyButton} value=${values.passportNationality} /> `} /> `} ${hasPassportDob && html` <${InputField} testId="identitydetails-field-passportdob" label=${i18n._('Date of birth')} placeholder=${DATE_FORMAT} variant="outline" isDisabled ...${register('passportDob')} additionalItems=${html` <${CopyButton} value=${values.passportDob} /> `} /> `} ${hasPassportGender && html` <${InputField} testId="identitydetails-field-passportgender" label=${i18n._('Gender')} placeholder=${i18n._('M/F')} variant="outline" isDisabled ...${register('passportGender')} additionalItems=${html` <${CopyButton} value=${values.passportGender} /> `} /> `} ${hasPassportPicture && html` <${ImagesField} testId="identitydetails-imagesfield-passport" title=${i18n._('Passport Images')} pictures=${values.passportPicture} />`} `} ${hasIdCard && html` <${FormGroup} testId="identitydetails-section-idcard" title=${i18n._('Identity Card')} isCollapse >
${hasIdCardNumber && html` <${InputField} testId="identitydetails-field-idcardnumber" label=${i18n._('ID card number')} placeholder="123456789" variant="outline" isDisabled ...${register('idCardNumber')} additionalItems=${html` <${CopyButton} value=${values.idCardNumber} /> `} /> `} ${hasIdCardDateOfIssue && html` <${InputField} testId="identitydetails-field-idcarddateofissue" label=${i18n._('Creation date')} placeholder=${DATE_FORMAT} variant="outline" isDisabled ...${register('idCardDateOfIssue')} additionalItems=${html` <${CopyButton} value=${values.idCardDateOfIssue} /> `} /> `} ${hasIdCardExpiryDate && html` <${InputField} testId="identitydetails-field-idcardexpirydate" label=${i18n._('Expiry date')} placeholder=${DATE_FORMAT} variant="outline" isDisabled ...${register('idCardExpiryDate')} additionalItems=${html` <${CopyButton} value=${values.idCardExpiryDate} /> `} /> `} ${hasIdCardIssuingCountry && html` <${InputField} testId="identitydetails-field-idcardissuingcountry" label=${i18n._('Issuing country')} placeholder=${i18n._('Insert country')} variant="outline" isDisabled ...${register('idCardIssuingCountry')} additionalItems=${html` <${CopyButton} value=${values.idCardIssuingCountry} /> `} /> `} ${hasIdCardPicture && html` <${ImagesField} testId="identitydetails-imagesfield-idcard" title=${i18n._('Identity Card Images')} pictures=${values.idCardPicture} />`} `} ${hasDrivingLicense && html` <${FormGroup} testId="identitydetails-section-drivinglicense" title=${i18n._('Driving license')} isCollapse >
${hasDrivingLicenseNumber && html` <${InputField} testId="identitydetails-field-drivinglicensenumber" label=${i18n._('Driving license number')} placeholder="123456789" variant="outline" isDisabled ...${register('drivingLicenseNumber')} additionalItems=${html` <${CopyButton} value=${values.drivingLicenseNumber} /> `} /> `} ${hasDrivingLicenseDateOfIssue && html` <${InputField} testId="identitydetails-field-drivinglicensedateofissue" label=${i18n._('Creation date')} placeholder=${DATE_FORMAT} variant="outline" isDisabled ...${register('drivingLicenseDateOfIssue')} additionalItems=${html` <${CopyButton} value=${values.drivingLicenseDateOfIssue} /> `} /> `} ${hasDrivingLicenseExpiryDate && html` <${InputField} testId="identitydetails-field-drivinglicenseexpirydate" label=${i18n._('Expiry date')} placeholder=${DATE_FORMAT} variant="outline" isDisabled ...${register('drivingLicenseExpiryDate')} additionalItems=${html` <${CopyButton} value=${values.drivingLicenseExpiryDate} /> `} /> `} ${hasDrivingLicenseIssuingCountry && html` <${InputField} testId="identitydetails-field-drivinglicenseissuingcountry" label=${i18n._('Issuing country')} placeholder=${i18n._('Insert country')} variant="outline" isDisabled ...${register('drivingLicenseIssuingCountry')} additionalItems=${html` <${CopyButton} value=${values.drivingLicenseIssuingCountry} /> `} /> `} ${hasDrivingLicensePicture && html` <${ImagesField} testId="identitydetails-imagesfield-drivinglicense" title=${i18n._('Driving License Images')} pictures=${values.drivingLicensePicture} />`} `} ${values?.attachments?.length > 0 && html` <${FormGroup}> ${values.attachments.map( (attachment) => html` <${AttachmentField} testId="identitydetails-attachment" label=${i18n._('File')} attachment=${attachment} /> ` )} `} ${hasNote && html` <${FormGroup}> <${InputFieldNote} testId="identitydetails-field-note" isDisabled ...${register('note')} additionalItems=${html` <${CopyButton} value=${values.note} /> `} /> `} ${hasCustomFields && html` <${FormGroup}> <${CustomFields} areInputsDisabled=${true} customFields=${list} register=${registerItem} /> `} ` } ================================================ FILE: src/containers/RecordDetails/IdentityDetailsForm/utils.ts ================================================ type FieldRegistration = { name: string value: string onChange?: unknown error?: string } export const toReadOnlyFieldProps = (field: FieldRegistration) => ({ name: field.name, value: field.value }) ================================================ FILE: src/containers/RecordDetails/LoginRecordDetailsForm/LoginRecordDetailsFormV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ container: { display: 'flex' as const, flexDirection: 'column' as const, justifyContent: 'space-between' as const, gap: `${rawTokens.spacing16}px`, width: '100%' }, topContent: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px` } }) ================================================ FILE: src/containers/RecordDetails/LoginRecordDetailsForm/LoginRecordDetailsFormV2.tsx ================================================ import { useEffect, useMemo } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' // @ts-ignore - declaration file is missing import { isBefore, subtractDateUnits } from '@tetherto/pear-apps-utils-date' import { AlertMessage, AttachmentField, Button, InputField, MultiSlotInput, PasswordField, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { OpenInNew } from '@tetherto/pearpass-lib-ui-kit/icons' import { html } from 'htm/react' import { OtpCodeFieldV2 } from '../../../components/OtpCodeFieldV2' import { ATTACHMENTS_FIELD_KEY } from '../../../constants/formFields' import { useModal } from '../../../context/ModalContext' import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard.electron' import { useGetMultipleFiles } from '../../../hooks/useGetMultipleFiles' import { addHttps } from '../../../utils/addHttps' import { formatPasskeyDate } from '../../../utils/formatPasskeyDate' import { isPasswordChangeReminderDisabled } from '../../../utils/isPasswordChangeReminderDisabled' import { useTranslation } from '../../../hooks/useTranslation' import { DisplayPictureModalContentV2 } from '../../Modal/DisplayPictureModalContentV2/DisplayPictureModalContentV2' import { createStyles } from './LoginRecordDetailsFormV2.styles' import { toReadOnlyFieldProps } from './utils' type Attachment = { id?: string tempId?: string name: string buffer?: ArrayBuffer | Uint8Array } type CustomField = { type: string name: string note?: string } type LoginRecord = { id: string folder?: string attachments?: Attachment[] otpPublic?: unknown data: { title?: string username?: string password?: string note?: string websites?: string[] customFields?: CustomField[] credential?: { id: string } passkeyCreatedAt?: number | string | Date | null passwordUpdatedAt?: number | string | Date } } type LoginRecordDetailsFormV2Props = { initialRecord?: LoginRecord selectedFolder?: string } type LoginRecordDetailsFormValues = { username: string password: string note: string websites: string[] customFields: CustomField[] folder?: string attachments: Attachment[] credential: string passkeyCreatedAt: number | string | Date | null } const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'] const getExtension = (filename?: string) => filename?.split('.').pop()?.toLowerCase() ?? '' export const LoginRecordDetailsFormV2 = ({ initialRecord, selectedFolder }: LoginRecordDetailsFormV2Props) => { const { t } = useTranslation() const { theme } = useTheme() const styles = createStyles() const { setModal } = useModal() const { copyToClipboard } = useCopyToClipboard() const initialValues = useMemo( () => ({ username: initialRecord?.data?.username ?? '', password: initialRecord?.data?.password ?? '', note: initialRecord?.data?.note ?? '', websites: initialRecord?.data?.websites ?? [], customFields: initialRecord?.data?.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [], credential: initialRecord?.data?.credential?.id ?? '', passkeyCreatedAt: initialRecord?.data?.passkeyCreatedAt ?? null }), [initialRecord, selectedFolder] ) const { register, setValues, values, setValue } = useForm({ initialValues }) useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) useEffect(() => { setValues(initialValues) }, [initialValues, setValues]) const hasUsername = !!values.username?.length const hasPassword = !!values.password?.length const hasPasskey = !!values.credential const hasWebsites = !!values.websites?.length const hasNote = !!values.note?.length const hasCustomFields = !!values.customFields?.length const hasAttachments = !!values.attachments?.length const isPasswordSixMonthsOld = () => { const passwordUpdatedAt = initialRecord?.data?.passwordUpdatedAt return ( !!passwordUpdatedAt && isBefore(passwordUpdatedAt, subtractDateUnits(6, 'month')) ) } const shouldShowSecurityWarning = !isPasswordChangeReminderDisabled() && isPasswordSixMonthsOld() const handleAttachmentPress = (attachment: Attachment) => { if (!attachment?.buffer || !attachment?.name) return const blob = new Blob([attachment.buffer as BlobPart]) const url = URL.createObjectURL(blob) const isImage = IMAGE_EXTENSIONS.includes(getExtension(attachment.name)) if (isImage) { setModal( html`<${DisplayPictureModalContentV2} url=${url} name=${attachment.name} />` ) return } const a = document.createElement('a') a.href = url a.download = attachment.name document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } return (
{(hasUsername || hasPassword || !!initialRecord?.otpPublic) && ( {hasUsername && ( )} {hasPassword && ( )} {!!initialRecord?.otpPublic && !!initialRecord?.id && ( [0]['otpPublic'] } isGrouped /> )} )} {hasWebsites && values.websites.map((website: string, index: number) => ( } onClick={() => window.open( addHttps(website) as unknown as string, '_blank', 'noopener,noreferrer' ) } /> ) : undefined } /> ))} {hasPasskey && ( )} {hasAttachments && ( {values.attachments.map((attachment: Attachment, index: number) => ( handleAttachmentPress(attachment)} /> ))} )} {hasNote && ( )} {hasCustomFields && ( {values.customFields.map((field: CustomField, index: number) => ( )}
{shouldShowSecurityWarning && ( )}
) } ================================================ FILE: src/containers/RecordDetails/LoginRecordDetailsForm/index.js ================================================ import React, { useEffect } from 'react' import { useLingui } from '@lingui/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { isBefore, subtractDateUnits } from '@tetherto/pear-apps-utils-date' import { html } from 'htm/react' import { AlertBox } from '../../../components/AlertBox' import { CopyButton } from '../../../components/CopyButton' import { FormGroup } from '../../../components/FormGroup' import { FormWrapper } from '../../../components/FormWrapper' import { InputFieldNote } from '../../../components/InputFieldNote' import { OtpCodeField } from '../../../components/OtpCodeField' import { WebsiteButton } from '../../../components/WebsiteButton' import { ATTACHMENTS_FIELD_KEY } from '../../../constants/formFields' import { useGetMultipleFiles } from '../../../hooks/useGetMultipleFiles' import { CompoundField, InputField, KeyIcon, PasswordField, UserIcon, WorldIcon } from '../../../lib-react-components' import { formatPasskeyDate } from '../../../utils/formatPasskeyDate' import { isPasswordChangeReminderDisabled } from '../../../utils/isPasswordChangeReminderDisabled' import { AttachmentField } from '../../AttachmentField' import { CustomFields } from '../../CustomFields' /** * @param {{ * initialRecord: { * data: { * title: string * username: string * password: string * note: string * websites: string[] * customFields: { * type: string * name: string * }[] * attachments: { id: string, name: string}[] * } * } * selectedFolder?: string * }} props */ export const LoginRecordDetailsForm = ({ initialRecord, selectedFolder }) => { const { i18n } = useLingui() const initialValues = React.useMemo( () => ({ username: initialRecord?.data?.username ?? '', password: initialRecord?.data?.password ?? '', note: initialRecord?.data?.note ?? '', websites: initialRecord?.data?.websites?.length ? initialRecord?.data?.websites.map((website) => ({ website })) : [{ name: 'website' }], customFields: initialRecord?.data.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [], credential: initialRecord?.data?.credential?.id ?? '', passkeyCreatedAt: initialRecord?.data?.passkeyCreatedAt }), [initialRecord, selectedFolder] ) const { register, registerArray, setValues, values, setValue } = useForm({ initialValues }) const { value: websitesList, registerItem } = registerArray('websites') const { value: customFieldsList, registerItem: registerCustomFieldItem } = registerArray('customFields') useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) const isPasswordSixMonthsOld = () => { const { passwordUpdatedAt } = initialRecord?.data || {} return ( !!passwordUpdatedAt && isBefore(passwordUpdatedAt, subtractDateUnits(6, 'month')) ) } useEffect(() => { setValues(initialValues) }, [initialValues, setValues]) return html` ${!isPasswordChangeReminderDisabled() && isPasswordSixMonthsOld() && html` <${AlertBox} message=${html` ${i18n._("It's been 6 months since you last updated this password")}
${i18n._('Consider changing it to keep your account secure.')} `} /> `} <${FormWrapper}> <${FormGroup}> ${!!values?.username?.length && html` <${InputField} label=${i18n._('Email or username')} placeholder=${i18n._('Email or username')} variant="outline" icon=${UserIcon} isDisabled ...${register('username')} additionalItems=${html` <${CopyButton} value=${values.username} /> `} /> `} ${!!values?.password?.length && html` <${PasswordField} label=${i18n._('Password')} placeholder=${i18n._('Password')} variant="outline" icon=${KeyIcon} isDisabled ...${register('password')} additionalItems=${html` <${CopyButton} value=${values.password} /> `} /> `} ${!!values?.credential && html` <${FormGroup}> <${InputField} label=${i18n._('Passkey')} value=${formatPasskeyDate(values.passkeyCreatedAt) || i18n._('Passkey Stored')} variant="outline" icon=${KeyIcon} isDisabled /> `} ${!!initialRecord?.otpPublic && html` <${FormGroup}> <${OtpCodeField} key=${initialRecord.id} recordId=${initialRecord.id} otpPublic=${initialRecord.otpPublic} /> `} ${!!values?.websites?.length && html` <${CompoundField}> ${websitesList.map( (website, index) => html` <${React.Fragment} key=${website.id}> <${InputField} label=${i18n._('Website')} placeholder=${i18n._('https://')} icon=${WorldIcon} ...${registerItem('website', index)} isDisabled additionalItems=${html` <${WebsiteButton} url=${registerItem('website', index).value} /> <${CopyButton} value=${registerItem('website', index).value} /> `} /> ` )} `} ${values?.attachments?.length > 0 && html` <${FormGroup}> ${values.attachments.map( (attachment) => html` <${AttachmentField} label=${i18n._('File')} attachment=${attachment} /> ` )} `} <${FormGroup}> ${!!values?.note?.length && html` <${InputFieldNote} ...${register('note')} isDisabled additionalItems=${html` <${CopyButton} value=${values.note} /> `} /> `} <${CustomFields} customFields=${customFieldsList} register=${registerCustomFieldItem} areInputsDisabled=${true} /> ` } ================================================ FILE: src/containers/RecordDetails/LoginRecordDetailsForm/utils.ts ================================================ type FieldRegistration = { name: string value: string onChange?: unknown error?: string } export const toReadOnlyFieldProps = (field: FieldRegistration) => ({ name: field.name, value: field.value }) ================================================ FILE: src/containers/RecordDetails/NoteDetailsForm/NoteDetailsFormV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ container: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, width: '100%' }, section: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing12}px` } }) ================================================ FILE: src/containers/RecordDetails/NoteDetailsForm/NoteDetailsFormV2.tsx ================================================ import { useEffect, useMemo } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { AttachmentField, InputField, MultiSlotInput, PasswordField, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { html } from 'htm/react' import { ATTACHMENTS_FIELD_KEY } from '../../../constants/formFields' import { useModal } from '../../../context/ModalContext' import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard.electron' import { useGetMultipleFiles } from '../../../hooks/useGetMultipleFiles' import { useTranslation } from '../../../hooks/useTranslation' import { DisplayPictureModalContentV2 } from '../../Modal/DisplayPictureModalContentV2/DisplayPictureModalContentV2' import { createStyles } from './NoteDetailsFormV2.styles' import { toReadOnlyFieldProps } from './utils' type Attachment = { id?: string tempId?: string name: string buffer?: ArrayBuffer | Uint8Array } type CustomField = { type: string name?: string note?: string } type NoteRecord = { id: string folder?: string attachments?: Attachment[] data: { title?: string note?: string customFields?: CustomField[] } } type NoteDetailsFormV2Props = { initialRecord?: NoteRecord selectedFolder?: string } type NoteDetailsFormValues = { note: string customFields: CustomField[] folder?: string attachments: Attachment[] } const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'] const getExtension = (filename?: string) => filename?.split('.').pop()?.toLowerCase() ?? '' export const NoteDetailsFormV2 = ({ initialRecord, selectedFolder }: NoteDetailsFormV2Props) => { const { t } = useTranslation() const { theme } = useTheme() const styles = createStyles() const { setModal } = useModal() const { copyToClipboard } = useCopyToClipboard() const initialValues = useMemo( () => ({ note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [] }), [initialRecord, selectedFolder] ) const { register, setValues, values, setValue } = useForm({ initialValues }) useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) useEffect(() => { setValues(initialValues) }, [initialValues, setValues]) const hasNote = !!values.note?.length const hasCustomFields = !!values.customFields?.length const hasAttachments = !!values.attachments?.length const handleAttachmentPress = (attachment: Attachment) => { if (!attachment?.buffer || !attachment?.name) return const blob = new Blob([attachment.buffer as BlobPart]) const url = URL.createObjectURL(blob) const isImage = IMAGE_EXTENSIONS.includes(getExtension(attachment.name)) if (isImage) { setModal( html`<${DisplayPictureModalContentV2} url=${url} name=${attachment.name} />` ) return } const a = document.createElement('a') a.href = url a.download = attachment.name document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } return (
{hasNote && (
{t('Details')}
)} {(hasAttachments || hasCustomFields) && (
{t('Additional')} {hasAttachments && ( {(values.attachments as Attachment[]).map((attachment, index) => ( handleAttachmentPress(attachment)} /> ))} )} {hasCustomFields && ( {(values.customFields as CustomField[]).map((field, index) => ( )}
)}
) } ================================================ FILE: src/containers/RecordDetails/NoteDetailsForm/index.js ================================================ import React, { useEffect } from 'react' import { useLingui } from '@lingui/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { html } from 'htm/react' import { CopyButton } from '../../../components/CopyButton' import { FormGroup } from '../../../components/FormGroup' import { FormWrapper } from '../../../components/FormWrapper' import { ATTACHMENTS_FIELD_KEY } from '../../../constants/formFields' import { useGetMultipleFiles } from '../../../hooks/useGetMultipleFiles' import { TextArea } from '../../../lib-react-components' import { AttachmentField } from '../../AttachmentField' import { CustomFields } from '../../CustomFields' /** * @param {{ * initialRecord: { * data: { * title: string * note: string * customFields: { * type: string * name: string * }[] * attachments: { id: string, name: string}[] * } * } * selectedFolder?: string * }} props */ export const NoteDetailsForm = ({ initialRecord, selectedFolder }) => { const { i18n } = useLingui() const initialValues = React.useMemo( () => ({ note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [] }), [initialRecord, selectedFolder] ) const { register, registerArray, setValues, values, setValue } = useForm({ initialValues }) const { value: list, registerItem } = registerArray('customFields') useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) useEffect(() => { setValues(initialValues) }, [initialValues, setValues]) return html` <${FormWrapper} data-id="note-details"> <${FormGroup}> ${!!values?.note?.length && html` <${TextArea} ...${register('note')} placeholder=${i18n._('Write a comment...')} isDisabled additionalItems=${html`<${CopyButton} value=${values.note} />`} /> `} ${values?.attachments?.length > 0 && html` <${FormGroup}> ${values.attachments.map( (attachment) => html` <${AttachmentField} key=${attachment.id} label=${i18n._('File')} attachment=${attachment} /> ` )} `} <${CustomFields} areInputsDisabled=${true} customFields=${list} register=${registerItem} /> ` } ================================================ FILE: src/containers/RecordDetails/NoteDetailsForm/utils.ts ================================================ type FieldRegistration = { name: string value: string onChange?: unknown error?: string } export const toReadOnlyFieldProps = (field: FieldRegistration) => ({ name: field.name, value: field.value }) ================================================ FILE: src/containers/RecordDetails/PassPhraseDetailsForm/PassPhraseDetailsFormV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ container: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, width: '100%' } }) ================================================ FILE: src/containers/RecordDetails/PassPhraseDetailsForm/PassPhraseDetailsFormV2.tsx ================================================ import { useEffect, useMemo } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { InputField, MultiSlotInput, PasswordField } from '@tetherto/pearpass-lib-ui-kit' import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard.electron' import { useTranslation } from '../../../hooks/useTranslation' import { PassPhraseV2 } from '../../PassPhrase/PassPhraseV2' import { createStyles } from './PassPhraseDetailsFormV2.styles' import { toReadOnlyFieldProps } from './utils' type CustomField = { type: string name?: string note?: string } type PassPhraseRecord = { id: string folder?: string data: { title?: string passPhrase?: string note?: string customFields?: CustomField[] } } type PassPhraseDetailsFormV2Props = { initialRecord?: PassPhraseRecord selectedFolder?: string } type PassPhraseDetailsFormValues = { title: string passPhrase: string note: string customFields: CustomField[] folder?: string } export const PassPhraseDetailsFormV2 = ({ initialRecord, selectedFolder }: PassPhraseDetailsFormV2Props) => { const { t } = useTranslation() const styles = createStyles() const { copyToClipboard } = useCopyToClipboard() const initialValues = useMemo( () => ({ title: initialRecord?.data?.title ?? '', passPhrase: initialRecord?.data?.passPhrase ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder }), [initialRecord, selectedFolder] ) const { register, setValues, values } = useForm({ initialValues }) useEffect(() => { setValues(initialValues) }, [initialValues, setValues]) const hasPassPhrase = !!values.passPhrase?.length const hasNote = !!values.note?.length const hasCustomFields = !!values.customFields?.length return (
{hasPassPhrase && } {hasNote && ( )} {hasCustomFields && ( {(values.customFields as CustomField[]).map((field, index) => ( )}
) } ================================================ FILE: src/containers/RecordDetails/PassPhraseDetailsForm/index.js ================================================ import React, { useEffect } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { html } from 'htm/react' import { CopyButton } from '../../../components/CopyButton' import { FormGroup } from '../../../components/FormGroup' import { FormWrapper } from '../../../components/FormWrapper' import { InputFieldNote } from '../../../components/InputFieldNote' import { CustomFields } from '../../CustomFields' import { PassPhrase } from '../../PassPhrase' /** * @param {{ * initialRecord: { * data: { * title: string * passPhrase: string * note: string * customFields: { * type: string * name: string * }[] * } * } * selectedFolder?: string * }} props */ export const PassPhraseDetailsForm = ({ initialRecord, selectedFolder }) => { const initialValues = React.useMemo( () => ({ title: initialRecord?.data?.title ?? '', passPhrase: initialRecord?.data?.passPhrase ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder }), [initialRecord, selectedFolder] ) const { register, registerArray, setValues, values } = useForm({ initialValues }) const { value: list, registerItem } = registerArray('customFields') useEffect(() => { setValues(initialValues) }, [initialValues, setValues]) return html` <${FormWrapper} data-testid="recoveryphrase-details"> <${FormGroup}> ${!!values?.passPhrase?.length && html`<${PassPhrase} ...${register('passPhrase')} /> `} <${FormGroup}> ${!!values?.note?.length && html` <${InputFieldNote} ...${register('note')} additionalItems=${html` <${CopyButton} value=${values.note} /> `} isDisabled /> `} <${CustomFields} areInputsDisabled=${true} customFields=${list} register=${registerItem} /> ` } ================================================ FILE: src/containers/RecordDetails/PassPhraseDetailsForm/utils.ts ================================================ type FieldRegistration = { name: string value: string onChange?: unknown error?: string } export const toReadOnlyFieldProps = (field: FieldRegistration) => ({ name: field.name, value: field.value }) ================================================ FILE: src/containers/RecordDetails/RecordDetailsContent/index.js ================================================ import { RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { isV2 } from '../../../utils/designVersion' import { CreditCardDetailsForm } from '../CreditCardDetailsForm' import { CreditCardDetailsFormV2 } from '../CreditCardDetailsForm/CreditCardDetailsFormV2' import { CustomDetailsForm } from '../CustomDetailsForm' import { CustomDetailsFormV2 } from '../CustomDetailsForm/CustomDetailsFormV2' import { IdentityDetailsForm } from '../IdentityDetailsForm' import { IdentityDetailsFormV2 } from '../IdentityDetailsForm/IdentityDetailsFormV2' import { LoginRecordDetailsForm } from '../LoginRecordDetailsForm' import { LoginRecordDetailsFormV2 } from '../LoginRecordDetailsForm/LoginRecordDetailsFormV2' import { NoteDetailsForm } from '../NoteDetailsForm' import { NoteDetailsFormV2 } from '../NoteDetailsForm/NoteDetailsFormV2' import { PassPhraseDetailsForm } from '../PassPhraseDetailsForm' import { PassPhraseDetailsFormV2 } from '../PassPhraseDetailsForm/PassPhraseDetailsFormV2' import { WifiDetailsForm } from '../WifiDetailsForm' import { WifiDetailsFormV2 } from '../WifiDetailsForm/WifiDetailsFormV2' /** * @param {{ * record: { * type: 'note' | 'creditCard' | 'custom' | 'identity' | 'login' * } * selectedFolder?: string * }} props */ export const RecordDetailsContent = ({ record, selectedFolder }) => { if (record?.type === RECORD_TYPES.CREDIT_CARD) { const CreditCardForm = isV2() ? CreditCardDetailsFormV2 : CreditCardDetailsForm return html`<${CreditCardForm} initialRecord=${record} selectedFolder=${selectedFolder} />` } if (record?.type === RECORD_TYPES.CUSTOM) { const CustomForm = isV2() ? CustomDetailsFormV2 : CustomDetailsForm return html`<${CustomForm} initialRecord=${record} selectedFolder=${selectedFolder} />` } if (record?.type === RECORD_TYPES.IDENTITY) { const IdentityForm = isV2() ? IdentityDetailsFormV2 : IdentityDetailsForm return html`<${IdentityForm} initialRecord=${record} selectedFolder=${selectedFolder} />` } if (record?.type === RECORD_TYPES.LOGIN) { const LoginForm = isV2() ? LoginRecordDetailsFormV2 : LoginRecordDetailsForm return html`<${LoginForm} initialRecord=${record} selectedFolder=${selectedFolder} />` } if (record?.type === RECORD_TYPES.NOTE) { const NoteForm = isV2() ? NoteDetailsFormV2 : NoteDetailsForm return html`<${NoteForm} initialRecord=${record} selectedFolder=${selectedFolder} />` } if (record?.type === RECORD_TYPES.WIFI_PASSWORD) { const WifiForm = isV2() ? WifiDetailsFormV2 : WifiDetailsForm return html`<${WifiForm} initialRecord=${record} selectedFolder=${selectedFolder} />` } if (record?.type === RECORD_TYPES.PASS_PHRASE) { const PassPhraseForm = isV2() ? PassPhraseDetailsFormV2 : PassPhraseDetailsForm return html`<${PassPhraseForm} initialRecord=${record} selectedFolder=${selectedFolder} />` } } ================================================ FILE: src/containers/RecordDetails/RecordDetailsV2.styles.ts ================================================ import { rawTokens, type ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { HEADER_MIN_HEIGHT } from '../../constants/layout' export const createStyles = (colors: ThemeColors) => ({ root: { display: 'flex' as const, flexDirection: 'column' as const, width: '100%', height: '100%', backgroundColor: colors.colorSurfacePrimary, boxSizing: 'border-box' as const, overflowY: 'auto' as const }, header: { display: 'flex' as const, alignItems: 'center' as const, height: `${HEADER_MIN_HEIGHT}px`, paddingInline: `${rawTokens.spacing12}px`, borderBottom: `1px solid ${colors.colorBorderPrimary}`, boxSizing: 'border-box' as const, flexShrink: 0 }, body: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, padding: `${rawTokens.spacing16}px` } }) ================================================ FILE: src/containers/RecordDetails/RecordDetailsV2.tsx ================================================ import React, { useEffect, useState } from 'react' import { Button, ContextMenu, ItemScreenHeader, NavbarListItem, rawTokens, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { KeyboardTab, ContentCopy, DriveFileMoveOutlined, EditOutlined, MoreVert, StarBorder, StarFilled, TrashOutlined } from '@tetherto/pearpass-lib-ui-kit/icons' // @ts-expect-error - declaration file is incomplete import { RECORD_TYPES, useRecordById } from '@tetherto/pearpass-lib-vault' import { RecordItemIcon } from '../../components/RecordItemIcon' import { useRouter } from '../../context/RouterContext' import { useRecordActionItems } from '../../hooks/useRecordActionItems' import { useTranslation } from '../../hooks/useTranslation' import { RecordDetailsContent } from './RecordDetailsContent' import { createStyles } from './RecordDetailsV2.styles' type RecordAction = { type: 'favorite' | 'edit' | 'move' | 'delete' | 'copy' | string name: string click?: () => void } const ACTION_ICON_BY_TYPE: Record< string, React.ComponentType<{ color?: string }> > = { copy: ContentCopy, move: DriveFileMoveOutlined, edit: EditOutlined, delete: TrashOutlined } const getActionIcon = ( action: RecordAction, isFavorite: boolean, textColor: string, destructiveColor: string ): React.ReactElement => { if (action.type === 'favorite') { const FavoriteIcon = isFavorite ? StarFilled : StarBorder return } const Icon = ACTION_ICON_BY_TYPE[action.type] ?? MoreVert const iconColor = action.type === 'delete' ? destructiveColor : textColor return } type RecordShape = { id: string type: string isFavorite?: boolean folder?: string data?: { title?: string websites?: string[] } } export const RecordDetailsV2 = () => { const { t } = useTranslation() const { theme } = useTheme() const styles = createStyles(theme.colors) const [isMenuOpen, setIsMenuOpen] = useState(false) const { currentPage, data: routerData, navigate } = useRouter() const { data: record } = useRecordById({ variables: { id: routerData.recordId } }) as { data?: RecordShape } const { actions } = useRecordActionItems({ excludeTypes: ['select', 'pin'], record, recordType: routerData?.recordType === RECORD_TYPES.OTP ? RECORD_TYPES.OTP : undefined, onClose: () => setIsMenuOpen(false) }) const handleCollapse = () => { navigate(currentPage, { ...routerData, recordId: '' }) } useEffect(() => { if (routerData.recordId && !record) handleCollapse() }, [record, routerData.recordId]) if (!record) return null const title = record?.data?.title ?? '' const avatar = ( ) const headerActions = (
} data-testid="details-button-actions-v2" /> } > {(actions as RecordAction[]).map((action, index, list) => ( { setIsMenuOpen(false) action.click?.() }} testID={`details-actions-item-${action.type}-v2`} /> ))}
) return (
[0]['record'] } />
) } ================================================ FILE: src/containers/RecordDetails/WifiDetailsForm/WifiDetailsFormV2.styles.ts ================================================ import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const createStyles = () => ({ container: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing8}px`, width: '100%' }, section: { display: 'flex' as const, flexDirection: 'column' as const, gap: `${rawTokens.spacing12}px` } }) ================================================ FILE: src/containers/RecordDetails/WifiDetailsForm/WifiDetailsFormV2.tsx ================================================ import { useEffect, useMemo } from 'react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { InputField, MultiSlotInput, PasswordField, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard.electron' import { useTranslation } from '../../../hooks/useTranslation' import { WifiPasswordQRCodeV2 } from '../../WifiPasswordQRCode/WifiPasswordQRCodeV2' import { createStyles } from './WifiDetailsFormV2.styles' import { toReadOnlyFieldProps } from './utils' type CustomField = { type: string name?: string note?: string } type WifiRecord = { id: string folder?: string data: { title?: string password?: string note?: string customFields?: CustomField[] } } type WifiDetailsFormV2Props = { initialRecord?: WifiRecord selectedFolder?: string } type WifiDetailsFormValues = { title: string password: string note: string customFields: CustomField[] folder?: string } export const WifiDetailsFormV2 = ({ initialRecord, selectedFolder }: WifiDetailsFormV2Props) => { const { t } = useTranslation() const { theme } = useTheme() const styles = createStyles() const { copyToClipboard } = useCopyToClipboard() const initialValues = useMemo( () => ({ title: initialRecord?.data?.title ?? '', password: initialRecord?.data?.password ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder }), [initialRecord, selectedFolder] ) const { register, setValues, values } = useForm({ initialValues }) useEffect(() => { setValues(initialValues) }, [initialValues, setValues]) const hasPassword = !!values.password?.length const hasNote = !!values.note?.length const hasCustomFields = !!values.customFields?.length return (
{hasPassword && (
{t('Credentials')}
)} {(hasNote || hasCustomFields) && (
{t('Additional')} {hasNote && ( )} {hasCustomFields && ( {(values.customFields as CustomField[]).map((field, index) => ( )}
)}
) } ================================================ FILE: src/containers/RecordDetails/WifiDetailsForm/index.js ================================================ import React, { useEffect } from 'react' import { useLingui } from '@lingui/react' import { useForm } from '@tetherto/pear-apps-lib-ui-react-hooks' import { html } from 'htm/react' import { CopyButton } from '../../../components/CopyButton' import { FormGroup } from '../../../components/FormGroup' import { FormWrapper } from '../../../components/FormWrapper' import { InputFieldNote } from '../../../components/InputFieldNote' import { ATTACHMENTS_FIELD_KEY } from '../../../constants/formFields' import { useGetMultipleFiles } from '../../../hooks/useGetMultipleFiles' import { PasswordField, PasswordIcon } from '../../../lib-react-components' import { AttachmentField } from '../../AttachmentField' import { CustomFields } from '../../CustomFields' import { WifiPasswordQRCode } from '../../WifiPasswordQRCode' /** * @param {{ * initialRecord: { * data: { * title: string * password: string * note: string * customFields: { * type: string * name: string * }[] * attachments: { id: string, name: string}[] * } * } * selectedFolder?: string * }} props */ export const WifiDetailsForm = ({ initialRecord, selectedFolder }) => { const { i18n } = useLingui() const initialValues = React.useMemo( () => ({ password: initialRecord?.data?.password ?? '', note: initialRecord?.data?.note ?? '', customFields: initialRecord?.data?.customFields ?? [], folder: selectedFolder ?? initialRecord?.folder, attachments: initialRecord?.attachments ?? [] }), [initialRecord, selectedFolder] ) const { register, registerArray, setValues, values, setValue } = useForm({ initialValues }) const { value: list, registerItem } = registerArray('customFields') useGetMultipleFiles({ fieldNames: [ATTACHMENTS_FIELD_KEY], updateValues: setValue, initialRecord }) useEffect(() => { setValues(initialValues) }, [initialValues, setValues]) return html` <${FormWrapper}> <${FormGroup}> ${!!values?.password?.length && html` <${PasswordField} testId="wifidetails-field-password" label=${i18n._('Password')} placeholder=${i18n._('Password')} belowInputContent=${html` <${WifiPasswordQRCode} ssid=${initialRecord?.data?.title} password=${values?.password} /> `} variant="outline" icon=${PasswordIcon} isDisabled ...${register('password')} additionalItems=${html` <${CopyButton} value=${values.password} /> `} /> `} <${FormGroup}> ${!!values?.note?.length && html` <${InputFieldNote} testId="wifidetails-field-note" ...${register('note')} isDisabled additionalItems=${html` <${CopyButton} value=${values.note} /> `} /> `} ${values?.attachments?.length > 0 && html` <${FormGroup}> ${values.attachments.map( (attachment) => html` <${AttachmentField} testId="wifidetails-attachment" key=${attachment.id} label=${i18n._('File')} attachment=${attachment} /> ` )} `} <${CustomFields} areInputsDisabled=${true} customFields=${list} register=${registerItem} /> ` } ================================================ FILE: src/containers/RecordDetails/WifiDetailsForm/utils.ts ================================================ type FieldRegistration = { name: string value: string onChange?: unknown error?: string } export const toReadOnlyFieldProps = (field: FieldRegistration) => ({ name: field.name, value: field.value }) ================================================ FILE: src/containers/RecordDetails/index.js ================================================ import React, { useEffect, useState } from 'react' import { useLingui } from '@lingui/react' import { generateAvatarInitials } from '@tetherto/pear-apps-utils-avatar-initials' import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' import { useRecordById, useRecords } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { RecordDetailsContent } from './RecordDetailsContent' import { RecordDetailsV2 } from './RecordDetailsV2' import { Fields, FavoriteButtonWrapper, FolderWrapper, Header, HeaderRight, RecordActions, Title, RecordInfo } from './styles' import { PopupMenu } from '../../components/PopupMenu' import { RecordActionsPopupContent } from '../../components/RecordActionsPopupContent' import { RecordAvatar } from '../../components/RecordAvatar' import { RECORD_COLOR_BY_TYPE } from '../../constants/recordColorByType' import { useRouter } from '../../context/RouterContext' import { useCreateOrEditRecord } from '../../hooks/useCreateOrEditRecord' import { useRecordActionItems } from '../../hooks/useRecordActionItems' import { BrushIcon, ButtonLittle, ButtonRoundIcon, CollapseIcon, FolderIcon, KebabMenuIcon, StarIcon } from '../../lib-react-components' import { isV2 } from '../../utils/designVersion' export const RecordDetails = () => { if (isV2()) { return html`<${RecordDetailsV2} />` } return html`<${RecordDetailsV1} />` } const RecordDetailsV1 = () => { const { i18n } = useLingui() const [isOpen, setIsOpen] = useState(false) const { currentPage, data: routerData, navigate } = useRouter() const { data: record } = useRecordById({ variables: { id: routerData.recordId } }) const { handleCreateOrEditRecord } = useCreateOrEditRecord() const { updateFavoriteState } = useRecords() const DATA_ID_PREFIX_BY_TYPE = { note: 'note', custom: 'custom' } const dataIdPrefix = DATA_ID_PREFIX_BY_TYPE[record?.type] const { actions: rawActions } = useRecordActionItems({ excludeTypes: ['select', 'pin'], record: record, onClose: () => { setIsOpen(false) } }) const actions = dataIdPrefix ? rawActions.map((action) => action.type === 'delete' ? { ...action, dataId: `${dataIdPrefix}-delete-button` } : action ) : rawActions const handleEdit = () => { handleCreateOrEditRecord({ recordType: record?.type, initialRecord: record }) } const handleCollapseRecordDetails = () => { navigate(currentPage, { ...routerData, recordId: '' }) } useEffect(() => { if (!record) { handleCollapseRecordDetails() } }, [record]) if (!record) { return null } const domain = record.type === 'login' ? record?.data?.websites?.[0] : null return html` <${React.Fragment}> <${Header} data-testid="details-header"> <${RecordInfo}> <${RecordAvatar} testId=${`details-avatar-${generateAvatarInitials(record?.data?.title)}`} websiteDomain=${domain} initials=${generateAvatarInitials(record?.data?.title)} isFavorite=${record?.isFavorite} color=${RECORD_COLOR_BY_TYPE[record?.type]} />
<${Title} data-testid=${`details-title-${record?.id}`}> ${record?.data?.title} ${!!record?.folder && html` <${FolderWrapper} data-testid=${`details-folder-${record?.folder ?? 'none'}`} > <${FolderIcon} size="24" color=${colors.grey200.mode1} /> ${record?.folder} `}
<${HeaderRight}> <${FavoriteButtonWrapper} data-testid="details-button-favorite" favorite=${record?.isFavorite} onClick=${() => updateFavoriteState([record?.id], !record?.isFavorite)} > <${StarIcon} size="24" fill=${record?.isFavorite} color=${colors.primary400.mode1} /> <${ButtonLittle} testId="details-button-edit" dataId=${dataIdPrefix ? `${dataIdPrefix}-edit-button` : undefined} startIcon=${BrushIcon} onClick=${handleEdit} > ${i18n._('Edit')} <${RecordActions}> <${PopupMenu} side="right" align="right" isOpen=${isOpen} setIsOpen=${setIsOpen} content=${html` <${RecordActionsPopupContent} menuItems=${actions} /> `} > <${ButtonRoundIcon} data-testid="details-button-actions" startIcon=${KebabMenuIcon} /> <${ButtonRoundIcon} testId="details-close-button" startIcon=${CollapseIcon} onClick=${handleCollapseRecordDetails} /> <${Fields}> <${RecordDetailsContent} record=${record} /> ` } ================================================ FILE: src/containers/RecordDetails/styles.js ================================================ import styled from 'styled-components' export const Header = styled.div` display: flex; justify-content: space-between; ` export const RecordInfo = styled.div` display: flex; gap: 10px; align-items: center; ` export const Title = styled.div` color: ${({ theme }) => theme.colors.white.dark}; font-family: 'Inter'; font-size: 16px; font-weight: 700; ` export const FolderWrapper = styled.div` display: flex; align-items: center; gap: 5px; color: ${({ theme }) => theme.colors.grey200.dark}; font-family: 'Inter'; font-size: 12px; font-weight: 400; margin-top: 2px; ` export const HeaderRight = styled.div` display: flex; align-items: center; gap: 10px; ` export const FavoriteButtonWrapper = styled.div.withConfig({ shouldForwardProp: (prop) => !['favorite'].includes(prop) })` display: flex; cursor: pointer; & path { fill: ${({ favorite, theme }) => favorite && theme.colors.primary400.mode1}; } ` export const Fields = styled.div` display: flex; flex-direction: column; gap: 15px; margin-top: 15px; padding-bottom: 24px; ` export const RecordActions = styled.div` display: flex; ` ================================================ FILE: src/containers/RecordListView/RecordListViewV2.styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' import { FADE_GRADIENT_HEIGHT } from '../../constants/layout' export const createStyles = (colors: ThemeColors) => ({ wrapper: { position: 'relative' as const, display: 'flex' as const, flexDirection: 'column' as const, flex: 1, minHeight: 0, width: '100%' }, scrollArea: { flex: 1, minHeight: 0, overflowY: 'auto' as const, overflowX: 'hidden' as const, paddingInline: `${rawTokens.spacing12}px`, paddingTop: `${rawTokens.spacing12}px`, paddingBottom: `${FADE_GRADIENT_HEIGHT}px`, display: 'flex' as const, flexDirection: 'column' as const }, section: { display: 'flex' as const, flexDirection: 'column' as const, gap: 2 }, sectionHeader: { display: 'flex' as const, flexDirection: 'row' as const, alignItems: 'center' as const, gap: `${rawTokens.spacing4}px`, paddingBlock: `${rawTokens.spacing8}px`, paddingInline: `${rawTokens.spacing4}px`, background: 'transparent', border: 'none', cursor: 'pointer' as const, userSelect: 'none' as const, color: colors.colorTextSecondary, width: '100%' }, staticSectionHeader: { display: 'flex' as const, flexDirection: 'row' as const, alignItems: 'center' as const, gap: `${rawTokens.spacing4}px`, paddingBlock: `${rawTokens.spacing8}px`, paddingInline: `${rawTokens.spacing4}px`, background: 'transparent', border: 'none', userSelect: 'none' as const, color: colors.colorTextPrimary, width: '100%' }, sectionHeaderChevron: { width: 16, height: 16, display: 'flex' as const, alignItems: 'center' as const, justifyContent: 'center' as const, transition: 'transform 150ms ease', flexShrink: 0 }, sectionHeaderChevronCollapsed: { transform: 'rotate(-90deg)' }, sectionList: { display: 'flex' as const, flexDirection: 'column' as const, gap: 1 }, recordRow: { cursor: 'pointer' as const }, divider: { width: '100%', height: 1, alignSelf: 'stretch' as const, backgroundColor: colors.colorBorderPrimary, marginBlock: `${rawTokens.spacing8}px`, flexShrink: 0 }, rowRightElement: { display: 'flex' as const, alignItems: 'center' as const, gap: `${rawTokens.spacing4}px` }, rowChevron: { display: 'flex' as const, alignItems: 'center' as const, justifyContent: 'center' as const, transform: 'rotate(-90deg)' }, fadeGradient: { position: 'absolute' as const, left: 0, right: 0, bottom: 0, height: FADE_GRADIENT_HEIGHT, pointerEvents: 'none' as const, background: `linear-gradient(180deg, ${colors.colorSurfacePrimary}00 0%, ${colors.colorSurfacePrimary} 100%)` } }) ================================================ FILE: src/containers/RecordListView/RecordListViewV2.test.tsx ================================================ import React from 'react' import '@testing-library/jest-dom' import { fireEvent, render, screen } from '@testing-library/react' import type { RecordSection } from '../../utils/groupRecordsByTimePeriod' const mockNavigate = jest.fn() let mockRouterData: Record = {} jest.mock('../../context/RouterContext', () => ({ useRouter: () => ({ currentPage: 'vault', data: mockRouterData, navigate: (...args: unknown[]) => mockNavigate(...args) }) })) jest.mock('../../hooks/useTranslation', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) jest.mock('../../components/RecordItemIcon', () => { const React = require('react') return { RecordItemIcon: ({ record }: { record: { id: string } }) => React.createElement('div', { 'data-testid': `record-icon-${record.id}` }) } }) jest.mock('@tetherto/pearpass-lib-ui-kit', () => { const React = require('react') return { useTheme: () => ({ theme: { colors: { colorTextSecondary: '#888', colorSurfaceDestructiveElevated: '#f00', colorBorderPrimary: '#222', colorSurfacePrimary: '#000' } } }), rawTokens: new Proxy({}, { get: () => 0 }), ListItem: ({ title, subtitle, onClick, testID, isSelected, selectionMode, rightElement }: { title: string subtitle?: string onClick?: () => void testID?: string isSelected?: boolean selectionMode?: string rightElement?: React.ReactNode }) => React.createElement( 'button', { type: 'button', 'data-testid': testID, 'data-selected': isSelected ? 'true' : 'false', 'data-selection-mode': selectionMode, onClick }, [ React.createElement('span', { key: 'title' }, title), subtitle ? React.createElement('span', { key: 'subtitle' }, subtitle) : null, rightElement ? React.createElement( 'div', { key: 'right', 'data-testid': `${testID}-right` }, rightElement ) : null ] ) } }) jest.mock('@tetherto/pearpass-lib-ui-kit/icons', () => { const React = require('react') const Stub = (name: string) => React.forwardRef((_: Record, _ref: unknown) => React.createElement('span', { 'data-icon': name }) ) return { ExpandMore: Stub('ExpandMore'), StarFilled: Stub('StarFilled'), ErrorFilled: Stub('ErrorFilled') } }) jest.mock('./RecordRowContextMenuV2', () => { return { RecordRowContextMenuV2: () => null } }) import { RecordListViewV2 } from './RecordListViewV2' const makeSection = ( key: string, titles: string[], extras?: Partial ): RecordSection => ({ key, title: key, data: titles.map((title, i) => ({ id: `${key}-${i}`, type: 'login', data: { title, username: `${title.toLowerCase()}@example.com` } })), ...extras }) describe('RecordListViewV2', () => { beforeEach(() => { mockRouterData = {} mockNavigate.mockReset() }) it('renders sections with titles translated and rows per record', () => { const sections: RecordSection[] = [ makeSection('favorites', ['Fav One'], { isFavorites: true }), makeSection('today', ['Today Item']) ] render() expect(screen.getByText('Favorites')).toBeInTheDocument() expect(screen.getByText('Today')).toBeInTheDocument() expect(screen.getByText('Fav One')).toBeInTheDocument() expect(screen.getByText('Today Item')).toBeInTheDocument() }) it('collapses a section when its header is clicked', () => { const sections: RecordSection[] = [makeSection('today', ['Row'])] render() expect(screen.getByText('Row')).toBeInTheDocument() fireEvent.click(screen.getByTestId('record-list-section-today')) expect(screen.queryByText('Row')).not.toBeInTheDocument() }) it('navigates to record details on row click when not in multi-select', () => { mockRouterData = { recordType: 'login' } const sections: RecordSection[] = [makeSection('today', ['Row'])] render() fireEvent.click(screen.getByTestId('record-list-item-today-0')) expect(mockNavigate).toHaveBeenCalledWith('vault', { recordId: 'today-0', recordType: 'login' }) }) it('toggles selection instead of navigating when multi-select is on', () => { const setSelectedRecords = jest.fn() const sections: RecordSection[] = [makeSection('today', ['Row A', 'Row B'])] render( ) fireEvent.click(screen.getByTestId('record-list-item-today-1')) expect(mockNavigate).not.toHaveBeenCalled() expect(setSelectedRecords).toHaveBeenCalledTimes(1) const updater = setSelectedRecords.mock.calls[0][0] as ( prev: string[] ) => string[] expect(updater(['today-0'])).toEqual(['today-0', 'today-1']) expect(updater(['today-0', 'today-1'])).toEqual(['today-0']) }) it('renders a divider between each pair of sections but not after the last', () => { const sections: RecordSection[] = [ makeSection('today', ['A']), makeSection('yesterday', ['B']), makeSection('older', ['C']) ] render() expect(screen.getAllByRole('separator')).toHaveLength(2) expect(screen.getByTestId('record-list-divider-today')).toBeInTheDocument() expect( screen.getByTestId('record-list-divider-yesterday') ).toBeInTheDocument() expect( screen.queryByTestId('record-list-divider-older') ).not.toBeInTheDocument() }) }) ================================================ FILE: src/containers/RecordListView/RecordListViewV2.tsx ================================================ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { ListItem, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { ErrorFilled, ExpandMore, StarFilled } from '@tetherto/pearpass-lib-ui-kit/icons' import { createStyles } from './RecordListViewV2.styles' import { RecordRowContextMenuV2 } from './RecordRowContextMenuV2' import { RecordItemIcon } from '../../components/RecordItemIcon' import { useRouter } from '../../context/RouterContext' import { useTranslation } from '../../hooks/useTranslation' import { getRecordSubtitle } from '../../utils/getRecordSubtitle' import type { RecordSection, VaultRecord } from '../../utils/groupRecordsByTimePeriod' const ROW_RECORD_ID_ATTR = 'data-record-id' type RecordListViewV2Props = { sections: RecordSection[] isMultiSelectOn?: boolean selectedRecords?: string[] setSelectedRecords?: ( updater: string[] | ((prev: string[]) => string[]) ) => void setIsMultiSelectOn?: (value: boolean) => void } type ActiveContextMenu = { record: VaultRecord position: { x: number; y: number } } const SECTION_TITLE_KEYS: Record = { favorites: 'Favorites', all: 'All Items', today: 'Today', yesterday: 'Yesterday', thisWeek: 'This Week', thisMonth: 'This Month', older: 'Older' } export const RecordListViewV2 = ({ sections, isMultiSelectOn = false, selectedRecords = [], setSelectedRecords, setIsMultiSelectOn }: RecordListViewV2Props) => { const { t } = useTranslation() const { theme } = useTheme() const { currentPage, navigate, data: routeData } = useRouter() const styles = createStyles(theme.colors) const [collapsedSections, setCollapsedSections] = useState< Record >({}) const [activeMenu, setActiveMenu] = useState(null) const allRecords = useMemo( () => sections.flatMap((s) => s.data), [sections] ) const toggleSection = useCallback((key: string) => { setCollapsedSections((prev) => ({ ...prev, [key]: !prev[key] })) }, []) const selectedRecordsSet = useMemo( () => new Set(selectedRecords), [selectedRecords] ) const handleRecordPress = useCallback( (record: VaultRecord) => { if (isMultiSelectOn) { setSelectedRecords?.((prev) => prev.includes(record.id) ? prev.filter((id) => id !== record.id) : [...prev, record.id] ) return } const isAlreadyOpen = routeData?.recordId === record.id navigate(currentPage, { recordId: isAlreadyOpen ? '' : record.id, recordType: routeData?.recordType }) }, [ isMultiSelectOn, setSelectedRecords, navigate, currentPage, routeData?.recordId, routeData?.recordType ] ) const handleRowContextMenu = useCallback( (event: React.MouseEvent, record: VaultRecord) => { if (isMultiSelectOn) return event.preventDefault() setActiveMenu({ record, position: { x: event.clientX, y: event.clientY } }) }, [isMultiSelectOn] ) // The overlay covers rows while open; re-target right-clicks via the // element stack at the cursor. useEffect(() => { if (!activeMenu) return const handler = (event: MouseEvent) => { event.preventDefault() const stack = document.elementsFromPoint(event.clientX, event.clientY) let recordId: string | null = null for (const el of stack) { const row = (el as HTMLElement).closest?.(`[${ROW_RECORD_ID_ATTR}]`) as | HTMLElement | null if (row) { recordId = row.getAttribute(ROW_RECORD_ID_ATTR) break } } const next = recordId ? allRecords.find((r) => r.id === recordId) : undefined setActiveMenu( next ? { record: next, position: { x: event.clientX, y: event.clientY } } : null ) } document.addEventListener('contextmenu', handler, true) return () => document.removeEventListener('contextmenu', handler, true) }, [activeMenu, allRecords]) const iconColor = theme.colors.colorTextSecondary const alertColor = theme.colors.colorSurfaceDestructiveElevated return (
{sections.map((section, sectionIndex) => { const isCollapsed = !!collapsedSections[section.key] const labelKey = SECTION_TITLE_KEYS[section.key] ?? section.title const label = t(labelKey) const isLastSection = sectionIndex === sections.length - 1 return (
{!isCollapsed && (
{section.data.map((record) => { const isSelected = selectedRecordsSet.has(record.id) return (
handleRowContextMenu(event, record) } > } iconSize={32} title={record.data?.title ?? ''} subtitle={getRecordSubtitle(record) || undefined} selectionMode={ isMultiSelectOn ? 'multi' : 'none' } isSelected={isSelected} onSelect={() => handleRecordPress(record)} onClick={() => handleRecordPress(record)} testID={`record-list-item-${record.id}`} isCheckboxSelectable={false} style={ styles.recordRow as React.ComponentProps< typeof ListItem >['style'] } rightElement={ !isMultiSelectOn ? (
{record.hasSecurityAlert && ( )}
) : undefined } />
) })}
)}
{!isLastSection && (
)} ) })}
) } ================================================ FILE: src/containers/RecordListView/RecordRowContextMenuV2.styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const RECORD_ROW_CONTEXT_MENU_WIDTH = 240 export const createStyles = (colors: ThemeColors) => ({ menuDivider: { width: '100%', height: 1, border: 'none', margin: 0, marginBlock: rawTokens.spacing4, backgroundColor: colors.colorBorderPrimary, flexShrink: 0 } }) ================================================ FILE: src/containers/RecordListView/RecordRowContextMenuV2.tsx ================================================ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { UNSUPPORTED } from '@tetherto/pearpass-lib-constants' import { NavbarListItem, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { CheckBox, CopyAll, DriveFileMoveOutlined, EditOutlined, Share, StarOutlined, TrashOutlined } from '@tetherto/pearpass-lib-ui-kit/icons' // @ts-expect-error - declaration file is incomplete import { useCreateRecord, useRecords, vaultGetFile } from '@tetherto/pearpass-lib-vault' import { createStyles, RECORD_ROW_CONTEXT_MENU_WIDTH } from './RecordRowContextMenuV2.styles' import { useModal } from '../../context/ModalContext' import { useCreateOrEditRecord } from '../../hooks/useCreateOrEditRecord' import { useTranslation } from '../../hooks/useTranslation' import { logger } from '../../utils/logger' import type { VaultRecord } from '../../utils/groupRecordsByTimePeriod' import { DeleteRecordsModalContentV2 } from '../Modal/DeleteRecordsModalContentV2' import { MoveFolderModalContentV2 } from '../Modal/MoveFolderModalContentV2/MoveFolderModalContentV2' const VIEWPORT_MARGIN = 8 const MENU_BOX_SHADOW = '0 185px 52px 0 rgba(8,10,5,0.01), 0 118px 47px 0 rgba(8,10,5,0.06), 0 67px 40px 0 rgba(8,10,5,0.20), 0 30px 30px 0 rgba(8,10,5,0.34), 0 7px 16px 0 rgba(8,10,5,0.39)' type RecordRowContextMenuV2Props = { record: VaultRecord open: boolean position: { x: number; y: number } onOpenChange: (open: boolean) => void setIsMultiSelectOn?: (value: boolean) => void setSelectedRecords?: ( updater: string[] | ((prev: string[]) => string[]) ) => void } type FileAttachment = { id: string; name: string } export const RecordRowContextMenuV2 = ({ record, open, position, onOpenChange, setIsMultiSelectOn, setSelectedRecords }: RecordRowContextMenuV2Props) => { const { t } = useTranslation() const { theme } = useTheme() const styles = createStyles(theme.colors) const { setModal } = useModal() const { handleCreateOrEditRecord } = useCreateOrEditRecord() const { updateFavoriteState } = useRecords() as { updateFavoriteState: (ids: string[], value: boolean) => Promise | void } const { createRecord } = useCreateRecord() const close = useCallback(() => onOpenChange(false), [onOpenChange]) const handleEdit = useCallback(() => { close() handleCreateOrEditRecord({ recordType: record.type, initialRecord: record }) }, [close, handleCreateOrEditRecord, record]) const handleToggleFavorite = useCallback(() => { close() void updateFavoriteState([record.id], !record.isFavorite) }, [close, updateFavoriteState, record.id, record.isFavorite]) const handleSelectItem = useCallback(() => { close() setIsMultiSelectOn?.(true) setSelectedRecords?.((prev) => prev.includes(record.id) ? prev : [...prev, record.id] ) }, [close, setIsMultiSelectOn, setSelectedRecords, record.id]) const handleShareItem = useCallback(() => { close() }, [close]) const handleMove = useCallback(() => { close() setModal() }, [close, setModal, record]) const fetchFileBuffers = useCallback( async (files: FileAttachment[] | undefined) => { if (!files?.length) return [] return Promise.all( files.map(async ({ id, name }) => { const buffer = await vaultGetFile(`record/${record.id}/file/${id}`) return { name, buffer } }) ) }, [record.id] ) const handleDuplicate = useCallback(async () => { close() try { const data: Record = { ...(record.data ?? {}) } data.attachments = await fetchFileBuffers( record.data?.attachments as FileAttachment[] | undefined ) if (record.type === 'identity') { data.passportPicture = await fetchFileBuffers( record.data?.passportPicture as FileAttachment[] | undefined ) data.idCardPicture = await fetchFileBuffers( record.data?.idCardPicture as FileAttachment[] | undefined ) data.drivingLicensePicture = await fetchFileBuffers( record.data?.drivingLicensePicture as FileAttachment[] | undefined ) } await createRecord({ type: record.type, folder: record.folder, isFavorite: record.isFavorite, data }) } catch (error) { logger.error('RecordRowContextMenuV2', 'Failed to duplicate record', error) } }, [close, createRecord, fetchFileBuffers, record]) const handleDelete = useCallback(() => { close() setModal() }, [close, setModal, record]) const textPrimary = theme.colors.colorTextPrimary const destructive = theme.colors.colorSurfaceDestructiveElevated const items = useMemo( () => [ { key: 'edit', label: t('Edit'), icon: , onClick: handleEdit }, { key: 'favorite', label: record.isFavorite ? t('Remove from Favorites') : t('Add to Favorites'), icon: , onClick: handleToggleFavorite }, { key: 'select', label: t('Select Item'), icon: , onClick: handleSelectItem }, ...(UNSUPPORTED ? [ { key: 'share', label: t('Share Item'), icon: , onClick: handleShareItem } ] : []), { key: 'move', label: t('Move to Another Folder'), icon: , onClick: handleMove }, { key: 'duplicate', label: t('Duplicate'), icon: , onClick: handleDuplicate } ], [ t, textPrimary, record.isFavorite, handleEdit, handleToggleFavorite, handleSelectItem, handleShareItem, handleMove, handleDuplicate ] ) const menuRef = useRef(null) // null until measured — keeps the menu hidden to avoid a one-frame flash. const [coords, setCoords] = useState<{ top: number left: number } | null>(null) useLayoutEffect(() => { if (!open) { setCoords(null) return } const el = menuRef.current if (!el) return const rect = el.getBoundingClientRect() const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : Infinity const maxLeft = Math.max( VIEWPORT_MARGIN, viewportWidth - rect.width - VIEWPORT_MARGIN ) const left = Math.min(Math.max(position.x, VIEWPORT_MARGIN), maxLeft) const fitsBelow = position.y + rect.height <= viewportHeight - VIEWPORT_MARGIN const top = fitsBelow ? position.y : Math.max(VIEWPORT_MARGIN, position.y - rect.height) setCoords({ top, left }) }, [open, position.x, position.y]) useEffect(() => { if (!open) return const handler = (event: KeyboardEvent) => { if (event.key === 'Escape') close() } document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) }, [open, close]) if (!open || typeof document === 'undefined') return null const visibility = coords === null ? 'hidden' : 'visible' const top = coords?.top ?? position.y const left = coords?.left ?? position.x return createPortal( <>
event.preventDefault()} style={{ position: 'fixed', inset: 0, backgroundColor: 'transparent', zIndex: 999 }} />
{items.map((item) => ( ))}
} label={t('Delete Item')} onClick={handleDelete} testID={`record-row-menu-delete-${record.id}`} />
, document.body ) } ================================================ FILE: src/containers/RecordListView/index.tsx ================================================ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { Dispatch, SetStateAction } from 'react' import { useLingui } from '@lingui/react' import { useRecords } from '@tetherto/pearpass-lib-vault' import { ActionsSection, DatePeriod, Folder, LeftActions, RecordsSection, RightActions, ViewWrapper } from './styles' import { isStartOfLast14DaysGroup, isStartOfLast7DaysGroup } from './utils' import { PopupMenu } from '../../components/PopupMenu' import { Record } from '../../components/Record' import { RecordSortActionsPopupContent } from '../../components/RecordSortActionsPopupContent' import { useModal } from '../../context/ModalContext' import { useRouter } from '../../context/RouterContext' import { ArrowUpAndDown, ButtonFilter, DeleteIcon, FolderIcon, MoveToIcon, MultiSelectionIcon, StarIcon, TimeIcon, XIcon } from '../../lib-react-components' import { FAVORITES_FOLDER_ID } from '../../utils/isFavorite' import { isV2 } from '../../utils/designVersion' import { ConfirmationModalContent } from '../Modal/ConfirmationModalContent' import { MoveFolderModalContent } from '../Modal/MoveFolderModalContent' import { MoveFolderModalContentV2 } from '../Modal/MoveFolderModalContentV2/MoveFolderModalContentV2' const ITEM_HEIGHT_RECORD = 45 const ITEM_HEIGHT_HEADER = 30 const ITEM_GAP = 5 const OVERSCAN = 5 type VirtualItem = | { kind: 'header'; label: string; index: number } | { kind: 'record'; record: RecordItem; index: number } type SortType = 'recent' | 'newToOld' | 'oldToNew' type RecordItem = { id: string createdAt: number updatedAt: number isFavorite: boolean vaultId: string folder: string type: 'note' | 'creditCard' | 'custom' | 'identity' | 'login' data?: { title?: string [key: string]: unknown } otpPublic?: { currentCode?: string | null } } type SortAction = { name: string icon: React.ComponentType type: SortType } type RecordListViewProps = { records: RecordItem[] selectedRecords: RecordItem[] setSelectedRecords: Dispatch> sortType: SortType setSortType: (type: SortType) => void } const PopupMenuComponent = PopupMenu as React.ComponentType<{ direction: | 'top' | 'bottom' | 'left' | 'right' | 'topRight' | 'topLeft' | 'bottomRight' | 'bottomLeft' isOpen: boolean setIsOpen: (value: boolean) => void content: React.ReactNode children: React.ReactNode }> const RecordComponent = Record as React.ComponentType<{ testId: string dataId: string record: RecordItem isSelected: boolean otpCode: string | null onSelect: (record: RecordItem, isSelected: boolean) => void onClick: (record: RecordItem, isSelected: boolean) => void }> const RecordSortActionsPopupContentComponent = RecordSortActionsPopupContent as unknown as React.ComponentType<{ onClick: (type: SortType) => void onClose: () => void selectedType: SortType menuItems: SortAction[] }> export const RecordListView = ({ records, selectedRecords, setSelectedRecords, sortType, setSortType }: RecordListViewProps) => { const { i18n } = useLingui() const { currentPage, navigate, data: routeData } = useRouter() const { setModal, closeModal } = useModal() const { deleteRecords } = useRecords() const [isSortPopupOpen, setIsSortPopupOpen] = useState(false) const [isMultiSelect, setIsMultiSelect] = useState(false) const navigateRef = useRef(navigate) const currentPageRef = useRef(currentPage) const recordTypeRef = useRef(routeData.recordType) const isMultiSelectRef = useRef(isMultiSelect) navigateRef.current = navigate currentPageRef.current = currentPage recordTypeRef.current = routeData.recordType isMultiSelectRef.current = isMultiSelect const sortActions: SortAction[] = [ { name: i18n._('Recent'), icon: TimeIcon, type: 'recent' }, { name: i18n._('Newest to oldest'), icon: ArrowUpAndDown, type: 'newToOld' }, { name: i18n._('Oldest to newest'), icon: ArrowUpAndDown, type: 'oldToNew' } ] const selectedRecordIds = useMemo( () => new Set(selectedRecords.map((r) => r.id)), [selectedRecords] ) // Refs & scroll state const scrollContainerRef = useRef(null) const [scrollTop, setScrollTop] = useState(0) const [containerHeight, setContainerHeight] = useState(600) const handleScroll = useCallback(() => { if (scrollContainerRef.current) setScrollTop(scrollContainerRef.current.scrollTop) }, []) useEffect(() => { const el = scrollContainerRef.current if (!el) return el.addEventListener('scroll', handleScroll, { passive: true }) return () => el.removeEventListener('scroll', handleScroll) }, [handleScroll]) useEffect(() => { const el = scrollContainerRef.current if (!el) return const ro = new ResizeObserver(([entry]) => setContainerHeight(entry.contentRect.height)) ro.observe(el) return () => ro.disconnect() }, []) const flatItems = useMemo((): VirtualItem[] => { const result: VirtualItem[] = [] records.forEach((record, originalIndex) => { if (!record?.data) return if (isStartOfLast7DaysGroup(record, originalIndex, records)) result.push({ kind: 'header', label: i18n._('Last 7 days'), index: result.length }) if (isStartOfLast14DaysGroup(record, originalIndex, records)) result.push({ kind: 'header', label: i18n._('Last 14 days'), index: result.length }) result.push({ kind: 'record', record, index: result.length }) }) return result }, [records, i18n]) const itemHeights = useMemo( () => flatItems.map((item) => item.kind === 'header' ? ITEM_HEIGHT_HEADER : ITEM_HEIGHT_RECORD ), [flatItems] ) const itemOffsets = useMemo((): number[] => { let top = 0 return flatItems.map((_, i) => { const offset = top const h = itemHeights[i] top += h + (i < flatItems.length - 1 ? ITEM_GAP : 0) return offset }) }, [flatItems, itemHeights]) const totalHeight = useMemo(() => { if (!flatItems.length) return 0 const last = flatItems.length - 1 return itemOffsets[last] + itemHeights[last] }, [flatItems.length, itemHeights, itemOffsets]) const { startIndex, endIndex } = useMemo(() => { if (!flatItems.length) return { startIndex: 0, endIndex: -1 } const viewTop = scrollTop const viewBottom = scrollTop + containerHeight let lo = 0, hi = flatItems.length - 1 while (lo < hi) { const mid = (lo + hi) >> 1 const h = itemHeights[mid] if (itemOffsets[mid] + h <= viewTop) lo = mid + 1 else hi = mid } let end = lo while (end < flatItems.length - 1 && itemOffsets[end + 1] < viewBottom) end++ return { startIndex: Math.max(0, lo - OVERSCAN), endIndex: Math.min(flatItems.length - 1, end + OVERSCAN) } }, [scrollTop, containerHeight, flatItems, itemHeights, itemOffsets]) const isRecordsSelected = selectedRecords.length > 0 const isFavorite = routeData.folder === FAVORITES_FOLDER_ID const selectedSortAction = sortActions.find((action) => action.type === sortType) ?? sortActions[0] const openRecordDetails = useCallback((record: RecordItem) => { navigateRef.current(currentPageRef.current, { recordId: record?.id, recordType: recordTypeRef.current }) }, []) const handleSelect = useCallback( (record: RecordItem, isSelected: boolean) => { setIsMultiSelect(true) setSelectedRecords((prev) => isSelected ? prev.filter((selectedRecord) => selectedRecord.id !== record?.id) : [...prev, record] ) }, [setSelectedRecords] ) const handleRecordClick = useCallback( (record: RecordItem, isSelected: boolean) => { if (isMultiSelectRef.current) { handleSelect(record, isSelected) return } openRecordDetails(record) }, [handleSelect, openRecordDetails] ) const handleSortTypeChange = (type: SortType) => { setSortType(type) } const onClearSelection = () => { setSelectedRecords([]) setIsMultiSelect(false) } const handleDeleteConfirm = async () => { await deleteRecords(selectedRecords.map((record) => record?.id)) onClearSelection() closeModal() } const handleDelete = async () => { setModal( ) } const handleMoveClick = () => { const VersionBasedMoveFolderModalContent = isV2() ? MoveFolderModalContentV2 : MoveFolderModalContent setModal( onClearSelection()} /> ) } return ( {isMultiSelect ? ( <> {i18n._('Move')} {i18n._('Delete')} ) : ( setIsSortPopupOpen(false)} selectedType={sortType} menuItems={sortActions} /> } > setIsSortPopupOpen((prev) => !prev)} > {selectedSortAction.name} )} {isMultiSelect ? ( {i18n._('Cancel')} ) : ( setIsMultiSelect(true)} startIcon={MultiSelectionIcon} > {i18n._('Multiple selection')} )} {!isMultiSelect && !!routeData?.folder?.length && (isFavorite ? ( {i18n._('Favorite')} ) : ( {routeData.folder} ))} {flatItems.slice(startIndex, endIndex + 1).map((item) => { const top = itemOffsets[item.index] if (item.kind === 'header') { return ( {item.label} ) } const { record } = item const isSelected = selectedRecordIds.has(record.id) return (
handleSelect(record, isSelected)} onClick={() => handleRecordClick(record, isSelected)} />
) })}
) } ================================================ FILE: src/containers/RecordListView/styles.js ================================================ import styled from 'styled-components' export const ViewWrapper = styled.div` width: 100%; height: 100%; display: flex; flex-direction: column; gap: 13px; ` export const ActionsSection = styled.div` width: 100%; display: flex; justify-content: space-between; ` export const LeftActions = styled.div` display: flex; gap: 10px; ` export const RightActions = styled.div` display: flex; ` export const Folder = styled.div` width: 100%; display: flex; align-items: center; gap: 6px; color: ${({ theme }) => theme.colors.white.mode1}; font-family: Inter; font-size: 16px; font-style: normal; font-weight: 400; line-height: normal; ` export const RecordsSection = styled.div` width: 100%; flex: 1; min-height: 0; overflow-y: auto; position: relative; ` export const DatePeriod = styled.div` color: ${({ theme }) => theme.colors.grey100.mode1}; font-family: 'Inter'; font-size: 12px; font-style: normal; font-weight: 400; line-height: normal; ` ================================================ FILE: src/containers/RecordListView/utils.js ================================================ import { MS_PER_WEEK } from '@tetherto/pearpass-lib-constants' /** * @param {{ * record: { * updatedAt: number * } * }} * @returns {boolean} */ export const isRecordInLast7Days = (record) => { const now = Date.now() const sevenDaysAgo = now - MS_PER_WEEK return record?.updatedAt >= sevenDaysAgo } /** * @param {{ * record: { * updatedAt: number * } * }} * @returns {boolean} */ export const isRecordInLast14Days = (record) => { const now = Date.now() const fourteenDaysAgo = now - MS_PER_WEEK * 2 const sevenDaysAgo = now - MS_PER_WEEK return ( record?.updatedAt >= fourteenDaysAgo && record?.updatedAt < sevenDaysAgo ) } /** * @param {{ * record: { * isFavorite: boolean, * updatedAt: number * }, * index: number, * sortedRecords: Array<{ * isFavorite: boolean, * updatedAt: number * }> * }} * @returns {boolean} */ export const isStartOfLast7DaysGroup = (record, index, sortedRecords) => { const prevRecord = sortedRecords[index - 1] const prevIsFavorite = prevRecord?.isFavorite const isInLast7Days = isRecordInLast7Days(record) return !record?.isFavorite && isInLast7Days && (index === 0 || prevIsFavorite) } /** * @param {{ * record: { * updatedAt: number * }, * index: number, * sortedRecords: Array<{ * isFavorite: boolean * updatedAt: number * }> * }} * @returns {boolean} */ export const isStartOfLast14DaysGroup = (record, index, sortedRecords) => { const prevRecord = sortedRecords[index - 1] const prevIsFavorite = prevRecord?.isFavorite const prevIsInLast7Days = prevRecord && isRecordInLast7Days(prevRecord) const isInLast14Days = isRecordInLast14Days(record) return ( !record?.isFavorite && isInLast14Days && (index === 0 || prevIsFavorite || prevIsInLast7Days) ) } ================================================ FILE: src/containers/Sidebar/SidebarCategories/index.js ================================================ import { useRecordCountsByType } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { CategoriesContainer } from './styles' import { SidebarCategory } from '../../../components/SidebarCategory' import { RECORD_COLOR_BY_TYPE } from '../../../constants/recordColorByType' import { RECORD_ICON_BY_TYPE } from '../../../constants/recordIconByType' import { useRouter } from '../../../context/RouterContext' import { useRecordMenuItems } from '../../../hooks/useRecordMenuItems' /** * * @param {{ * sidebarSize: 'default' | 'tight' * }} props */ export const SideBarCategories = ({ sidebarSize = 'default' }) => { const { navigate, data: routerData } = useRouter() const { menuItems } = useRecordMenuItems() const { data: recordCountData } = useRecordCountsByType() const handleRecordClick = (type) => { navigate('vault', { ...routerData, recordType: type, folder: undefined }) } return html` <${CategoriesContainer} size=${sidebarSize}> ${menuItems.map((record) => { const count = recordCountData[record?.type] || 0 return html` <${SidebarCategory} testId=${`sidebar-category-${record?.type}`} key=${record?.type} categoryName=${record?.name} color=${RECORD_COLOR_BY_TYPE[record?.type]} quantity=${count} isSelected=${routerData.recordType === record?.type} icon=${RECORD_ICON_BY_TYPE[record?.type]} onClick=${() => handleRecordClick(record?.type)} size=${sidebarSize} /> ` })} ` } ================================================ FILE: src/containers/Sidebar/SidebarCategories/styles.js ================================================ import styled from 'styled-components' import { isV2 } from '../../../utils/designVersion' export const CategoriesContainer = styled.div` display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; row-gap: ${({ size }) => (size === 'default' ? '8px' : '10px')}; column-gap: 12px; ${isV2() && 'flex-shrink: 0;'} ` ================================================ FILE: src/containers/Sidebar/SidebarV2.styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' import { FADE_GRADIENT_HEIGHT, HEADER_MIN_HEIGHT } from '../../constants/layout' export const SIDEBAR_WIDTH_EXPANDED = 250 export const SIDEBAR_WIDTH_COLLAPSED = 57 const SIDEBAR_HORIZONTAL_PADDING = rawTokens.spacing12 const FOLDERS_HEADER_HORIZONTAL_PADDING = rawTokens.spacing4 // Icon (16) + padding (4×2) + border (1×2). const COLLAPSED_SMALL_ICON_BUTTON_WIDTH = 26 const COLLAPSED_CHEVRON_WIDTH = 16 // translateX offsets to re-center a flex-anchored child in the collapsed column. export const COLLAPSE_BUTTON_CENTER_SHIFT_PX = (SIDEBAR_WIDTH_COLLAPSED - COLLAPSED_SMALL_ICON_BUTTON_WIDTH) / 2 - (SIDEBAR_WIDTH_COLLAPSED - SIDEBAR_HORIZONTAL_PADDING - COLLAPSED_SMALL_ICON_BUTTON_WIDTH) export const FOLDERS_CHEVRON_CENTER_SHIFT_PX = (SIDEBAR_WIDTH_COLLAPSED - COLLAPSED_CHEVRON_WIDTH) / 2 - (SIDEBAR_HORIZONTAL_PADDING + FOLDERS_HEADER_HORIZONTAL_PADDING) export const FOLDER_CONTEXT_MENU_WIDTH = 220 export const createStyles = (colors: ThemeColors, isCollapsed: boolean) => ({ wrapper: { display: 'flex' as const, flexDirection: 'column' as const, height: '100%', width: isCollapsed ? SIDEBAR_WIDTH_COLLAPSED : SIDEBAR_WIDTH_EXPANDED, backgroundColor: colors.colorSurfacePrimary, borderRight: `1px solid ${colors.colorBorderPrimary}`, boxSizing: 'border-box' as const, overflow: 'hidden' as const, transition: 'width 150ms ease' }, vaultSelector: { display: 'flex' as const, alignItems: 'center' as const, gap: rawTokens.spacing8, width: '100%', height: HEADER_MIN_HEIGHT, padding: rawTokens.spacing12, borderBottom: `1px solid ${colors.colorBorderPrimary}`, backgroundColor: colors.colorSurfacePrimary, boxSizing: 'border-box' as const, flexShrink: 0 }, vaultIconHidden: { display: 'none' as const }, vaultNameGroup: { flex: 1, minWidth: 0 }, vaultNameGroupHidden: { display: 'none' as const }, vaultNameRow: { display: 'flex' as const, alignItems: 'center' as const, gap: rawTokens.spacing4, minWidth: 0, cursor: 'pointer' as const, userSelect: 'none' as const }, vaultNameText: { minWidth: 0, overflow: 'hidden' as const, textOverflow: 'ellipsis' as const, whiteSpace: 'nowrap' as const, color: colors.colorTextPrimary }, chevron: { flexShrink: 0, transition: 'transform 150ms ease' }, chevronFlipped: { transform: 'rotate(180deg)' }, collapseButtonSlot: { marginInlineStart: 'auto' as const, display: 'flex' as const, // Use the individual-transform properties so `translate` can animate // while `rotate` flips instantly. translate: `${isCollapsed ? COLLAPSE_BUTTON_CENTER_SHIFT_PX : 0}px`, rotate: `${isCollapsed ? 180 : 0}deg`, transition: 'translate 150ms ease' }, scrollContainer: { position: 'relative' as const, display: 'flex' as const, flexDirection: 'column' as const, flex: 1, minHeight: 0 }, scrollArea: { display: 'flex' as const, flexDirection: 'column' as const, flex: 1, minHeight: 0, gap: rawTokens.spacing8, paddingInline: rawTokens.spacing12, paddingTop: rawTokens.spacing12, paddingBottom: FADE_GRADIENT_HEIGHT, overflowY: 'auto' as const, overflowX: 'hidden' as const }, fadeGradient: { position: 'absolute' as const, left: 0, right: 0, bottom: 0, height: FADE_GRADIENT_HEIGHT, pointerEvents: 'none' as const, background: `linear-gradient(180deg, ${colors.colorSurfacePrimary}00 0%, ${colors.colorSurfacePrimary} 100%)` }, sectionList: { display: 'flex' as const, flexDirection: 'column' as const, gap: 1, width: '100%' }, divider: { width: '100%', height: 1, backgroundColor: colors.colorBorderPrimary, border: 'none', margin: 0, flexShrink: 0 }, foldersHeader: { display: 'flex' as const, alignItems: 'center' as const, gap: rawTokens.spacing4, height: 32, padding: `${rawTokens.spacing8}px ${rawTokens.spacing4}px`, borderRadius: rawTokens.radius8, width: '100%', boxSizing: 'border-box' as const }, foldersHeaderToggle: { flex: 1, minWidth: 0 }, foldersHeaderToggleInner: { display: 'flex' as const, alignItems: 'center' as const, gap: rawTokens.spacing4, cursor: 'pointer' as const, userSelect: 'none' as const }, // Kept mounted and allowed to shrink so the chevron keeps its size when collapsed. foldersHeaderLabel: { opacity: isCollapsed ? 0 : 1, transition: 'opacity 150ms ease', minWidth: 0, overflow: 'hidden' as const, whiteSpace: 'nowrap' as const }, // Anchors the right-click ContextMenu so it opens just below the row, // aligned to its right edge, regardless of where the cursor was clicked. folderRow: { position: 'relative' as const, width: '100%' }, folderRowMenuAnchor: { position: 'absolute' as const, bottom: 0, right: 0 }, folderRowMenuTrigger: { display: 'block' as const, width: 0, height: 0 }, footerSection: { display: 'flex' as const, flexDirection: 'column' as const, alignItems: isCollapsed ? ('center' as const) : ('stretch' as const), gap: 1, padding: rawTokens.spacing12, borderTop: `1px solid ${colors.colorBorderPrimary}`, backgroundColor: colors.colorSurfacePrimary, flexShrink: 0 } }) ================================================ FILE: src/containers/Sidebar/SidebarV2.test.tsx ================================================ import React from 'react' import '@testing-library/jest-dom' import { fireEvent, render, screen, waitFor } from '@testing-library/react' const mockNavigate = jest.fn() const mockCloseAllInstances = jest.fn() const mockResetState = jest.fn() const mockSetIsLoading = jest.fn() const mockDeleteFolder = jest.fn() const mockSetModal = jest.fn() const mockCloseModal = jest.fn() let mockRouterData: Record = {} let mockFoldersData: { customFolders: Record< string, { name: string; records: Array<{ data?: unknown }> } > favorites: { records: unknown[] } } = { customFolders: {}, favorites: { records: [] } } jest.mock('@tetherto/pearpass-lib-constants', () => ({ AUTHENTICATOR_ENABLED: false })) jest.mock('@tetherto/pearpass-lib-vault', () => ({ RECORD_TYPES: { LOGIN: 'login', IDENTITY: 'identity', CREDIT_CARD: 'credit_card', NOTE: 'note', CUSTOM: 'custom', WIFI_PASSWORD: 'wifi_password', PASS_PHRASE: 'pass_phrase' }, closeAllInstances: (...args: unknown[]) => mockCloseAllInstances(...args), useFolders: () => ({ data: mockFoldersData, deleteFolder: mockDeleteFolder }), useRecordCountsByType: () => ({ data: {} }), useVault: () => ({ data: { name: 'Test Vault' } }), useVaults: () => ({ resetState: mockResetState }) })) jest.mock('@tetherto/pearpass-lib-ui-kit', () => { const React = require('react') return { useTheme: () => ({ theme: { colors: {} } }), rawTokens: new Proxy({}, { get: () => 0 }), Button: ({ onClick, 'data-testid': dataTestId, 'aria-label': ariaLabel }: { onClick?: () => void 'data-testid'?: string 'aria-label'?: string }) => React.createElement('button', { type: 'button', 'data-testid': dataTestId, 'aria-label': ariaLabel, onClick }), NavbarListItem: ({ label, onClick, onContextMenu, testID }: { label?: string onClick?: (e: React.MouseEvent) => void onContextMenu?: (e: React.MouseEvent) => void testID?: string }) => React.createElement( 'button', { type: 'button', 'data-testid': testID, onClick, onContextMenu }, label ), ContextMenu: ({ open, children, testID }: { open?: boolean children?: React.ReactNode testID?: string }) => open ? React.createElement( 'div', { role: 'menu', 'data-testid': testID }, children ) : null, Text: ({ children }: { children: React.ReactNode }) => React.createElement('span', null, children) } }) jest.mock( '@tetherto/pearpass-lib-ui-kit/components/Pressable', () => { const React = require('react') return { Pressable: ({ children, onClick, 'data-testid': dataTestId }: { children: React.ReactNode onClick?: () => void 'data-testid'?: string }) => React.createElement( 'button', { type: 'button', 'data-testid': dataTestId, onClick }, children ) } }, { virtual: true } ) const iconStub = () => null jest.mock('@tetherto/pearpass-lib-ui-kit/icons', () => ({ Add: iconStub, Close: iconStub, CreateNewFolder: iconStub, EditOutlined: iconStub, ExpandMore: iconStub, Folder: iconStub, FolderCopy: iconStub, Layers: iconStub, LockFilled: iconStub, LockOutlined: iconStub, MenuOpen: iconStub, SettingsOutlined: iconStub, StarBorder: iconStub, StarFilled: iconStub, TrashOutlined: iconStub, TwoFactorAuthenticationOutlined: iconStub, AccountCircleFilled: iconStub, AccountCircleOutlined: iconStub, AssignmentInd: iconStub, CreditCard: iconStub, FormatQuote: iconStub, GridView: iconStub, LayerFilled: iconStub, Note: iconStub, WiFi: iconStub })) jest.mock('../../context/RouterContext', () => ({ useRouter: () => ({ navigate: mockNavigate, data: mockRouterData }) })) jest.mock('../../context/ModalContext', () => ({ useModal: () => ({ setModal: mockSetModal, closeModal: mockCloseModal }) })) jest.mock('../../context/LoadingContext', () => ({ useLoadingContext: () => ({ setIsLoading: mockSetIsLoading }) })) jest.mock('../../hooks/useTranslation', () => ({ useTranslation: () => ({ t: (s: string) => s }) })) jest.mock('../Modal/CreateFolderModalContentV2/CreateFolderModalContentV2', () => { const React = require('react') return { CreateFolderModalContentV2: (props: { initialValues?: { title: string } }) => React.createElement('div', { 'data-testid': 'mock-create-folder-modal', 'data-initial-title': props.initialValues?.title ?? '' }) } }) jest.mock('../Modal/DeleteFolderModalContentV2/DeleteFolderModalContentV2', () => { const React = require('react') return { DeleteFolderModalContentV2: (props: { folderName: string; count: number }) => React.createElement('div', { 'data-testid': 'mock-delete-folder-modal', 'data-folder-name': props.folderName, 'data-count': String(props.count) }) } }) import { SidebarV2 } from './SidebarV2' const makeFolder = ( name: string, itemCount: number ): { name: string; records: Array<{ data?: unknown }> } => ({ name, records: Array.from({ length: itemCount }, () => ({ data: {} })) }) describe('SidebarV2 — lock app flow', () => { beforeEach(() => { jest.clearAllMocks() mockRouterData = {} mockFoldersData = { customFolders: {}, favorites: { records: [] } } }) it('closes instances, navigates to master password, and resets vault state', async () => { mockCloseAllInstances.mockImplementation(() => Promise.resolve()) render() fireEvent.click(screen.getByTestId('sidebar-lock-app')) await waitFor(() => { expect(mockCloseAllInstances).toHaveBeenCalledTimes(1) }) expect(mockNavigate).toHaveBeenCalledWith('welcome', { state: 'masterPassword' }) expect(mockResetState).toHaveBeenCalledTimes(1) expect(mockSetIsLoading).toHaveBeenNthCalledWith(1, true) expect(mockSetIsLoading).toHaveBeenLastCalledWith(false) }) it('navigates to settings when Settings is clicked', async () => { render() fireEvent.click(screen.getByTestId('sidebar-settings-button')) expect(mockNavigate).toHaveBeenCalledWith('settings', {}) }) }) describe('SidebarV2 — folder context menu', () => { beforeEach(() => { jest.clearAllMocks() mockRouterData = {} mockFoldersData = { customFolders: { work: makeFolder('work', 2), empty: makeFolder('empty', 0), other: makeFolder('other', 1) }, favorites: { records: [] } } }) it('opens the menu with Rename and Delete on right-click', () => { render() expect(screen.queryByTestId('sidebar-folder-menu-work')).toBeNull() fireEvent.contextMenu(screen.getByTestId('sidebar-folder-work')) expect(screen.getByTestId('sidebar-folder-menu-work')).toBeInTheDocument() expect( screen.getByTestId('sidebar-folder-menu-rename-work') ).toBeInTheDocument() expect( screen.getByTestId('sidebar-folder-menu-delete-work') ).toBeInTheDocument() }) it('Rename opens CreateFolderModalContentV2 prefilled with the folder title', () => { render() fireEvent.contextMenu(screen.getByTestId('sidebar-folder-work')) fireEvent.click(screen.getByTestId('sidebar-folder-menu-rename-work')) expect(mockSetModal).toHaveBeenCalledTimes(1) const modal = mockSetModal.mock.calls[0][0] as React.ReactElement render(modal) const instance = screen.getByTestId('mock-create-folder-modal') expect(instance.getAttribute('data-initial-title')).toBe('work') }) it('Delete on an empty folder calls deleteFolder directly (no modal)', () => { render() fireEvent.contextMenu(screen.getByTestId('sidebar-folder-empty')) fireEvent.click(screen.getByTestId('sidebar-folder-menu-delete-empty')) expect(mockDeleteFolder).toHaveBeenCalledWith('empty') expect(mockSetModal).not.toHaveBeenCalled() }) it('Delete on an empty active folder navigates away to All Folders', () => { mockRouterData = { folder: 'empty', recordType: 'all' } render() fireEvent.contextMenu(screen.getByTestId('sidebar-folder-empty')) fireEvent.click(screen.getByTestId('sidebar-folder-menu-delete-empty')) expect(mockDeleteFolder).toHaveBeenCalledWith('empty') expect(mockNavigate).toHaveBeenCalledWith('vault', { recordType: 'all' }) }) it('Delete on a non-empty folder opens DeleteFolderModalContentV2 with the item count', () => { render() fireEvent.contextMenu(screen.getByTestId('sidebar-folder-work')) fireEvent.click(screen.getByTestId('sidebar-folder-menu-delete-work')) expect(mockDeleteFolder).not.toHaveBeenCalled() expect(mockSetModal).toHaveBeenCalledTimes(1) const modal = mockSetModal.mock.calls[0][0] as React.ReactElement render(modal) const instance = screen.getByTestId('mock-delete-folder-modal') expect(instance.getAttribute('data-folder-name')).toBe('work') expect(instance.getAttribute('data-count')).toBe('2') }) it('opening one folder menu closes any other open menu', () => { render() fireEvent.contextMenu(screen.getByTestId('sidebar-folder-work')) expect(screen.getByTestId('sidebar-folder-menu-work')).toBeInTheDocument() fireEvent.contextMenu(screen.getByTestId('sidebar-folder-other')) expect(screen.getByTestId('sidebar-folder-menu-other')).toBeInTheDocument() expect(screen.queryByTestId('sidebar-folder-menu-work')).toBeNull() }) }) ================================================ FILE: src/containers/Sidebar/SidebarV2.tsx ================================================ import React, { useMemo, useState } from 'react' import { AUTHENTICATOR_ENABLED } from '@tetherto/pearpass-lib-constants' import { closeAllInstances, useFolders, useRecordCountsByType, useVault, useVaults, RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { Button, ContextMenu, NavbarListItem, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { Pressable } from '@tetherto/pearpass-lib-ui-kit/components/Pressable' import { Close, CreateNewFolder, EditOutlined, ExpandMore, Folder, FolderCopy, LockFilled, LockOutlined, MenuOpen, SettingsOutlined, StarBorder, StarFilled, TrashOutlined, TwoFactorAuthenticationOutlined } from '@tetherto/pearpass-lib-ui-kit/icons' import { createStyles, FOLDER_CONTEXT_MENU_WIDTH, FOLDERS_CHEVRON_CENTER_SHIFT_PX } from './SidebarV2.styles' import { VaultSelector } from './VaultSelector/VaultSelector' import { NAVIGATION_ROUTES } from '../../constants/navigation' import { useLoadingContext } from '../../context/LoadingContext' import { useModal } from '../../context/ModalContext' import { useRouter } from '../../context/RouterContext' import { useRecordMenuItemsV2 } from '../../hooks/useRecordMenuItemsV2' import { useTranslation } from '../../hooks/useTranslation' import { FAVORITES_FOLDER_ID } from '../../utils/isFavorite' import { sortByName } from '../../utils/sortByName' import { CreateFolderModalContentV2 } from '../Modal/CreateFolderModalContentV2/CreateFolderModalContentV2' import { DeleteFolderModalContentV2 } from '../Modal/DeleteFolderModalContentV2/DeleteFolderModalContentV2' export const SidebarV2 = () => { const { t } = useTranslation() const { theme } = useTheme() const [isCollapsed, setIsCollapsed] = useState(false) const [isVaultSelectorOpen, setIsVaultSelectorOpen] = useState(false) const [openFolderMenu, setOpenFolderMenu] = useState(null) const styles = createStyles(theme.colors, isCollapsed) const { navigate, data: routerData } = useRouter() const { data: vaultData } = useVault() const { data: foldersData, deleteFolder } = useFolders() const { data: recordCounts } = useRecordCountsByType() const { resetState } = useVaults() const { setModal, closeModal } = useModal() const { setIsLoading } = useLoadingContext() const [isFoldersExpanded, setIsFoldersExpanded] = useState(true) const { categoriesItems } = useRecordMenuItemsV2() const isAuthenticatorActive = routerData?.recordType === RECORD_TYPES.OTP const activeCategory = isAuthenticatorActive ? null : (routerData?.recordType ?? null) const isFavoritesActive = routerData?.folder === FAVORITES_FOLDER_ID const selectedFolderName = routerData?.folder && !isFavoritesActive ? routerData.folder : null const customFolders = useMemo(() => { const raw = Object.values(foldersData?.customFolders ?? {}) as Array<{ name: string records: Array<{ data?: unknown }> }> return sortByName( raw.map((folder) => ({ name: folder.name, count: folder.records.filter((record) => !!record.data).length })) ) }, [foldersData]) const favoritesCount = (foldersData?.favorites?.records?.length as number | undefined) ?? 0 const currentRecordType = routerData?.recordType ?? 'all' const currentFolder = routerData?.folder // Folder selection doesn't apply in authenticator mode; fall back to "all" // so clicking a folder exits authenticator cleanly instead of landing on the // nonsensical { recordType: RECORD_TYPES.OTP, folder: X } state. const folderClickRecordType = isAuthenticatorActive ? 'all' : currentRecordType const handleCategoryClick = (type: string) => { navigate('vault', { recordType: type, ...(currentFolder ? { folder: currentFolder } : {}) }) } const handleFolderClick = (folderId: string) => { navigate('vault', { recordType: folderClickRecordType, folder: folderId }) } const handleAllFoldersClick = () => { navigate('vault', { recordType: folderClickRecordType }) } const handleAddFolderClick = () => { setModal() } const handleRenameFolder = (folderName: string) => { setModal( { if (routerData?.folder === previousName) { navigate('vault', { recordType: currentRecordType, folder: newName }) } }} /> ) } const handleDeleteFolder = (folderName: string, count: number) => { if (count === 0) { void deleteFolder(folderName) if (routerData?.folder === folderName) { navigate('vault', { recordType: currentRecordType }) } return } setModal( ) } const handleSettingsClick = () => { navigate('settings', {}) } const handleLockApp = async () => { setIsLoading(true) try { await closeAllInstances() navigate('welcome', { state: NAVIGATION_ROUTES.MASTER_PASSWORD }) resetState() } finally { setIsLoading(false) } } const isAllFoldersActive = !isAuthenticatorActive && !routerData?.folder const iconTextPrimary = { color: theme.colors.colorTextPrimary } const iconTextSecondary = { color: theme.colors.colorTextSecondary } const renderCollapseButton = () => (
) const renderVaultHeader = () => { const chevronStyle = { ...iconTextPrimary, ...styles.chevron, ...(isVaultSelectorOpen ? styles.chevronFlipped : {}) } const showCloseButton = !isCollapsed && isVaultSelectorOpen const rightButton = showCloseButton ? (
{isFoldersExpanded && ( <> } onClick={handleAllFoldersClick} /> ) : ( ) } onClick={() => handleFolderClick(FAVORITES_FOLDER_ID)} /> {customFolders.map((folder) => ( setOpenFolderMenu(open ? folder.name : null) } styles={styles} theme={theme} onSelect={handleFolderClick} onRename={handleRenameFolder} onDelete={handleDeleteFolder} t={t} /> ))} )}
)}
{AUTHENTICATOR_ENABLED && (
} onClick={() => navigate('vault', { recordType: RECORD_TYPES.OTP })} />
)}
} onClick={handleSettingsClick} /> } onClick={handleLockApp} />
) } type FolderRowProps = { folder: { name: string; count: number } selected: boolean isCollapsed: boolean menuOpen: boolean onMenuOpenChange: (open: boolean) => void styles: ReturnType theme: ReturnType['theme'] onSelect: (folderName: string) => void onRename: (folderName: string) => void onDelete: (folderName: string, count: number) => void t: ReturnType['t'] } const FolderRow = ({ folder, selected, isCollapsed, menuOpen, onMenuOpenChange, styles, theme, onSelect, onRename, onDelete, t }: FolderRowProps) => { const iconColor = selected ? theme.colors.colorTextPrimary : theme.colors.colorTextSecondary const withMenuClose = (handler: () => void) => () => { onMenuOpenChange(false) handler() } return (
} onClick={() => onSelect(folder.name)} onContextMenu={(e: React.MouseEvent) => { e.preventDefault() onMenuOpenChange(true) }} />
} > } label={t('Rename Folder')} testID={`sidebar-folder-menu-rename-${folder.name}`} onClick={withMenuClose(() => onRename(folder.name))} /> } label={t('Delete Folder')} testID={`sidebar-folder-menu-delete-${folder.name}`} onClick={withMenuClose(() => onDelete(folder.name, folder.count))} />
) } ================================================ FILE: src/containers/Sidebar/VaultSelector/VaultSelector.styles.ts ================================================ import type { ThemeColors } from '@tetherto/pearpass-lib-ui-kit' import { rawTokens } from '@tetherto/pearpass-lib-ui-kit' export const VAULT_ACTIONS_MENU_WIDTH = 215 export const createStyles = (colors: ThemeColors) => ({ wrapper: { display: 'flex' as const, flexDirection: 'column' as const, gap: rawTokens.spacing4, width: '100%' }, titleRow: { display: 'flex' as const, alignItems: 'center' as const, gap: rawTokens.spacing4, padding: `${rawTokens.spacing8}px ${rawTokens.spacing4}px`, borderRadius: rawTokens.radius8, width: '100%', boxSizing: 'border-box' as const }, titleLabel: { flex: 1, minWidth: 0 }, list: { display: 'flex' as const, flexDirection: 'column' as const, gap: 1, width: '100%', cursor: 'pointer' as const }, vaultRow: { minHeight: '58px', gap: `${rawTokens.spacing8}px`, paddingInline: `${rawTokens.spacing8}px` }, rowActions: { display: 'flex' as const, alignItems: 'center' as const, gap: `${rawTokens.spacing8}px` }, iconActionButton: { paddingInline: '0', paddingBlock: '0', borderWidth: '0' }, menuGroup: { display: 'flex' as const, flexDirection: 'column' as const, width: '100%' }, menuDivider: { width: '100%', height: 1, backgroundColor: colors.colorBorderPrimary, border: 'none', margin: 0, flexShrink: 0 } }) ================================================ FILE: src/containers/Sidebar/VaultSelector/VaultSelector.tsx ================================================ import React, { useMemo, useState } from 'react' import { UNSUPPORTED } from '@tetherto/pearpass-lib-constants' import { useInvite, useVault, useVaults, type Vault } from '@tetherto/pearpass-lib-vault' import { Button, ContextMenu, ListItem, NavbarListItem, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { Add, Devices, EditOutlined, Key, LockFilled, MoreVert, PersonAddAlt, Share, TrashOutlined } from '@tetherto/pearpass-lib-ui-kit/icons' import { createStyles, VAULT_ACTIONS_MENU_WIDTH } from './VaultSelector.styles' import { useLoadingContext } from '../../../context/LoadingContext' import { useModal } from '../../../context/ModalContext' import { useTranslation } from '../../../hooks/useTranslation' import { sortByName } from '../../../utils/sortByName' import { AddDeviceModalContentV2 } from '../../Modal/AddDeviceModalContentV2/AddDeviceModalContentV2' import { CreateOrEditVaultModalContentV2 } from '../../Modal/CreateOrEditVaultModalContentV2/CreateOrEditVaultModalContentV2' import { DeleteVaultModalContent } from '../../Modal/DeleteVaultModalContent' import { ModifyVaultModalContent } from '../../Modal/ModifyVaultModalContent' import { PairedDevicesModalContent } from '../../Modal/PairedDevicesModalContent' import { useVaultSwitch } from '../../../hooks/useVaultSwitch' type VaultSelectorProps = { onClose?: () => void } export const VaultSelector = ({ onClose }: VaultSelectorProps = {}) => { const { t } = useTranslation() const { theme } = useTheme() const styles = createStyles(theme.colors) const { setIsLoading } = useLoadingContext() const { setModal, closeModal } = useModal() const { switchVault } = useVaultSwitch() const { data: vaultsData } = useVaults() const { data: activeVault } = useVault() const { data: inviteData, createInvite } = useInvite() const vaults = useMemo( () => sortByName(vaultsData ?? []), [vaultsData] ) const iconPrimary = { color: theme.colors.colorTextPrimary } const iconSecondary = { color: theme.colors.colorTextSecondary } const iconDestructive = { color: theme.colors.colorSurfaceDestructiveElevated } const openInviteModal = async (vault: Vault) => { if (inviteData?.vaultId !== vault.id) { setIsLoading(true) try { await createInvite() } finally { setIsLoading(false) } } setModal() } const handleCreate = () => { setModal( { closeModal() onClose?.() }} /> ) } const handleVaultClick = (vault: Vault) => { if (activeVault?.id !== vault.id) { void switchVault(vault) } onClose?.() } const handleInvite = (vault: Vault) => { openInviteModal(vault) } const handleRename = (vault: Vault) => { setModal( ) } const handleSetPassword = (vault: Vault) => { void switchVault(vault, () => { setModal( ) }) } const handleDelete = (vault: Vault) => { setModal() } const handleViewDevices = () => { setModal() } return (
{t('Vaults')}
{vaults.map((vault) => ( ))}
) } type VaultRowProps = { vault: Vault isActive: boolean iconPrimary: { color: string } iconDestructive: { color: string } styles: ReturnType onSelect: (vault: Vault) => void onInvite: (vault: Vault) => void onRename: (vault: Vault) => void onViewDevices: () => void onManageMembers: (vault: Vault) => void onSetPassword: (vault: Vault) => void onDelete: (vault: Vault) => void } const VaultRow = ({ vault, isActive, iconPrimary, iconDestructive, styles, onSelect, onInvite, onRename, onViewDevices, onManageMembers, onSetPassword, onDelete }: VaultRowProps) => { const { t } = useTranslation() const [menuOpen, setMenuOpen] = useState(false) const withMenuClose = (handler: (vault: Vault) => void) => () => { setMenuOpen(false) handler(vault) } const stopPropagation = (event: React.MouseEvent) => { event.stopPropagation() } const actionButtonStyle = styles.iconActionButton as React.ComponentProps< typeof Button >['style'] const rightElement = (
) return ( } iconSize={16} title={vault.name} selected={isActive} style={styles.vaultRow as React.ComponentProps['style']} testID={`vault-row-${vault.id}`} onClick={() => onSelect(vault)} rightElement={isActive ? rightElement : undefined} /> ) } ================================================ FILE: src/containers/Sidebar/index.js ================================================ import React, { useEffect, useMemo, useState } from 'react' import { matchPatternToValue } from '@tetherto/pear-apps-utils-pattern-search' import { AUTHENTICATOR_ENABLED } from '@tetherto/pearpass-lib-constants' import { closeAllInstances, useFolders, useVault, useVaults, RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { SideBarCategories } from './SidebarCategories' import { FoldersWrapper, LogoWrapper, PearPass, SettingsContainer, SettingsSeparator, SidebarAuthenticatorSection, sideBarContent, SidebarNestedFoldersContainer, SidebarSettings, SidebarWrapper } from './styles' import { DropdownSwapVault } from '../../components/DropdownSwapVault' import { SidebarFolder } from '../../components/SidebarFolder' import { SidebarSearch } from '../../components/SidebarSearch' import { NAVIGATION_ROUTES } from '../../constants/navigation' import { useLoadingContext } from '../../context/LoadingContext' import { useModal } from '../../context/ModalContext' import { useRouter } from '../../context/RouterContext' import { useTranslation } from '../../hooks/useTranslation.js' import { ButtonThin, ExitIcon, LockIcon, SettingsIcon, StarIcon, UserSecurityIcon } from '../../lib-react-components' import { LogoLock } from '../../svgs/LogoLock' import { isV2 } from '../../utils/designVersion' import { FAVORITES_FOLDER_ID } from '../../utils/isFavorite' import { sortByName } from '../../utils/sortByName' import { AddDeviceModalContent } from '../Modal/AddDeviceModalContent' import { AddDeviceModalContentV2 } from '../Modal/AddDeviceModalContentV2/AddDeviceModalContentV2' import { CreateFolderModalContent } from '../Modal/CreateFolderModalContent' import { CreateFolderModalContentV2 } from '../Modal/CreateFolderModalContentV2/CreateFolderModalContentV2' /** * @param {{ * sidebarSize?: 'default' | 'tight' * }} props */ export const Sidebar = ({ sidebarSize = 'tight' }) => { const { t } = useTranslation() const { navigate, data: routerData } = useRouter() const [searchValue, setSearchValue] = useState('') const { setIsLoading } = useLoadingContext() const { data } = useFolders() const { data: vaultsData, resetState, refetch: refetchMasterVault } = useVaults() const { data: vaultData } = useVault() const vaults = useMemo( () => sortByName(vaultsData?.filter((vault) => vault.id !== vaultData?.id)), [vaultsData, vaultData] ) const handleSettingsClick = () => { navigate('settings', {}) } const openMainView = () => { navigate('vault', { recordType: 'all' }) } const handleExitVault = async () => { setIsLoading(true) await closeAllInstances() navigate('welcome', { state: NAVIGATION_ROUTES.MASTER_PASSWORD }) resetState() setIsLoading(false) } const folders = React.useMemo(() => { const { customFolders } = data || {} const otherFolders = Object.values(customFolders ?? {}) .map(({ name }) => ({ name, id: name, isActive: routerData?.folder === name })) .sort((a, b) => a.name.localeCompare(b.name)) const filteredFolders = searchValue ? otherFolders.filter((folder) => matchPatternToValue(searchValue, folder.name) ) : otherFolders const allItemsFolder = { name: t('All Items'), id: 'allItems', isRoot: true, isActive: !routerData?.folder && routerData?.recordType === 'all' } const favoritesFolder = { name: t('Favorites'), id: FAVORITES_FOLDER_ID, icon: StarIcon, isActive: routerData?.folder === FAVORITES_FOLDER_ID } return [allItemsFolder, favoritesFolder, ...filteredFolders] }, [data, t, routerData, searchValue]) const { setModal, closeModal } = useModal() const handleAddDevice = () => { setModal(isV2() ? : ) } const handleAddFolderClick = () => { isV2() ? setModal() : setModal(html`<${CreateFolderModalContent} />`) } const handleFolderClick = (id) => { if (id === 'allItems') { navigate('vault', { recordType: 'all' }) return } if (id === RECORD_TYPES.OTP) { navigate('vault', { recordType: RECORD_TYPES.OTP }) return } navigate('vault', { recordType: 'all', folder: id }) } useEffect(() => { refetchMasterVault() }, []) return html` <${SidebarWrapper} size=${sidebarSize}> <${LogoWrapper} onClick=${openMainView}> <${LogoLock} width="20" height="26" /> <${PearPass}>${window.electronAPI?.productName ?? 'PearPass'} <${sideBarContent}> <${DropdownSwapVault} vaults=${vaults} selectedVault=${vaultData} /> <${SideBarCategories} sidebarSize=${sidebarSize} /> ${data && html` <${SidebarNestedFoldersContainer}> <${SidebarSearch} testId="sidebar-folder-search" value=${searchValue} onChange=${setSearchValue} /> <${FoldersWrapper}> ${folders.map(({ id, isRoot, name, icon, isActive }) => { const hasMenu = id !== FAVORITES_FOLDER_ID && !isRoot return html`<${SidebarFolder} key=${id} isOpen=${false} onClick=${() => handleFolderClick(id)} onAddClick=${handleAddFolderClick} isRoot=${isRoot} name=${name} icon=${icon} isActive=${isActive} hasMenu=${hasMenu} />` })} ${AUTHENTICATOR_ENABLED && html` <${SidebarAuthenticatorSection}> <${SidebarFolder} key="authenticator" isOpen=${false} onClick=${() => handleFolderClick(RECORD_TYPES.OTP)} name=${t('Authenticator')} icon=${LockIcon} isActive=${routerData?.recordType === RECORD_TYPES.OTP} hasMenu=${false} /> `} `} <${SidebarSettings}> <${SettingsContainer} data-testid="sidebar-settings-button" onClick=${handleSettingsClick} > <${SettingsIcon} size="24" /> ${t('Settings')} <${SettingsSeparator} /> <${ButtonThin} testId="sidebar-adddevice-button" startIcon=${UserSecurityIcon} onClick=${handleAddDevice} > ${t('Add a Device')} <${ButtonThin} testId="sidebar-exit-button" startIcon=${ExitIcon} onClick=${handleExitVault} > ${t('Exit Vault')} ` } ================================================ FILE: src/containers/Sidebar/styles.js ================================================ import styled from 'styled-components' import { isV2 } from '../../utils/designVersion' export const SidebarWrapper = styled.div` display: flex; gap: 20px; padding: 25px 20px; color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; width: ${({ size }) => (size === 'tight' ? '245px' : '296px')}; height: 100%; flex-direction: column; justify-content: space-between; align-items: flex-start; align-self: stretch; border-right: 1px solid ${({ theme }) => theme.colors.grey300.mode1}; background: ${({ theme }) => theme.colors.grey500.mode1}; height: 100%; ` export const LogoWrapper = styled.div` display: flex; align-items: center; gap: 10px; cursor: pointer; ` export const PearPass = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Humble Nostalgia'; font-size: 32px; font-style: normal; font-weight: 400; line-height: normal; height: 26px; ` export const sideBarContent = styled.div` display: flex; flex-direction: column; flex: 1; width: 100%; gap: 20px; min-height: 0; overflow: hidden; ${isV2() && ` overflow-y: auto; overflow-x: hidden; padding-right: 4px; &::-webkit-scrollbar { width: 4px; } &::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 10px; } &::-webkit-scrollbar-track { background: transparent; } padding-bottom: 20px; `} ` export const SidebarNestedFoldersContainer = styled.div` padding: 10px 0; display: flex; flex-direction: column; gap: 6px; ${isV2() ? 'flex-shrink: 0;' : 'flex: 1; min-height: 0;'} ` export const FoldersWrapper = styled.div` overflow-y: ${isV2() ? 'visible' : 'auto'}; display: flex; flex-direction: column; ${isV2() ? 'flex-shrink: 0;' : 'min-height: 0;'} ` export const SidebarAuthenticatorSection = styled.div` margin-top: 8px; padding-top: 8px; border-top: 1px solid ${({ theme }) => theme.colors.grey300.mode1}; flex-shrink: 0; ` export const SidebarSettings = styled.div` width: 100%; flex-grow: 0; display: flex; flex-direction: column; gap: 10px; ` export const SettingsContainer = styled.div` display: flex; align-items: center; padding: 0px 5px; gap: 5px; cursor: pointer; ` export const SettingsSeparator = styled.div` width: 100%; height: 2px; background: ${({ theme }) => theme.colors.grey300.mode1}; ` ================================================ FILE: src/containers/WifiPasswordQRCode/WifiPasswordQRCodeV2.tsx ================================================ import { useEffect, useState } from 'react' import { generateQRCodeSVG } from '@tetherto/pear-apps-utils-qr' import { rawTokens, Text, useTheme } from '@tetherto/pearpass-lib-ui-kit' import { useTranslation } from '../../hooks/useTranslation' import { logger } from '../../utils/logger' interface Props { ssid?: string password?: string encryptionType?: string isHidden?: boolean } export const WifiPasswordQRCodeV2 = ({ ssid, password, encryptionType = 'WPA', isHidden = false }: Props) => { const { t } = useTranslation() const { theme } = useTheme() const [qrCodeSvg, setQrCodeSvg] = useState('') useEffect(() => { if (!ssid || !password) { setQrCodeSvg('') return } const wifiString = `WIFI:T:${encryptionType};S:${ssid};P:${password};H:${isHidden};;` generateQRCodeSVG(wifiString, { type: 'svg', margin: 0 }) .then((svg: string) => setQrCodeSvg(svg)) .catch((err: unknown) => logger.error('Error generating QR code:', err)) }, [ssid, password, encryptionType, isHidden]) if (!ssid || !password || !qrCodeSvg) return null return (
{t('Scan QR Code to connect with the Wi-Fi')}
) } ================================================ FILE: src/containers/WifiPasswordQRCode/index.js ================================================ import { useEffect, useState } from 'react' import { useLingui } from '@lingui/react' import { generateQRCodeSVG } from '@tetherto/pear-apps-utils-qr' import { html } from 'htm/react' import { Container, QRCode, QrContainer, Title } from './styles' import { logger } from '../../utils/logger' /** * @param {{ * ssid: string * password: string * encryptionType?: string * isHidden?: boolean * }} props */ export const WifiPasswordQRCode = ({ ssid, password, encryptionType = 'WPA', isHidden = false }) => { const { i18n } = useLingui() const [qrCodeSvg, setQrCodeSvg] = useState('') const generateWifiQRString = ( ssid, password, encryptionType = 'WPA', isHidden = false ) => `WIFI:T:${encryptionType};S:${ssid};P:${password};H:${isHidden};;` useEffect(() => { if (ssid && password) { const wifiString = generateWifiQRString( ssid, password, encryptionType, isHidden ) generateQRCodeSVG(wifiString, { type: 'svg', margin: 0 }) .then((svgString) => { setQrCodeSvg(svgString) }) .catch((error) => { logger.error('Error generating QR code:', error) }) } }, [ssid, password, encryptionType, isHidden]) if (!ssid || !password || !qrCodeSvg) { return null } return html` <${Container} data-testid="wifidetails-qrcode"> <${Title}> ${i18n._(`Scan the QR-Code to connect to the Wi-Fi`)} <${QrContainer}> <${QRCode} style=${{ width: '200px', height: '200px' }} dangerouslySetInnerHTML=${{ __html: qrCodeSvg }} /> ` } ================================================ FILE: src/containers/WifiPasswordQRCode/styles.js ================================================ import styled from 'styled-components' export const Container = styled.div` width: 100%; display: flex; padding: 20px 10px; flex-direction: column; align-items: center; gap: 10px; align-self: stretch; border-radius: 10px; background: ${({ theme }) => theme.colors.grey500.mode1}; ` export const Title = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 14px; font-style: normal; font-weight: 700; line-height: normal; ` export const QrContainer = styled.div` display: flex; justify-content: center; align-items: center; ` export const QRCode = styled.div` width: 226px; height: 226px; padding: 15px; border-radius: 10px; background-color: ${({ theme }) => theme.colors.white.mode1}; ` ================================================ FILE: src/context/AppHeaderContext.test.js ================================================ import React from 'react' import '@testing-library/jest-dom' import { render, screen, act } from '@testing-library/react' import { renderHook } from '@testing-library/react' import { AppHeaderContextProvider, useAppHeaderContext } from './AppHeaderContext' const Consumer = () => { const { searchValue, setSearchValue, isAddMenuOpen, setIsAddMenuOpen } = useAppHeaderContext() return (
{searchValue} {String(isAddMenuOpen)}
) } describe('AppHeaderContext', () => { it('throws when useAppHeaderContext is used outside AppHeaderContextProvider', () => { const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) expect(() => { renderHook(() => useAppHeaderContext()) }).toThrow( 'useAppHeaderContext must be used within AppHeaderContextProvider' ) spy.mockRestore() }) it('provides default state', () => { render( ) expect(screen.getByTestId('search')).toHaveTextContent('') expect(screen.getByTestId('menu-open')).toHaveTextContent('false') }) it('updates searchValue and isAddMenuOpen via setters', () => { render( ) act(() => { screen.getByTestId('set-search').click() }) expect(screen.getByTestId('search')).toHaveTextContent('hello') act(() => { screen.getByTestId('toggle-menu').click() }) expect(screen.getByTestId('menu-open')).toHaveTextContent('true') act(() => { screen.getByTestId('toggle-menu').click() }) expect(screen.getByTestId('menu-open')).toHaveTextContent('false') }) }) ================================================ FILE: src/context/AppHeaderContext.tsx ================================================ import React, { createContext, useContext, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react' export type AppHeaderContextState = { searchValue: string setSearchValue: Dispatch> isAddMenuOpen: boolean setIsAddMenuOpen: Dispatch> } const AppHeaderContext = createContext(null) export const AppHeaderContextProvider = ({ children }: { children: ReactNode }) => { const [searchValue, setSearchValue] = useState('') const [isAddMenuOpen, setIsAddMenuOpen] = useState(false) const value = useMemo( (): AppHeaderContextState => ({ searchValue, setSearchValue, isAddMenuOpen, setIsAddMenuOpen }), [searchValue, isAddMenuOpen] ) return ( {children} ) } export const useAppHeaderContext = (): AppHeaderContextState => { const ctx = useContext(AppHeaderContext) if (!ctx) { throw new Error( 'useAppHeaderContext must be used within AppHeaderContextProvider' ) } return ctx } ================================================ FILE: src/context/BannerContext.js ================================================ import { createContext, useContext, useEffect, useState } from 'react' import { html } from 'htm/react' import { BannerBox } from '../components/BannerBox' import { CHROME_EXTENSION_STORE_LINK } from '../constants/pearpassLinks' import { useTranslation } from '../hooks/useTranslation' import { isNativeMessagingIPCRunning } from '../services/nativeMessagingIPCServer' import { getNativeMessagingEnabled } from '../services/nativeMessagingPreferences' const BannerContext = createContext() export const BannerProvider = ({ children }) => { const { t } = useTranslation() const [visible, setVisible] = useState(false) useEffect(() => { const enabled = getNativeMessagingEnabled() const isRunning = isNativeMessagingIPCRunning() if (!enabled || !isRunning) { setVisible(true) } }, []) const showBanner = () => { setVisible(true) } const hideBanner = () => { setVisible(false) } return html` <${BannerContext.Provider} value=${{ visible, showBanner, hideBanner }} > ${children} ${visible && html` <${BannerBox} onClose=${hideBanner} isVisible=${visible} href=${CHROME_EXTENSION_STORE_LINK} title=${t('You’ve got the app. Now unlock the full experience.')} message=${t( 'Install our browser extension to autofill passwords, save new logins in a click, and log in instantly,right where you browse.' )} highlightedDescription=${t( 'No more copy-paste. No more interruptions. Just seamless security.' )} buttonText=${t('Download now')} /> `} ` } export const useBanner = () => useContext(BannerContext) ================================================ FILE: src/context/LoadingContext.js ================================================ import { createContext, useCallback, useContext, useEffect, useState } from 'react' import { html } from 'htm/react' import { LoadingOverlay } from '../components/LoadingOverlay' const LoadingContext = createContext() /** * @param {{ * children: import('react').ReactNode * }} props */ export const LoadingProvider = ({ children }) => { // Ref-counted so concurrent callers don't stomp each other's loading state. const [count, setCount] = useState(0) const setIsLoading = useCallback((isLoading) => { setCount((prev) => { if (isLoading) return prev + 1 return prev > 0 ? prev - 1 : 0 }) }, []) const isLoading = count > 0 return html` <${LoadingContext.Provider} value=${{ isLoading, setIsLoading }}> ${children} ${isLoading && html`<${LoadingOverlay} />`} ` } /** * @returns {{ * isLoading: boolean, * setIsLoading: (isLoading: boolean) => void * }} */ export const useLoadingContext = () => useContext(LoadingContext) /** * @param {{ * isLoading: boolean * }} props */ export const useGlobalLoading = ({ isLoading }) => { const { setIsLoading } = useLoadingContext() useEffect(() => { if (isLoading !== true) return setIsLoading(true) return () => setIsLoading(false) }, [isLoading, setIsLoading]) } ================================================ FILE: src/context/LoadingContext.test.js ================================================ import React from 'react' import { render, act } from '@testing-library/react' import { LoadingProvider, useLoadingContext, useGlobalLoading } from './LoadingContext' import '@testing-library/jest-dom' jest.mock('../components/LoadingOverlay', () => ({ LoadingOverlay: () => 'LoadingOverlay' })) describe('LoadingContext', () => { describe('LoadingProvider', () => { it('should render children', () => { const { getByText } = render(
Test Child
) expect(getByText('Test Child')).toBeInTheDocument() }) it('should show LoadingOverlay when loading', () => { const { container } = render(
Test Child
) expect(container.innerHTML).not.toContain('LoadingOverlay') const TestComponent = () => { const { setIsLoading } = useLoadingContext() return } const { getByText } = render( ) act(() => { getByText('Load').click() }) expect(container.innerHTML).toContain('Test Child') }) }) describe('useLoadingContext', () => { it('should provide loading state and setter', () => { let contextValue const TestComponent = () => { contextValue = useLoadingContext() return null } render( ) expect(contextValue.isLoading).toBe(false) expect(typeof contextValue.setIsLoading).toBe('function') act(() => { contextValue.setIsLoading(true) }) expect(contextValue.isLoading).toBe(true) }) it('should stay loading until every acquire is released', () => { let contextValue const TestComponent = () => { contextValue = useLoadingContext() return null } render( ) act(() => { contextValue.setIsLoading(true) contextValue.setIsLoading(true) }) expect(contextValue.isLoading).toBe(true) act(() => { contextValue.setIsLoading(false) }) expect(contextValue.isLoading).toBe(true) act(() => { contextValue.setIsLoading(false) }) expect(contextValue.isLoading).toBe(false) }) it('should clamp release at zero so stray falses do not underflow', () => { let contextValue const TestComponent = () => { contextValue = useLoadingContext() return null } render( ) act(() => { contextValue.setIsLoading(false) contextValue.setIsLoading(false) contextValue.setIsLoading(true) }) expect(contextValue.isLoading).toBe(true) }) }) describe('useGlobalLoading', () => { it('should set loading state based on props', () => { let contextValue const TestComponent = ({ isLoading }) => { useGlobalLoading({ isLoading }) contextValue = useLoadingContext() return null } const { rerender } = render( ) expect(contextValue.isLoading).toBe(false) rerender( ) expect(contextValue.isLoading).toBe(true) }) it('should not set loading state when isLoading is not boolean', () => { let contextValue const TestComponent = ({ isLoading }) => { useGlobalLoading({ isLoading }) contextValue = useLoadingContext() return null } render( ) expect(contextValue.isLoading).toBe(false) }) it('should not release a concurrent imperative acquire when its own isLoading flips to false', () => { let contextValue const TestComponent = ({ isLoading }) => { useGlobalLoading({ isLoading }) contextValue = useLoadingContext() return null } const { rerender } = render( ) act(() => { contextValue.setIsLoading(true) }) expect(contextValue.isLoading).toBe(true) rerender( ) rerender( ) expect(contextValue.isLoading).toBe(true) }) }) }) ================================================ FILE: src/context/ModalContext.js ================================================ import { createContext, useState, useContext, useEffect, useCallback, useMemo, useRef } from 'react' import { generateUniqueId } from '@tetherto/pear-apps-utils-generate-unique-id' import { html } from 'htm/react' import { Overlay } from '../components/Overlay' import { BASE_TRANSITION_DURATION } from '../constants/transitions' import { ModalWrapper } from '../containers/Modal' import { SideDrawer } from '../containers/Modal/SideDrawer' // Pad past the overlay fade so unmount lands after `transitionend`. export const STACK_CLEANUP_BUFFER = 100 const ModalContext = createContext() const getTopModal = (modalStack) => modalStack[modalStack.length - 1] const DEFAULT_MODAL_PARAMS = { hasOverlay: true, overlayType: 'default', modalType: 'default', closable: true, replace: false } /** * @param {{ * children: import('react').ReactNode * }} props */ export const ModalProvider = ({ children }) => { const [modalStack, setModalStack] = useState([]) // Ids that already have a removal timer pending, to avoid duplicates. const scheduledIdsRef = useRef(new Set()) const isOpen = !!modalStack.length const setModal = useCallback((content, params) => { setModalStack((prevState) => { if (params?.replace) { return [ { content, id: generateUniqueId(), isOpen: true, params: { ...DEFAULT_MODAL_PARAMS, ...params } } ] } return [ ...prevState, { content, id: generateUniqueId(), isOpen: true, params: { ...DEFAULT_MODAL_PARAMS, ...params } } ] }) }, []) const closeModal = useCallback(() => { setModalStack((prevState) => { // Skip entries already closing so a rapid second close hits the modal // beneath one mid-transition. for (let i = prevState.length - 1; i >= 0; i--) { if (prevState[i].isOpen) { const next = [...prevState] next[i] = { ...next[i], isOpen: false } return next } } return prevState }) }, []) // Drops closing entries after their fade. Runs as an effect because the // state closeModal flips isn't visible until the next render. useEffect(() => { const closingEntries = modalStack.filter( (m) => !m.isOpen && !scheduledIdsRef.current.has(m.id) ) if (closingEntries.length === 0) return closingEntries.forEach(({ id }) => { scheduledIdsRef.current.add(id) setTimeout(() => { setModalStack((prev) => prev.filter((m) => m.id !== id)) scheduledIdsRef.current.delete(id) }, BASE_TRANSITION_DURATION + STACK_CLEANUP_BUFFER) }) }, [modalStack]) useEffect(() => { const handleKeydown = (event) => { if (event.key === 'Escape' && isOpen) { const topModal = getTopModal(modalStack) if (topModal?.params?.closable !== false) { void closeModal() } } } window.addEventListener('keydown', handleKeydown) return () => { window.removeEventListener('keydown', handleKeydown) } }, [isOpen]) const contextValue = useMemo( () => ({ isOpen, setModal, closeModal }), [isOpen, setModal, closeModal] ) return html` <${ModalContext.Provider} value=${contextValue}> ${children} ${modalStack?.map( ({ content, id, isOpen, params }) => html` <${ModalWrapper} key=${id}> ${params.hasOverlay && html`<${Overlay} onClick=${params?.closable ? closeModal : undefined} type=${params.overlayType} isOpen=${isOpen} /> `} ${params.modalType === 'sideDrawer' && html`<${SideDrawer} isOpen=${isOpen}> ${content} `} ${params.modalType === 'default' && isOpen && content} ` )} ` } /** * @returns {{ * isOpen: boolean, * setModal: (content: any, params?: any) => void, * closeModal: () => void * }} */ export const useModal = () => useContext(ModalContext) ================================================ FILE: src/context/ModalContext.test.js ================================================ import React from 'react' import { render, screen, fireEvent, act } from '@testing-library/react' import { ModalProvider, STACK_CLEANUP_BUFFER, useModal } from './ModalContext' import { BASE_TRANSITION_DURATION } from '../constants/transitions' import '@testing-library/jest-dom' const CLOSE_DURATION = BASE_TRANSITION_DURATION + STACK_CLEANUP_BUFFER jest.mock('@tetherto/pear-apps-utils-generate-unique-id', () => { let n = 0 return { generateUniqueId: jest.fn(() => `id-${n++}`) } }) jest.mock('../components/Overlay', () => ({ Overlay: ({ onClick, type, isOpen }) => (
Overlay: {type}, isOpen: {isOpen.toString()}
) })) jest.mock('../containers/Modal', () => ({ ModalWrapper: ({ children }) => (
{children}
) })) jest.mock('../containers/Modal/SideDrawer', () => ({ SideDrawer: ({ children, isOpen }) => (
{children}
) })) const TestComponent = () => { const { setModal, closeModal, isOpen } = useModal() return (
{isOpen.toString()}
) } const StackedModalsTestComponent = () => { const { setModal, closeModal } = useModal() return (
) } const TestSideDrawerComponent = () => { const { setModal, isOpen } = useModal() return (
{isOpen.toString()}
) } describe('ModalProvider', () => { beforeEach(() => { jest.useFakeTimers() }) afterEach(() => { jest.runOnlyPendingTimers() jest.useRealTimers() }) test('renders children', () => { render(
Child Content
) expect(screen.getByTestId('child')).toBeInTheDocument() }) test('opens and closes default modal using setModal and closeModal', () => { render( ) expect(screen.getByTestId('is-open').textContent).toBe('false') fireEvent.click(screen.getByText('Open Modal')) expect(screen.getByTestId('is-open').textContent).toBe('true') expect(screen.getByTestId('modal-content')).toBeInTheDocument() fireEvent.click(screen.getByText('Close Modal')) act(() => { jest.advanceTimersByTime(CLOSE_DURATION) }) expect(screen.queryByTestId('modal-content')).not.toBeInTheDocument() }) test('closes modal on Escape key press', () => { render( ) fireEvent.click(screen.getByText('Open Modal')) expect(screen.getByTestId('modal-content')).toBeInTheDocument() fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' }) act(() => { jest.advanceTimersByTime(CLOSE_DURATION) }) expect(screen.queryByTestId('modal-content')).not.toBeInTheDocument() }) test('closes modal on overlay click', () => { render( ) fireEvent.click(screen.getByText('Open Modal')) expect(screen.getByTestId('modal-content')).toBeInTheDocument() fireEvent.click(screen.getByTestId('overlay')) act(() => { jest.advanceTimersByTime(CLOSE_DURATION) }) expect(screen.queryByTestId('modal-content')).not.toBeInTheDocument() }) test('removes wrapper from stack after close, not just content', () => { render( ) fireEvent.click(screen.getByText('Open Modal')) expect(screen.getAllByTestId('modal-wrapper').length).toBe(1) fireEvent.click(screen.getByText('Close Modal')) act(() => { jest.advanceTimersByTime(CLOSE_DURATION) }) expect(screen.queryAllByTestId('modal-wrapper').length).toBe(0) }) test('closes stacked modals sequentially', () => { render( ) fireEvent.click(screen.getByText('open A')) fireEvent.click(screen.getByText('open B')) expect(screen.getAllByTestId('modal-wrapper').length).toBe(2) fireEvent.click(screen.getByText('close')) act(() => { jest.advanceTimersByTime(CLOSE_DURATION) }) expect(screen.queryByTestId('modal-B')).not.toBeInTheDocument() expect(screen.getByTestId('modal-A')).toBeInTheDocument() expect(screen.getAllByTestId('modal-wrapper').length).toBe(1) fireEvent.click(screen.getByText('close')) act(() => { jest.advanceTimersByTime(CLOSE_DURATION) }) expect(screen.queryByTestId('modal-A')).not.toBeInTheDocument() expect(screen.queryAllByTestId('modal-wrapper').length).toBe(0) }) test('rapid double-close targets the modal beneath one mid-transition', () => { render( ) fireEvent.click(screen.getByText('open A')) fireEvent.click(screen.getByText('open B')) fireEvent.click(screen.getByText('close')) fireEvent.click(screen.getByText('close')) act(() => { jest.advanceTimersByTime(CLOSE_DURATION) }) expect(screen.queryByTestId('modal-A')).not.toBeInTheDocument() expect(screen.queryByTestId('modal-B')).not.toBeInTheDocument() expect(screen.queryAllByTestId('modal-wrapper').length).toBe(0) }) test('renders sideDrawer modal correctly', () => { render( ) fireEvent.click(screen.getByText('Open Side Drawer Modal')) expect(screen.getByTestId('side-drawer')).toBeInTheDocument() expect(screen.getByTestId('side-drawer-content')).toBeInTheDocument() }) }) ================================================ FILE: src/context/RouterContext.d.ts ================================================ import type { ReactNode } from 'react' /** Shape of `data` passed to `navigate` and read from `useRouter().data` */ export type RouterData = { recordId?: string recordType?: string folder?: string vaultId?: string initialTab?: string } & Record export type RouterContextValue = { currentPage: string data: RouterData navigate: (page: string, data?: RouterData) => void } export declare function RouterProvider(props: { children: ReactNode }): ReactNode export declare function useRouter(): RouterContextValue ================================================ FILE: src/context/RouterContext.js ================================================ import { createContext, useState, useContext } from 'react' import { html } from 'htm/react' const RouterContext = createContext() /** * @typedef RouterProviderProps * @property {import('react').ReactNode} children React node to be rendered inside */ /** * @param {RouterProviderProps} props */ export const RouterProvider = ({ children }) => { const [state, setState] = useState({ currentPage: 'loading', data: { recordId: '', recordType: 'all' } }) const navigate = (page, data = {}) => { setState({ currentPage: page, data }) } return html` <${RouterContext.Provider} value=${{ ...state, navigate }}> ${children} ` } /** * @returns {{ * currentPage: string, * data: Object., * navigate: (currentPage: string, data: Object.) => void * }} */ export const useRouter = () => useContext(RouterContext) ================================================ FILE: src/context/RouterContext.test.js ================================================ import React from 'react' import { render, act } from '@testing-library/react' import { RouterProvider, useRouter } from './RouterContext' const TestComponent = () => { const { currentPage, data, navigate } = useRouter() return (
{currentPage}
{data.recordId}
{data.recordType}
) } describe('RouterContext', () => { test('should provide initial router state', () => { const { getByTestId } = render( ) expect(getByTestId('current-page').textContent).toBe('loading') expect(getByTestId('record-id').textContent).toBe('') expect(getByTestId('record-type').textContent).toBe('all') }) test('should navigate to a new page with data', () => { const { getByTestId } = render( ) expect(getByTestId('current-page').textContent).toBe('loading') act(() => { getByTestId('navigate-button').click() }) expect(getByTestId('current-page').textContent).toBe('test-page') expect(getByTestId('record-id').textContent).toBe('123') expect(getByTestId('record-type').textContent).toBe('password') }) test('should handle multiple navigation events', () => { const MultiNavComponent = () => { const { currentPage, navigate } = useRouter() return (
{currentPage}
) } const { getByTestId } = render( ) expect(getByTestId('current-page').textContent).toBe('loading') act(() => { getByTestId('nav1').click() }) expect(getByTestId('current-page').textContent).toBe('page1') act(() => { getByTestId('nav2').click() }) expect(getByTestId('current-page').textContent).toBe('page2') }) }) ================================================ FILE: src/context/ToastContext.js ================================================ import { createContext, useState, useContext } from 'react' import { html } from 'htm/react' import { Toasts } from '../components/Toasts' const ToastContext = createContext() /** * @param {{ * children: import('react').ReactNode * }} props */ export const ToastProvider = ({ children }) => { const [stack, setStack] = useState([]) const setToast = (data) => { setStack((prev) => [...prev, data]) setTimeout(() => { setStack((prev) => prev.slice(1)) }, 3000) } return html` <${ToastContext.Provider} value=${{ setToast }}> ${children} <${Toasts} toasts=${stack} /> ` } /** * @returns {{ * setToast: (data: { message: string, icon?: import('react').ElementType }) => void * }} */ export const useToast = () => useContext(ToastContext) ================================================ FILE: src/context/ToastContext.test.js ================================================ import React from 'react' import { render, act } from '@testing-library/react' import { ToastProvider, useToast } from './ToastContext' import { Toasts } from '../components/Toasts' jest.mock('../components/Toasts', () => ({ Toasts: jest.fn(() => null) })) jest.useFakeTimers() const TestComponent = ({ onToast }) => { const { setToast } = useToast() React.useEffect(() => { if (onToast) { onToast(setToast) } }, [onToast]) return null } describe('ToastContext', () => { beforeEach(() => { jest.clearAllMocks() jest.clearAllTimers() }) it('should provide toast context', () => { const mockSetToast = jest.fn() render( ) expect(mockSetToast).toHaveBeenCalled() expect(typeof mockSetToast.mock.calls[0][0]).toBe('function') }) it('should add toast to stack when setToast is called', () => { let setToastFn const toastData = { message: 'Test toast', icon: 'test-icon' } render( { setToastFn = fn }} /> ) act(() => { setToastFn(toastData) }) // Find the call with toasts const callsWithToasts = Toasts.mock.calls.filter( (call) => call[0].toasts.length > 0 ) expect(callsWithToasts.length).toBeGreaterThan(0) expect(callsWithToasts[callsWithToasts.length - 1][0]).toEqual( expect.objectContaining({ toasts: [toastData] }) ) }) it('should remove toast after timeout', () => { let setToastFn const toastData = { message: 'Test toast', icon: 'test-icon' } render( { setToastFn = fn }} /> ) act(() => { setToastFn(toastData) }) // Verify toast was added const callsWithToasts = Toasts.mock.calls.filter( (call) => call[0].toasts.length > 0 ) expect(callsWithToasts.length).toBeGreaterThan(0) expect(callsWithToasts[callsWithToasts.length - 1][0]).toEqual( expect.objectContaining({ toasts: [toastData] }) ) act(() => { jest.advanceTimersByTime(3000) }) // Find the last call with empty toasts const lastCall = Toasts.mock.calls[Toasts.mock.calls.length - 1] expect(lastCall[0].toasts).toEqual([]) }) it('should handle multiple toasts', () => { let setToastFn const toast1 = { message: 'Test toast 1', icon: 'icon-1' } const toast2 = { message: 'Test toast 2', icon: 'icon-2' } render( { setToastFn = fn }} /> ) act(() => { setToastFn(toast1) setToastFn(toast2) }) // Find the call with two toasts const callsWithTwoToasts = Toasts.mock.calls.filter( (call) => call[0].toasts.length === 2 ) expect(callsWithTwoToasts.length).toBeGreaterThan(0) expect(callsWithTwoToasts[callsWithTwoToasts.length - 1][0]).toEqual( expect.objectContaining({ toasts: [toast1, toast2] }) ) }) }) ================================================ FILE: src/context/UnsavedChangesContext.tsx ================================================ import React, { createContext, ReactNode, useCallback, useContext, useMemo, useRef } from 'react' export type UnsavedChangesGuard = { hasUnsavedChanges: boolean description: string save: () => Promise } type UnsavedChangesContextValue = { setGuard: (guard: UnsavedChangesGuard | null) => void getGuard: () => UnsavedChangesGuard | null } const UnsavedChangesContext = createContext( null ) type UnsavedChangesProviderProps = { children: ReactNode } export const UnsavedChangesProvider = ({ children }: UnsavedChangesProviderProps) => { const guardRef = useRef(null) const setGuard = useCallback((guard: UnsavedChangesGuard | null) => { guardRef.current = guard }, []) const getGuard = useCallback(() => guardRef.current, []) const value = useMemo(() => ({ setGuard, getGuard }), [setGuard, getGuard]) return ( {children} ) } export const useUnsavedChanges = () => { const ctx = useContext(UnsavedChangesContext) if (!ctx) { throw new Error( 'useUnsavedChanges must be used within an UnsavedChangesProvider' ) } return ctx } ================================================ FILE: src/electron/index.js ================================================ /** * Electron entry helpers: config and vault client when running inside Electron. * Use these when window.electronAPI is defined (Electron); otherwise use Pear + createOrGetPipe. */ import { createElectronVaultClientProxy } from './vaultClientProxy' let configPromise = null let vaultClientPromise = null /** * @returns {Promise<{ storage: string, key: string | null, upgrade: string | null, version: string | number, applink: string }>} */ export function getElectronConfig() { if (!configPromise && typeof window !== 'undefined' && window.electronAPI) { configPromise = window.electronAPI.getConfig() } return configPromise || Promise.resolve(null) } /** * @returns {Promise} */ export function getElectronVaultClient() { if ( !vaultClientPromise && typeof window !== 'undefined' && window.electronAPI ) { vaultClientPromise = Promise.resolve( createElectronVaultClientProxy(window.electronAPI) ) } return vaultClientPromise || Promise.resolve(null) } /** * Runtime update APIs (for usePearUpdate). */ export const electronRuntime = typeof window !== 'undefined' && window.electronAPI ? { onUpdating: (cb) => window.electronAPI.onRuntimeUpdating(cb), onUpdated: (cb) => window.electronAPI.onRuntimeUpdated(cb), applyUpdate: () => window.electronAPI.applyUpdate(), restart: () => window.electronAPI.restart(), checkUpdated: () => window.electronAPI.checkUpdated() } : null export function isElectron() { return typeof window !== 'undefined' && !!window.electronAPI } ================================================ FILE: src/electron/vaultClientProxy.js ================================================ /* eslint-disable no-underscore-dangle */ /** * Renderer-side proxy for PearpassVaultClient when running in Electron. * Forwards every method call to the main process via IPC; main process holds * the real client connected to the bare worklet. Buffer args must be sent as * { __base64: base64String }; Buffer return values come back as { __base64 }. */ import EventEmitter from 'events' function isBufferLike(value) { return ( value instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) ) } function toSerializableArg(value) { if (isBufferLike(value)) { const b = typeof Buffer !== 'undefined' ? Buffer.from(value) : new Uint8Array(value) const base64 = typeof b.toString === 'function' ? b.toString('base64') : btoa(String.fromCharCode(...new Uint8Array(b))) return { __base64: base64 } } if (value && typeof value === 'object' && !Array.isArray(value)) { const out = {} for (const k of Object.keys(value)) { out[k] = toSerializableArg(value[k]) } return out } if (Array.isArray(value)) { return value.map(toSerializableArg) } return value } function fromSerializableData(data) { if (data && typeof data === 'object' && data.__base64) { const bin = atob(data.__base64) const bytes = new Uint8Array(bin.length) for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) return typeof Buffer !== 'undefined' ? Buffer.from(bytes) : bytes } if (data && typeof data === 'object' && !Array.isArray(data)) { const out = {} for (const k of Object.keys(data)) { out[k] = fromSerializableData(data[k]) } return out } if (Array.isArray(data)) { return data.map(fromSerializableData) } return data } const VAULT_METHODS = [ 'setStoragePath', 'vaultsInit', 'vaultsGetStatus', 'vaultsGet', 'vaultsClose', 'vaultsAdd', 'activeVaultGetFile', 'activeVaultRemoveFile', 'vaultsList', 'activeVaultInit', 'activeVaultGetStatus', 'recordFailedMasterPassword', 'getMasterPasswordStatus', 'resetFailedAttempts', 'createMasterPassword', 'initWithPassword', 'updateMasterPassword', 'initWithCredentials', 'activeVaultClose', 'activeVaultAdd', 'activeVaultRemove', 'activeVaultList', 'activeVaultGet', 'activeVaultCreateInvite', 'activeVaultDeleteInvite', 'pairActiveVault', 'cancelPairActiveVault', 'initListener', 'getBlindMirrors', 'addBlindMirrors', 'removeBlindMirror', 'addDefaultBlindMirrors', 'removeAllBlindMirrors', 'encryptionInit', 'encryptExportData', 'encryptionGetStatus', 'encryptionGet', 'encryptionAdd', 'hashPassword', 'encryptVaultKeyWithHashedPassword', 'encryptVaultWithKey', 'getDecryptionKey', 'decryptVaultKey', 'encryptionClose', 'closeAllInstances', 'activeVaultAddFile', 'activeVaultGetFile', 'beginBackground', 'endBackground', 'generateOtpCodesByIds', 'generateHotpNext', 'addOtpToRecord', 'removeOtpFromRecord', 'fetchFavicon', 'decryptExportData' ] /** * Creates a proxy that implements the vault client interface over IPC. * Extends EventEmitter so removeAllListeners, removeListener, on, once, etc. are always available. * @param {{ vaultInvoke: (method: string, args: any[]) => Promise<{ok: boolean, data?: any, error?: string}>, vaultOnUpdate: (cb: () => void) => () => void }} api * @returns {import('@tetherto/pearpass-lib-vault-core').PearpassVaultClient} */ export function createElectronVaultClientProxy(api) { class ElectronVaultClientProxy extends EventEmitter { constructor() { super() api.vaultOnUpdate(() => this.emit('update')) for (const method of VAULT_METHODS) { this[method] = async (...args) => { const serialized = args.map(toSerializableArg) const result = await api.vaultInvoke(method, serialized) if (!result.ok) { const err = new Error(result.error || 'Vault request failed') if (result.code) err.code = result.code throw err } return fromSerializableData(result.data) } } } } return new ElectronVaultClientProxy() } ================================================ FILE: src/electron/vaultClientProxy.test.js ================================================ /* eslint-disable no-underscore-dangle */ import { createElectronVaultClientProxy } from './vaultClientProxy' // Ensure btoa/atob exist in the Jest environment if (typeof globalThis.btoa === 'undefined') { globalThis.btoa = (str) => Buffer.from(str, 'binary').toString('base64') } if (typeof globalThis.atob === 'undefined') { globalThis.atob = (b64) => Buffer.from(b64, 'base64').toString('binary') } describe('createElectronVaultClientProxy', () => { let vaultInvoke let vaultOnUpdate let unsubscribe let updateCallback let client beforeEach(() => { vaultInvoke = jest.fn().mockResolvedValue({ ok: true, data: null }) unsubscribe = jest.fn() updateCallback = null vaultOnUpdate = jest.fn((cb) => { updateCallback = cb return unsubscribe }) const api = { vaultInvoke, vaultOnUpdate } client = createElectronVaultClientProxy(api) }) test('subscribes to vaultOnUpdate and re-emits update events', () => { const handler = jest.fn() client.on('update', handler) expect(vaultOnUpdate).toHaveBeenCalledTimes(1) expect(typeof updateCallback).toBe('function') // Simulate an update from the main process updateCallback() expect(handler).toHaveBeenCalledTimes(1) }) test('creates proxy methods that forward to vaultInvoke', async () => { const args = [{ foo: 'bar' }, 42] await client.vaultsInit(...args) expect(vaultInvoke).toHaveBeenCalledTimes(1) const [method, forwardedArgs] = vaultInvoke.mock.calls[0] expect(method).toBe('vaultsInit') expect(forwardedArgs).toHaveLength(args.length) expect(forwardedArgs[0]).toEqual({ foo: 'bar' }) expect(forwardedArgs[1]).toBe(42) }) test('serializes buffer-like arguments to base64 objects', async () => { const bytes = Uint8Array.from([1, 2, 3, 4]) await client.hashPassword(bytes) expect(vaultInvoke).toHaveBeenCalledTimes(1) const [method, forwardedArgs] = vaultInvoke.mock.calls[0] expect(method).toBe('hashPassword') const serialized = forwardedArgs[0] expect(serialized).toHaveProperty('__base64') const decoded = Buffer.from(serialized.__base64, 'base64') expect(Array.from(decoded)).toEqual([1, 2, 3, 4]) }) test('serializes nested objects and arrays containing buffer-like values', async () => { const payload = { meta: 'test', data: [Uint8Array.from([9, 8, 7])] } await client.encryptVaultWithKey(payload) const [method, forwardedArgs] = vaultInvoke.mock.calls[0] expect(method).toBe('encryptVaultWithKey') const serialized = forwardedArgs[0] expect(serialized.meta).toBe('test') expect(Array.isArray(serialized.data)).toBe(true) expect(serialized.data[0]).toHaveProperty('__base64') }) test('deserializes base64 data from vaultInvoke into Buffer/Uint8Array', async () => { const bytes = Uint8Array.from([5, 6, 7]) const base64 = Buffer.from(bytes).toString('base64') vaultInvoke.mockResolvedValueOnce({ ok: true, data: { __base64: base64 } }) const result = await client.getDecryptionKey('some-id') expect(vaultInvoke).toHaveBeenCalledWith( 'getDecryptionKey', expect.any(Array) ) expect(result instanceof Uint8Array || Buffer.isBuffer(result)).toBe(true) expect(Array.from(result)).toEqual([5, 6, 7]) }) test('throws an Error when vaultInvoke indicates failure and preserves error code', async () => { vaultInvoke.mockResolvedValueOnce({ ok: false, error: 'Something went wrong', code: 'E_VAULT' }) await expect(client.vaultsGetStatus()).rejects.toMatchObject({ message: 'Something went wrong', code: 'E_VAULT' }) }) }) ================================================ FILE: src/hooks/__tests__/useTranslation.test.js ================================================ import { jest } from '@jest/globals' import { useLingui } from '@lingui/react' import { renderHook, act } from '@testing-library/react' jest.mock('@lingui/react', () => ({ useLingui: jest.fn() })) import { useTranslation } from '../useTranslation' describe('useTranslation', () => { beforeEach(() => { jest.clearAllMocks() }) test('returns an object with a t function', () => { const translateMock = jest.fn((key) => `translated:${key}`) useLingui.mockReturnValue({ i18n: { _: translateMock } }) const { result } = renderHook(() => useTranslation()) expect(result.current).toHaveProperty('t') expect(typeof result.current.t).toBe('function') }) test('t calls i18n._ with the provided key and returns translation', () => { const translateMock = jest.fn((key) => `translated:${key}`) useLingui.mockReturnValue({ i18n: { _: translateMock } }) const { result } = renderHook(() => useTranslation()) let output act(() => { output = result.current.t('hello.world') }) expect(translateMock).toHaveBeenCalledTimes(1) expect(translateMock).toHaveBeenCalledWith('hello.world') expect(output).toBe('translated:hello.world') }) test('t passes interpolation values as the second argument to i18n._', () => { const translateMock = jest.fn((key, values) => values !== null ? `${key}|${JSON.stringify(values)}` : key ) useLingui.mockReturnValue({ i18n: { _: translateMock } }) const { result } = renderHook(() => useTranslation()) let output act(() => { output = result.current.t('and {count} more', { count: 2 }) }) expect(translateMock).toHaveBeenCalledTimes(1) expect(translateMock).toHaveBeenCalledWith('and {count} more', { count: 2 }) expect(output).toBe('and {count} more|{"count":2}') }) test('t reference is stable when i18n instance does not change', () => { const translateMock = jest.fn((key) => key) const i18nInstance = { _: translateMock } useLingui.mockReturnValue({ i18n: i18nInstance }) const { result, rerender } = renderHook(() => useTranslation()) const firstT = result.current.t rerender() const secondT = result.current.t expect(secondT).toBe(firstT) }) test('t reference updates when i18n instance changes', () => { const translateMock1 = jest.fn((key) => key) const translateMock2 = jest.fn((key) => key) const i18n1 = { _: translateMock1 } const i18n2 = { _: translateMock2 } useLingui .mockReturnValueOnce({ i18n: i18n1 }) .mockReturnValue({ i18n: i18n2 }) const { result, rerender } = renderHook(() => useTranslation()) const firstT = result.current.t rerender() const secondT = result.current.t expect(secondT).not.toBe(firstT) }) }) ================================================ FILE: src/hooks/useAnimatedVisibility.js ================================================ import { useEffect, useState } from 'react' import { BASE_TRANSITION_DURATION } from '../constants/transitions' const SAFETY_BUFFER = 100 /** * @param {{ * isOpen: boolean * transitionDuration?: number * nodeRef?: import('react').RefObject * propertyName?: string * }} params * @returns {{ * isShown: boolean * isRendered: boolean * }} */ export const useAnimatedVisibility = ({ isOpen, transitionDuration = BASE_TRANSITION_DURATION, nodeRef, propertyName }) => { const [isShown, setIsShown] = useState(false) useEffect(() => { if (isOpen) { setIsShown(true) return } const node = nodeRef?.current ?? null const useTransitionEnd = !!(node && propertyName) let done = false const finish = () => { if (done) return done = true setIsShown(false) } const handleTransitionEnd = (event) => { if (event.target !== node) return if (event.propertyName !== propertyName) return finish() } if (useTransitionEnd) { node.addEventListener('transitionend', handleTransitionEnd) } const fallbackDelay = useTransitionEnd ? transitionDuration + SAFETY_BUFFER : transitionDuration const fallbackTimer = setTimeout(finish, fallbackDelay) return () => { if (useTransitionEnd) { node.removeEventListener('transitionend', handleTransitionEnd) } clearTimeout(fallbackTimer) } }, [isOpen, transitionDuration, nodeRef, propertyName]) return { isShown: isShown && isOpen, isRendered: isShown || isOpen } } ================================================ FILE: src/hooks/useAnimatedVisibility.test.js ================================================ import { useRef } from 'react' import { renderHook, act } from '@testing-library/react' import { useAnimatedVisibility } from './useAnimatedVisibility' jest.useFakeTimers() const SAFETY_BUFFER = 100 describe('useAnimatedVisibility', () => { test('initial state when isOpen is false', () => { const { result } = renderHook(() => useAnimatedVisibility({ isOpen: false }) ) expect(result.current.isShown).toBe(false) expect(result.current.isRendered).toBe(false) }) test('initial state when isOpen is true', () => { const { result } = renderHook(() => useAnimatedVisibility({ isOpen: true })) expect(result.current.isShown).toBe(true) expect(result.current.isRendered).toBe(true) }) test('transitions from closed to open', () => { const { result, rerender } = renderHook( (props) => useAnimatedVisibility(props), { initialProps: { isOpen: false } } ) expect(result.current.isShown).toBe(false) expect(result.current.isRendered).toBe(false) rerender({ isOpen: true }) expect(result.current.isShown).toBe(true) expect(result.current.isRendered).toBe(true) }) test('transitions from open to closed', () => { const { result, rerender } = renderHook( (props) => useAnimatedVisibility(props), { initialProps: { isOpen: true, transitionDuration: 300 } } ) expect(result.current.isShown).toBe(true) expect(result.current.isRendered).toBe(true) rerender({ isOpen: false, transitionDuration: 300 }) expect(result.current.isShown).toBe(false) expect(result.current.isRendered).toBe(true) act(() => { jest.advanceTimersByTime(300) }) expect(result.current.isShown).toBe(false) expect(result.current.isRendered).toBe(false) }) test('uses custom transition duration', () => { const customDuration = 500 const { result, rerender } = renderHook( (props) => useAnimatedVisibility(props), { initialProps: { isOpen: true, transitionDuration: customDuration } } ) rerender({ isOpen: false, transitionDuration: customDuration }) expect(result.current.isRendered).toBe(true) act(() => { jest.advanceTimersByTime(customDuration - 10) }) expect(result.current.isRendered).toBe(true) act(() => { jest.advanceTimersByTime(10) }) expect(result.current.isRendered).toBe(false) }) describe('with nodeRef + propertyName', () => { const renderWithNode = (node, initialProps = {}) => renderHook( (props) => { const ref = useRef(node) return useAnimatedVisibility({ ...props, nodeRef: ref, propertyName: 'opacity' }) }, { initialProps: { isOpen: true, transitionDuration: 300, ...initialProps } } ) test('finalizes on matching transitionend before the fallback timer', () => { const node = document.createElement('div') const { result, rerender } = renderWithNode(node) rerender({ isOpen: false, transitionDuration: 300 }) expect(result.current.isRendered).toBe(true) act(() => { const event = new Event('transitionend') Object.defineProperty(event, 'propertyName', { value: 'opacity' }) Object.defineProperty(event, 'target', { value: node }) node.dispatchEvent(event) }) expect(result.current.isRendered).toBe(false) }) test('ignores transitionend for a different property', () => { const node = document.createElement('div') const { result, rerender } = renderWithNode(node) rerender({ isOpen: false, transitionDuration: 300 }) act(() => { const event = new Event('transitionend') Object.defineProperty(event, 'propertyName', { value: 'transform' }) Object.defineProperty(event, 'target', { value: node }) node.dispatchEvent(event) }) // Still rendered: the transform transitionend doesn't match opacity. expect(result.current.isRendered).toBe(true) }) test('ignores transitionend bubbling up from a descendant', () => { const node = document.createElement('div') const child = document.createElement('span') node.appendChild(child) const { result, rerender } = renderWithNode(node) rerender({ isOpen: false, transitionDuration: 300 }) act(() => { const event = new Event('transitionend', { bubbles: true }) Object.defineProperty(event, 'propertyName', { value: 'opacity' }) Object.defineProperty(event, 'target', { value: child }) child.dispatchEvent(event) }) expect(result.current.isRendered).toBe(true) }) test('falls back after transitionDuration + SAFETY_BUFFER if no event fires', () => { const node = document.createElement('div') const { result, rerender } = renderWithNode(node) rerender({ isOpen: false, transitionDuration: 300 }) act(() => { jest.advanceTimersByTime(300 + SAFETY_BUFFER - 1) }) expect(result.current.isRendered).toBe(true) act(() => { jest.advanceTimersByTime(1) }) expect(result.current.isRendered).toBe(false) }) test('does not double-fire when transitionend and fallback both occur', () => { const node = document.createElement('div') const { result, rerender } = renderWithNode(node) rerender({ isOpen: false, transitionDuration: 300 }) act(() => { const event = new Event('transitionend') Object.defineProperty(event, 'propertyName', { value: 'opacity' }) Object.defineProperty(event, 'target', { value: node }) node.dispatchEvent(event) }) expect(result.current.isRendered).toBe(false) // Fallback timer firing after the listener already finalized must be a // no-op — not flip state back to a stale value. act(() => { jest.advanceTimersByTime(SAFETY_BUFFER + 300) }) expect(result.current.isRendered).toBe(false) }) test('removes the transitionend listener on unmount', () => { const node = document.createElement('div') const removeSpy = jest.spyOn(node, 'removeEventListener') const { rerender, unmount } = renderWithNode(node) rerender({ isOpen: false, transitionDuration: 300 }) unmount() expect(removeSpy).toHaveBeenCalledWith( 'transitionend', expect.any(Function) ) }) }) }) ================================================ FILE: src/hooks/useAutoLockPreferences.test.js ================================================ // Mock the constants module before imports const DEFAULT_AUTO_LOCK_TIMEOUT = 300000 jest.mock('@tetherto/pearpass-lib-constants', () => ({ DEFAULT_AUTO_LOCK_TIMEOUT: 300000, AUTO_LOCK_ENABLED: true })) import { renderHook, act } from '@testing-library/react' import { useAutoLockPreferences, getAutoLockTimeoutMs, isAutoLockEnabled, AutoLockProvider } from './useAutoLockPreferences' import { LOCAL_STORAGE_KEYS } from '../constants/localStorage' describe('useAutoLockPreferences', () => { beforeEach(() => { localStorage.clear() jest.clearAllMocks() }) describe('initial state', () => { it('should default isAutoLockEnabled to true when localStorage is empty', () => { const { result } = renderHook(() => useAutoLockPreferences(), { wrapper: AutoLockProvider }) expect(result.current.isAutoLockEnabled).toBe(true) }) it('should default timeoutMs to DEFAULT_AUTO_LOCK_TIMEOUT when localStorage is empty', () => { const { result } = renderHook(() => useAutoLockPreferences(), { wrapper: AutoLockProvider }) expect(result.current.timeoutMs).toBe(DEFAULT_AUTO_LOCK_TIMEOUT) }) it('should return false for isAutoLockEnabled when localStorage is set to "false"', () => { localStorage.setItem(LOCAL_STORAGE_KEYS.AUTO_LOCK_ENABLED, 'false') const { result } = renderHook(() => useAutoLockPreferences(), { wrapper: AutoLockProvider }) expect(result.current.isAutoLockEnabled).toBe(false) }) it('should return stored timeoutMs from localStorage', () => { const customTimeout = 60000 localStorage.setItem( LOCAL_STORAGE_KEYS.AUTO_LOCK_TIMEOUT_MS, String(customTimeout) ) const { result } = renderHook(() => useAutoLockPreferences(), { wrapper: AutoLockProvider }) expect(result.current.timeoutMs).toBe(customTimeout) }) }) describe('setAutoLockEnabled', () => { it('should set isAutoLockEnabled to false and update localStorage', () => { const { result } = renderHook(() => useAutoLockPreferences(), { wrapper: AutoLockProvider }) act(() => { result.current.setAutoLockEnabled(false) }) expect(result.current.isAutoLockEnabled).toBe(false) expect(localStorage.getItem(LOCAL_STORAGE_KEYS.AUTO_LOCK_ENABLED)).toBe( 'false' ) }) it('should set isAutoLockEnabled to true and remove localStorage key', () => { localStorage.setItem(LOCAL_STORAGE_KEYS.AUTO_LOCK_ENABLED, 'false') const { result } = renderHook(() => useAutoLockPreferences(), { wrapper: AutoLockProvider }) act(() => { result.current.setAutoLockEnabled(true) }) expect(result.current.isAutoLockEnabled).toBe(true) expect(localStorage.getItem(LOCAL_STORAGE_KEYS.AUTO_LOCK_ENABLED)).toBe( null ) }) it('should dispatch auto-lock-settings-changed event when enabling', () => { const dispatchEventSpy = jest.spyOn(window, 'dispatchEvent') const { result } = renderHook(() => useAutoLockPreferences(), { wrapper: AutoLockProvider }) act(() => { result.current.setAutoLockEnabled(true) }) expect(dispatchEventSpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'auto-lock-settings-changed' }) ) }) it('should dispatch auto-lock-settings-changed event when disabling', () => { const dispatchEventSpy = jest.spyOn(window, 'dispatchEvent') const { result } = renderHook(() => useAutoLockPreferences(), { wrapper: AutoLockProvider }) act(() => { result.current.setAutoLockEnabled(false) }) expect(dispatchEventSpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'auto-lock-settings-changed' }) ) }) }) describe('setTimeoutMs', () => { it('should update timeoutMs state and localStorage', () => { const { result } = renderHook(() => useAutoLockPreferences(), { wrapper: AutoLockProvider }) const newTimeout = 120000 act(() => { result.current.setTimeoutMs(newTimeout) }) expect(result.current.timeoutMs).toBe(newTimeout) expect( localStorage.getItem(LOCAL_STORAGE_KEYS.AUTO_LOCK_TIMEOUT_MS) ).toBe(String(newTimeout)) }) it('supports null timeout and stores "null"', () => { const { result } = renderHook(() => useAutoLockPreferences(), { wrapper: AutoLockProvider }) act(() => { result.current.setTimeoutMs(null) }) expect(result.current.timeoutMs).toBeNull() expect( localStorage.getItem(LOCAL_STORAGE_KEYS.AUTO_LOCK_TIMEOUT_MS) ).toBe('null') }) it('should dispatch auto-lock-settings-changed event', () => { const dispatchEventSpy = jest.spyOn(window, 'dispatchEvent') const { result } = renderHook(() => useAutoLockPreferences(), { wrapper: AutoLockProvider }) act(() => { result.current.setTimeoutMs(60000) }) expect(dispatchEventSpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'auto-lock-settings-changed' }) ) }) }) }) describe('getAutoLockTimeoutMs', () => { beforeEach(() => { localStorage.clear() }) it('should return DEFAULT_AUTO_LOCK_TIMEOUT when localStorage is empty', () => { expect(getAutoLockTimeoutMs()).toBe(DEFAULT_AUTO_LOCK_TIMEOUT) }) it('should return stored timeout from localStorage', () => { const customTimeout = 300000 localStorage.setItem( LOCAL_STORAGE_KEYS.AUTO_LOCK_TIMEOUT_MS, String(customTimeout) ) expect(getAutoLockTimeoutMs()).toBe(customTimeout) }) it('returns null when localStorage value is "null"', () => { localStorage.setItem(LOCAL_STORAGE_KEYS.AUTO_LOCK_TIMEOUT_MS, 'null') expect(getAutoLockTimeoutMs()).toBeNull() }) }) describe('isAutoLockEnabled', () => { beforeEach(() => { localStorage.clear() }) it('should return true when localStorage is empty', () => { expect(isAutoLockEnabled()).toBe(true) }) it('should return false when localStorage is set to "false"', () => { localStorage.setItem(LOCAL_STORAGE_KEYS.AUTO_LOCK_ENABLED, 'false') expect(isAutoLockEnabled()).toBe(false) }) it('should return true for any value other than "false"', () => { localStorage.setItem(LOCAL_STORAGE_KEYS.AUTO_LOCK_ENABLED, 'true') expect(isAutoLockEnabled()).toBe(true) localStorage.setItem(LOCAL_STORAGE_KEYS.AUTO_LOCK_ENABLED, 'random') expect(isAutoLockEnabled()).toBe(true) }) }) describe('storage sync events', () => { beforeEach(() => { localStorage.clear() jest.clearAllMocks() }) it('updates enabled state on apply-auto-lock-enabled event', () => { const { result } = renderHook(() => useAutoLockPreferences(), { wrapper: AutoLockProvider }) expect(result.current.isAutoLockEnabled).toBe(true) act(() => { localStorage.setItem(LOCAL_STORAGE_KEYS.AUTO_LOCK_ENABLED, 'false') window.dispatchEvent(new Event('apply-auto-lock-enabled')) }) expect(result.current.isAutoLockEnabled).toBe(false) }) it('updates timeout state on apply-auto-lock-timeout event', () => { const { result } = renderHook(() => useAutoLockPreferences(), { wrapper: AutoLockProvider }) const newTimeout = 1234 act(() => { localStorage.setItem( LOCAL_STORAGE_KEYS.AUTO_LOCK_TIMEOUT_MS, String(newTimeout) ) window.dispatchEvent(new Event('apply-auto-lock-timeout')) }) expect(result.current.timeoutMs).toBe(newTimeout) }) }) ================================================ FILE: src/hooks/useAutoLockPreferences.ts ================================================ import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { DEFAULT_AUTO_LOCK_TIMEOUT, AUTO_LOCK_ENABLED } from '@tetherto/pearpass-lib-constants' import { LOCAL_STORAGE_KEYS } from '../constants/localStorage' import { applyAutoLockEnabled, applyAutoLockTimeout } from '../utils/autoLock' type AutoLockContextValue = { shouldBypassAutoLock: boolean setShouldBypassAutoLock: (value: boolean) => void isAutoLockEnabled: boolean timeoutMs: number | null setAutoLockEnabled: (enabled: boolean) => void setTimeoutMs: (ms: number | null) => void } const AutoLockContext = createContext({ shouldBypassAutoLock: false, setShouldBypassAutoLock: () => { }, isAutoLockEnabled: true, timeoutMs: DEFAULT_AUTO_LOCK_TIMEOUT, setAutoLockEnabled: () => { }, setTimeoutMs: () => { } }) export const AutoLockProvider = ({ children }: { children: React.ReactNode }) => { const [shouldBypassAutoLock, setShouldBypassAutoLock] = useState(false) const [autoLockEnabled, setAutoLockEnabledState] = useState(() => { if (!AUTO_LOCK_ENABLED) { return false } const stored = localStorage.getItem(LOCAL_STORAGE_KEYS.AUTO_LOCK_ENABLED) return stored !== 'false' }) const [timeoutMs, setTimeoutMsState] = useState(() => { if (!AUTO_LOCK_ENABLED) { return DEFAULT_AUTO_LOCK_TIMEOUT } const stored = localStorage.getItem(LOCAL_STORAGE_KEYS.AUTO_LOCK_TIMEOUT_MS) if (stored === 'null') { return null } return stored ? Number(stored) : DEFAULT_AUTO_LOCK_TIMEOUT }) useEffect(() => { const syncFromStorage = () => { setAutoLockEnabledState(isAutoLockEnabled()) } window.addEventListener('apply-auto-lock-enabled', syncFromStorage) return () => { window.removeEventListener( 'apply-auto-lock-enabled', syncFromStorage ) } }, []) useEffect(() => { const syncFromStorage = () => { setTimeoutMsState(getAutoLockTimeoutMs()) } window.addEventListener('apply-auto-lock-timeout', syncFromStorage) return () => { window.removeEventListener( 'apply-auto-lock-timeout', syncFromStorage ) } }, []) const setAutoLockEnabled = useCallback((enabled: boolean) => { applyAutoLockEnabled(enabled) setAutoLockEnabledState(enabled) }, []) const setTimeoutMs = useCallback((ms: number | null) => { applyAutoLockTimeout(ms) setTimeoutMsState(ms) }, []) const value = useMemo( () => ({ shouldBypassAutoLock, setShouldBypassAutoLock, isAutoLockEnabled: autoLockEnabled, timeoutMs, setAutoLockEnabled, setTimeoutMs }), [shouldBypassAutoLock, autoLockEnabled, timeoutMs, setAutoLockEnabled, setTimeoutMs] ) return createElement(AutoLockContext.Provider, { value }, children) } export const useAutoLockPreferences = () => useContext(AutoLockContext) export function getAutoLockTimeoutMs(): number | null { if (!AUTO_LOCK_ENABLED) { return DEFAULT_AUTO_LOCK_TIMEOUT } const stored = localStorage.getItem(LOCAL_STORAGE_KEYS.AUTO_LOCK_TIMEOUT_MS) if (stored === 'null') { return null } return stored ? Number(stored) : DEFAULT_AUTO_LOCK_TIMEOUT } export function isAutoLockEnabled(): boolean { const stored = localStorage.getItem(LOCAL_STORAGE_KEYS.AUTO_LOCK_ENABLED) return stored !== 'false' } ================================================ FILE: src/hooks/useConnectExtension.js ================================================ import React, { useState } from 'react' import { CopyIcon } from '../lib-react-components' import { useCopyToClipboard } from './useCopyToClipboard.electron' import { useTranslation } from './useTranslation' import { COPY_FEEDBACK_DISPLAY_TIME } from '../constants/timeConstants' import { ExtensionPairingModalContent } from '../containers/Modal/ExtensionPairingModalContent' import { ExtensionPairingModalContentV2 } from '../containers/Modal/ExtensionPairingModalContent/ExtensionPairingModalContentV2' import { useGlobalLoading } from '../context/LoadingContext.js' import { useModal } from '../context/ModalContext' import { useToast } from '../context/ToastContext' import { getElectronConfig } from '../electron' import { createOrGetPearpassClient } from '../services/createOrGetPearpassClient' import { isNativeMessagingIPCRunning, startNativeMessagingIPC, stopNativeMessagingIPC } from '../services/nativeMessagingIPCServer' import { getNativeMessagingEnabled, setNativeMessagingEnabled } from '../services/nativeMessagingPreferences' import { getFingerprint, getOrCreateIdentity, getPairingToken, resetIdentity } from '../services/security/appIdentity' import { clearAllSessions } from '../services/security/sessionStore.js' import { isV2 } from '../utils/designVersion' import { setupNativeMessaging, killNativeMessagingHostProcesses, cleanupNativeMessaging } from '../utils/nativeMessagingSetup' export const useConnectExtension = () => { const { setModal } = useModal() const { setToast } = useToast() const { t } = useTranslation() const { copyToClipboard } = useCopyToClipboard({ onCopy: () => setToast({ message: t('Copied!'), icon: CopyIcon }) }) const [isBrowserExtensionEnabled, setIsBrowserExtensionEnabled] = useState( getNativeMessagingEnabled() && isNativeMessagingIPCRunning() ) const handleSetupExtension = async () => { // Setup native messaging for the extension const config = await getElectronConfig() const result = await setupNativeMessaging({ userDataPath: config.userDataPath, execPath: config.execPath, bridgePath: config.bridgePath }) if (result.success) { // Kill any existing native host so Chrome respawns it and re-reads the manifest await killNativeMessagingHostProcesses() // Start native messaging IPC server const client = createOrGetPearpassClient() await startNativeMessagingIPC(client) setNativeMessagingEnabled(true) setIsBrowserExtensionEnabled(true) setToast({ message: t('PearPass ready for extension connection.') }) } else { const errorMessage = result.message || t('Setup failed') throw new Error(errorMessage) } } const handleStopNativeMessaging = async () => { clearAllSessions() await stopNativeMessagingIPC() // Ensure any running native host is terminated so it cannot continue talking await killNativeMessagingHostProcesses() // Clean unused manifest file and make sure browser cannot respawn the host while off await cleanupNativeMessaging().catch(() => {}) resetState() setNativeMessagingEnabled(false) // Reset identity to force re-pairing // This prevents extensions from reconnecting without a new pairing token const client = createOrGetPearpassClient() await resetIdentity(client) } // Pairing info state const [isExtensionConnectionLoading, setIsExtensionConnectionLoading] = useState(false) useGlobalLoading({ isLoading: isExtensionConnectionLoading }) const [copyFeedback, setCopyFeedback] = useState('') const resetState = () => { setIsBrowserExtensionEnabled(false) setIsExtensionConnectionLoading(false) setCopyFeedback('') } const loadPairingInfo = async (reset = false) => { const client = createOrGetPearpassClient() const id = reset ? // Reset pairing - generate new identity and clear sessions await resetIdentity(client) : // Just load existing identity await getOrCreateIdentity(client) // Mark pairing as approved for this identity so that nmBeginHandshake is allowed await client .encryptionAdd('nm.identity.pairingApproved', 'true') .catch(() => {}) const pairingToken = await getPairingToken(client, id.ed25519PublicKey) const fingerprint = getFingerprint(id.ed25519PublicKey) const result = { pairingToken, fingerprint, tokenCreationDate: id.creationDate } if (reset) { // Show feedback when new token is generated setCopyFeedback(t('New pairing token generated!')) setTimeout(() => setCopyFeedback(''), COPY_FEEDBACK_DISPLAY_TIME) } return result } const toggleBrowserExtension = async (isOn) => { if (isOn) { setIsExtensionConnectionLoading(true) return handleSetupExtension() .then(loadPairingInfo) .then(({ pairingToken, fingerprint, tokenCreationDate }) => { setModal( isV2() ? ( copyToClipboard(pairingToken)} pairingToken={pairingToken} loadingPairing={isExtensionConnectionLoading} /> ) : ( copyToClipboard(pairingToken)} pairingToken={pairingToken} loadingPairing={isExtensionConnectionLoading} copyFeedback={copyFeedback} tokenCreationDate={tokenCreationDate} fingerprint={fingerprint} /> ), { replace: true } ) }) .catch((error) => { setToast({ message: t('Error: ') + error.message }) }) .finally(() => { setIsExtensionConnectionLoading(false) }) } return handleStopNativeMessaging() } return { toggleBrowserExtension, isBrowserExtensionEnabled } } ================================================ FILE: src/hooks/useConnectExtension.test.js ================================================ jest.mock('sodium-native', () => ({ crypto_sign_keypair: jest.fn(), crypto_sign_ed25519_pk_to_curve25519: jest.fn(), crypto_sign_ed25519_sk_to_curve25519: jest.fn(), crypto_kx_keypair: jest.fn(), crypto_kx_server_session_keys: jest.fn(), crypto_kx_client_session_keys: jest.fn(), crypto_secretbox_easy: jest.fn(), crypto_secretbox_open_easy: jest.fn(), randombytes_buf: jest.fn(), sodium_malloc: jest.fn((size) => Buffer.alloc(size)), crypto_sign_PUBLICKEYBYTES: 32, crypto_sign_SECRETKEYBYTES: 64, crypto_kx_PUBLICKEYBYTES: 32, crypto_kx_SECRETKEYBYTES: 32, crypto_kx_SESSIONKEYBYTES: 32, crypto_secretbox_NONCEBYTES: 24, crypto_secretbox_MACBYTES: 16 })) jest.mock( '../containers/Modal/ExtensionPairingModalContent/ExtensionPairingModalContentV2', () => ({ ExtensionPairingModalContentV2: () => null }) ) jest.mock('../containers/Modal/ExtensionPairingModalContent', () => ({ ExtensionPairingModalContent: () => null })) import { act, renderHook, waitFor } from '@testing-library/react' import { useConnectExtension } from './useConnectExtension' import { createOrGetPearpassClient } from '../services/createOrGetPearpassClient' import { isNativeMessagingIPCRunning, startNativeMessagingIPC, stopNativeMessagingIPC } from '../services/nativeMessagingIPCServer' import { getNativeMessagingEnabled, setNativeMessagingEnabled } from '../services/nativeMessagingPreferences' import { getFingerprint, getOrCreateIdentity, getPairingToken } from '../services/security/appIdentity' import { killNativeMessagingHostProcesses, setupNativeMessaging } from '../utils/nativeMessagingSetup' const mockSetModal = jest.fn() const mockSetToast = jest.fn() jest.mock('../context/ModalContext', () => ({ useModal: () => ({ setModal: mockSetModal }) })) jest.mock('../context/ToastContext', () => ({ useToast: () => ({ setToast: mockSetToast }) })) jest.mock('../context/LoadingContext', () => ({ useGlobalLoading: jest.fn() })) jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (msg) => msg } }) })) jest.mock('../services/createOrGetPearpassClient', () => ({ createOrGetPearpassClient: jest.fn() })) jest.mock('../services/nativeMessagingIPCServer', () => ({ isNativeMessagingIPCRunning: jest.fn(), startNativeMessagingIPC: jest.fn(), stopNativeMessagingIPC: jest.fn() })) jest.mock('../services/nativeMessagingPreferences', () => ({ getNativeMessagingEnabled: jest.fn(), setNativeMessagingEnabled: jest.fn() })) jest.mock('../services/security/appIdentity', () => ({ getFingerprint: jest.fn(), getOrCreateIdentity: jest.fn(), getPairingToken: jest.fn(), resetIdentity: jest.fn() })) jest.mock('../utils/nativeMessagingSetup', () => ({ setupNativeMessaging: jest.fn(), cleanupNativeMessaging: jest.fn().mockResolvedValue(), killNativeMessagingHostProcesses: jest.fn().mockResolvedValue() })) jest.mock('../electron', () => ({ getElectronConfig: jest.fn().mockResolvedValue({ userDataPath: '/mock/user/data', execPath: '/mock/exec/path', bridgePath: '/mock/bridge/path' }) })) describe('useConnectExtension', () => { beforeEach(() => { jest.clearAllMocks() }) it('initializes extension state if enabled and running', () => { getNativeMessagingEnabled.mockReturnValue(true) isNativeMessagingIPCRunning.mockReturnValue(true) const { result } = renderHook(() => useConnectExtension()) expect(result.current.isBrowserExtensionEnabled).toBe(true) }) it('does not enable if not running or not enabled', () => { getNativeMessagingEnabled.mockReturnValue(false) isNativeMessagingIPCRunning.mockReturnValue(false) const { result } = renderHook(() => useConnectExtension()) expect(result.current.isBrowserExtensionEnabled).toBe(false) }) it('connects extension successfully via toggleBrowserExtension', async () => { const fakeIdentity = { ed25519PublicKey: 'pubkey', creationDate: '2023-01-01' } setupNativeMessaging.mockResolvedValue({ success: true }) startNativeMessagingIPC.mockResolvedValue() killNativeMessagingHostProcesses.mockResolvedValue() createOrGetPearpassClient.mockReturnValue({ encryptionAdd: jest.fn().mockResolvedValue(undefined) }) getOrCreateIdentity.mockResolvedValue(fakeIdentity) getPairingToken.mockResolvedValue('PAIRCODE-ABCD') getFingerprint.mockReturnValue('ABCD1234') const { result } = renderHook(() => useConnectExtension()) await act(async () => { await result.current.toggleBrowserExtension(true) }) expect(setupNativeMessaging).toHaveBeenCalled() expect(killNativeMessagingHostProcesses).toHaveBeenCalled() expect(startNativeMessagingIPC).toHaveBeenCalled() expect(setNativeMessagingEnabled).toHaveBeenCalledWith(true) expect(mockSetModal).toHaveBeenCalled() }) it('handles setup failure gracefully via toggleBrowserExtension', async () => { setupNativeMessaging.mockResolvedValue({ success: false, message: 'fail' }) createOrGetPearpassClient.mockReturnValue({}) const { result } = renderHook(() => useConnectExtension()) await act(async () => { await result.current.toggleBrowserExtension(true) }) expect(setupNativeMessaging).toHaveBeenCalled() expect(startNativeMessagingIPC).not.toHaveBeenCalled() expect(mockSetToast).toHaveBeenCalled() }) it('stops native messaging when toggled off', async () => { stopNativeMessagingIPC.mockResolvedValue() const { result } = renderHook(() => useConnectExtension()) await act(async () => { await result.current.toggleBrowserExtension(false) }) expect(stopNativeMessagingIPC).toHaveBeenCalled() expect(setNativeMessagingEnabled).toHaveBeenCalledWith(false) }) it('loads pairing info on enable', async () => { const fakeIdentity = { ed25519PublicKey: 'pubkey', creationDate: '2023-01-01' } setupNativeMessaging.mockResolvedValue({ success: true }) startNativeMessagingIPC.mockResolvedValue() killNativeMessagingHostProcesses.mockResolvedValue() getOrCreateIdentity.mockResolvedValue(fakeIdentity) getPairingToken.mockResolvedValue('PAIRCODE-ABCD') getFingerprint.mockReturnValue('ABCD1234') getNativeMessagingEnabled.mockReturnValue(false) isNativeMessagingIPCRunning.mockReturnValue(false) createOrGetPearpassClient.mockReturnValue({ encryptionAdd: jest.fn().mockResolvedValue(undefined) }) const { result } = renderHook(() => useConnectExtension()) await act(async () => { await result.current.toggleBrowserExtension(true) }) await waitFor(() => { expect(getOrCreateIdentity).toHaveBeenCalled() expect(getPairingToken).toHaveBeenCalled() expect(getFingerprint).toHaveBeenCalledWith('pubkey') }) }) }) ================================================ FILE: src/hooks/useCopyToClipboard.electron.js ================================================ /** * Electron renderer implementation: uses Clipboard API only. * No pear-run / pear-ipc so the bundle doesn't pull in Node-only deps (__filename, etc.). * Optional: clear clipboard after delay can be added via IPC to main later. */ import React, { useState, useEffect } from 'react' import { CLIPBOARD_CLEAR_TIMEOUT } from '@tetherto/pearpass-lib-constants' import { Check } from '@tetherto/pearpass-lib-ui-kit/icons' import { useTranslation } from './useTranslation' import { LOCAL_STORAGE_KEYS } from '../constants/localStorage' import { useToast } from '../context/ToastContext' import { logger } from '../utils/logger' /** * @param {{ onCopy?: () => void }} props * @returns {{ isCopied: boolean, copyToClipboard: (text: string) => boolean, isCopyToClipboardDisabled: boolean }} */ export const useCopyToClipboard = ({ onCopy } = {}) => { const { t } = useTranslation() const toastCtx = useToast() const setToast = toastCtx?.setToast const [isCopyToClipboardDisabled, setIsCopyToClipboardDisabled] = useState(true) const [isCopied, setIsCopied] = useState(false) useEffect(() => { const disabled = localStorage.getItem( LOCAL_STORAGE_KEYS.COPY_TO_CLIPBOARD_DISABLED ) setIsCopyToClipboardDisabled(disabled === 'true') }, []) const copyToClipboard = (text) => { if (isCopyToClipboardDisabled) return false if (!text || typeof text !== 'string') { logger.error('useCopyToClipboard', 'Text to copy is invalid or undefined') return false } if (!navigator.clipboard) { logger.error('useCopyToClipboard', 'Clipboard API is not available') return false } navigator.clipboard.writeText(text).then( () => { setIsCopied(true) if (onCopy) { onCopy() } else { setToast?.({ message: t('Copied to Clipboard'), icon: Check }) } // Clear clipboard automatically after delay if (window.electronAPI) { window.electronAPI.clearClipboardAfter?.( text, CLIPBOARD_CLEAR_TIMEOUT ) } }, (err) => { logger.error( 'useCopyToClipboard', 'Failed to copy text to clipboard', err ) } ) return true } return { isCopied, copyToClipboard, isCopyToClipboardDisabled } } ================================================ FILE: src/hooks/useCopyToClipboard.electron.test.js ================================================ import { act, renderHook, waitFor } from '@testing-library/react' import { useCopyToClipboard } from './useCopyToClipboard.electron' import { LOCAL_STORAGE_KEYS } from '../constants/localStorage' import { logger } from '../utils/logger' jest.mock('../utils/logger', () => ({ logger: { error: jest.fn() } })) jest.mock('./useTranslation', () => ({ useTranslation: () => ({ t: (value) => value }) })) jest.mock('@tetherto/pearpass-lib-constants', () => ({ CLIPBOARD_CLEAR_TIMEOUT: 1000 })) describe('useCopyToClipboard.electron', () => { beforeEach(() => { jest.clearAllMocks() localStorage.clear() window.electronAPI = { clearClipboardAfter: jest.fn() } Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: { writeText: jest.fn().mockResolvedValue(undefined) } }) }) it('starts with isCopied set to false', () => { const { result } = renderHook(() => useCopyToClipboard()) expect(result.current.isCopied).toBe(false) }) it('reads disabled flag from localStorage on mount', async () => { localStorage.setItem(LOCAL_STORAGE_KEYS.COPY_TO_CLIPBOARD_DISABLED, 'true') const { result } = renderHook(() => useCopyToClipboard()) await waitFor(() => { expect(result.current.isCopyToClipboardDisabled).toBe(true) }) }) it('enables copy when disabled flag is not true', async () => { const { result } = renderHook(() => useCopyToClipboard()) await waitFor(() => { expect(result.current.isCopyToClipboardDisabled).toBe(false) }) }) it('does not copy when disabled', async () => { localStorage.setItem(LOCAL_STORAGE_KEYS.COPY_TO_CLIPBOARD_DISABLED, 'true') const { result } = renderHook(() => useCopyToClipboard()) await waitFor(() => { expect(result.current.isCopyToClipboardDisabled).toBe(true) }) let copied await act(async () => { copied = result.current.copyToClipboard('secret') }) expect(copied).toBe(false) expect(navigator.clipboard.writeText).not.toHaveBeenCalled() }) it('copies text and sets isCopied on success', async () => { const onCopy = jest.fn() const { result } = renderHook(() => useCopyToClipboard({ onCopy })) await waitFor(() => { expect(result.current.isCopyToClipboardDisabled).toBe(false) }) let copied await act(async () => { copied = result.current.copyToClipboard('secret') }) expect(copied).toBe(true) expect(navigator.clipboard.writeText).toHaveBeenCalledWith('secret') await waitFor(() => { expect(result.current.isCopied).toBe(true) expect(onCopy).toHaveBeenCalledTimes(1) }) expect(window.electronAPI.clearClipboardAfter).toHaveBeenCalledWith( 'secret', 1000 ) }) it('logs and returns false when text is invalid', async () => { const { result } = renderHook(() => useCopyToClipboard()) await waitFor(() => { expect(result.current.isCopyToClipboardDisabled).toBe(false) }) let copied await act(async () => { copied = result.current.copyToClipboard(undefined) }) expect(copied).toBe(false) expect(logger.error).toHaveBeenCalledWith( 'useCopyToClipboard', 'Text to copy is invalid or undefined' ) expect(navigator.clipboard.writeText).not.toHaveBeenCalled() }) it('logs and returns false when Clipboard API is unavailable', async () => { const { result } = renderHook(() => useCopyToClipboard()) await waitFor(() => { expect(result.current.isCopyToClipboardDisabled).toBe(false) }) Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: undefined }) let copied await act(async () => { copied = result.current.copyToClipboard('secret') }) expect(copied).toBe(false) expect(logger.error).toHaveBeenCalledWith( 'useCopyToClipboard', 'Clipboard API is not available' ) }) it('logs write failure when clipboard write rejects', async () => { const error = new Error('copy failed') navigator.clipboard.writeText.mockRejectedValueOnce(error) const { result } = renderHook(() => useCopyToClipboard()) await waitFor(() => { expect(result.current.isCopyToClipboardDisabled).toBe(false) }) let copied await act(async () => { copied = result.current.copyToClipboard('secret') await Promise.resolve() }) expect(copied).toBe(true) await waitFor(() => { expect(logger.error).toHaveBeenCalledWith( 'useCopyToClipboard', 'Failed to copy text to clipboard', error ) }) }) }) ================================================ FILE: src/hooks/useCreateOrEditRecord.d.ts ================================================ import { PassType } from '../shared/types' export type CreateOrEditRecordParams = { recordType: string initialRecord?: unknown selectedFolder?: string isFavorite?: boolean setValue?: (value: string, type: PassType) => void } export function useCreateOrEditRecord(): { handleCreateOrEditRecord: (params: CreateOrEditRecordParams) => void } ================================================ FILE: src/hooks/useCreateOrEditRecord.js ================================================ import { html } from 'htm/react' import { CreateOrEditCategoryWrapper } from '../containers/Modal/CreateOrEditCategoryWrapper' import { GeneratePasswordModalContentV2 } from '../containers/Modal/GeneratePasswordModalContentV2/GeneratePasswordModalContentV2' import { GeneratePasswordSideDrawerContent } from '../containers/Modal/GeneratePasswordSideDrawerContent' import { useModal } from '../context/ModalContext' import { isV2 } from '../utils/designVersion' export const useCreateOrEditRecord = () => { const { setModal } = useModal() const getModalContentByRecordType = ({ recordType, initialRecord, selectedFolder, isFavorite }) => html` <${CreateOrEditCategoryWrapper} recordType=${recordType} initialRecord=${initialRecord} selectedFolder=${selectedFolder} isFavorite=${isFavorite} /> ` const getSideDrawerContentByRecordType = ({ recordType, setValue }) => { if (recordType === 'password') { return html`<${GeneratePasswordSideDrawerContent} onPasswordInsert=${setValue} />` } } const getGeneratePasswordV2Content = ({ setValue }) => html` <${GeneratePasswordModalContentV2} onPasswordInsert=${setValue} /> ` /** * @param {{ * recordType: string, * initialRecord?: unknown, * selectedFolder?: string, * isFavorite?: boolean, * setValue?: (value: string, type: import('../shared/types').PassType) => void * }} options */ const handleCreateOrEditRecord = (options) => { const { recordType, initialRecord, selectedFolder, isFavorite, setValue } = options if (recordType === 'password') { if (isV2()) { setModal(getGeneratePasswordV2Content({ setValue })) return } setModal(getSideDrawerContentByRecordType({ recordType, setValue }), { modalType: 'sideDrawer' }) return } setModal( getModalContentByRecordType({ recordType, initialRecord, selectedFolder, isFavorite }) ) } return { handleCreateOrEditRecord } } ================================================ FILE: src/hooks/useCreateOrEditRecord.test.js ================================================ import { renderHook } from '@testing-library/react' import { useCreateOrEditRecord } from './useCreateOrEditRecord' import { useModal } from '../context/ModalContext' import '@testing-library/jest-dom' jest.mock( '../containers/Modal/CreateFolderModalContentV2/CreateFolderModalContentV2', () => ({ CreateFolderModalContentV2: function MockCreateFolderModalContentV2() { return null } }) ) jest.mock( '../containers/Modal/DisplayPictureModalContentV2/DisplayPictureModalContentV2', () => ({ DisplayPictureModalContentV2: function MockDisplayPictureModalContentV2() { return null } }) ) jest.mock('../containers/Modal/CreateOrEditCategoryWrapper', () => ({ CreateOrEditCategoryWrapper: function MockCreateOrEditCategoryWrapper() { return null } })) jest.mock( '../containers/Modal/GeneratePasswordModalContentV2/GeneratePasswordModalContentV2', () => ({ GeneratePasswordModalContentV2: function MockGeneratePasswordModalContentV2() { return null } }) ) jest.mock('../context/ModalContext', () => ({ useModal: jest.fn() })) jest.mock('@tetherto/pear-apps-utils-generate-unique-id', () => ({ generateUniqueId: jest.fn(() => 'mocked-unique-id') })) jest.mock('../utils/designVersion', () => ({ isV2: () => true })) describe('useCreateOrEditRecord', () => { const setModalMock = jest.fn() beforeEach(() => { jest.clearAllMocks() useModal.mockReturnValue({ setModal: setModalMock }) }) it('should return handleCreateOrEditRecord function', () => { const { result } = renderHook(() => useCreateOrEditRecord()) expect(typeof result.current.handleCreateOrEditRecord).toBe('function') }) it('should call setModal for password record type', () => { const { result } = renderHook(() => useCreateOrEditRecord()) const setValue = jest.fn() result.current.handleCreateOrEditRecord({ recordType: 'password', setValue }) expect(setModalMock).toHaveBeenCalledTimes(1) expect(setModalMock.mock.calls[0][1]).toBeUndefined() }) it('should call setModal with category wrapper content for non-password record types', () => { const { result } = renderHook(() => useCreateOrEditRecord()) result.current.handleCreateOrEditRecord({ recordType: 'login', initialRecord: { id: '123' }, selectedFolder: 'folder1' }) expect(setModalMock).toHaveBeenCalledTimes(1) expect(setModalMock.mock.calls[0][1]).toBeUndefined() }) it('should handle various record types', () => { const { result } = renderHook(() => useCreateOrEditRecord()) const recordTypes = ['login', 'creditCard', 'identity', 'note', 'custom'] recordTypes.forEach((recordType) => { setModalMock.mockClear() result.current.handleCreateOrEditRecord({ recordType }) expect(setModalMock).toHaveBeenCalledTimes(1) }) }) }) ================================================ FILE: src/hooks/useCustomFields.js ================================================ import { useState } from 'react' import { generateUniqueId } from '@tetherto/pear-apps-utils-generate-unique-id' /** * @param {{ * customFields?: { * id: string, * type: string, * props: any * }[] * }} props * @returns {{ * customFields: {id: string, type: string, props: any}[], * createCustomField: (type: string, props: any) => void, * setCustomFields: React.Dispatch> * }} */ export const useCustomFields = ({ customFields: initialCustomFields = [] } = {}) => { const [customFields, setCustomFields] = useState(initialCustomFields) const createCustomField = (type, props) => { const id = generateUniqueId() return { id, type, props: { ...props } } } return { customFields, createCustomField, setCustomFields } } ================================================ FILE: src/hooks/useCustomFields.test.js ================================================ import { renderHook, act } from '@testing-library/react' import { generateUniqueId } from '@tetherto/pear-apps-utils-generate-unique-id' import { useCustomFields } from './useCustomFields' jest.mock('@tetherto/pear-apps-utils-generate-unique-id', () => ({ generateUniqueId: jest.fn() })) describe('useCustomFields', () => { beforeEach(() => { jest.clearAllMocks() generateUniqueId.mockReturnValue('test-id-123') }) test('should initialize with empty customFields by default', () => { const { result } = renderHook(() => useCustomFields()) expect(result.current.customFields).toEqual([]) }) test('should initialize with provided customFields', () => { const initialCustomFields = [ { id: 'field1', type: 'text', props: { label: 'Field 1' } } ] const { result } = renderHook(() => useCustomFields({ customFields: initialCustomFields }) ) expect(result.current.customFields).toEqual(initialCustomFields) }) test('should create a custom field with the correct structure', () => { const { result } = renderHook(() => useCustomFields()) const newField = result.current.createCustomField('text', { label: 'New Field' }) expect(generateUniqueId).toHaveBeenCalledTimes(1) expect(newField).toEqual({ id: 'test-id-123', type: 'text', props: { label: 'New Field' } }) }) test('should update customFields with setCustomFields', () => { const { result } = renderHook(() => useCustomFields()) const newCustomFields = [ { id: 'field1', type: 'text', props: { label: 'Field 1' } } ] act(() => { result.current.setCustomFields(newCustomFields) }) expect(result.current.customFields).toEqual(newCustomFields) }) test('should handle multiple custom fields', () => { generateUniqueId.mockReturnValueOnce('id-1').mockReturnValueOnce('id-2') const { result } = renderHook(() => useCustomFields()) const field1 = result.current.createCustomField('text', { label: 'Field 1' }) const field2 = result.current.createCustomField('checkbox', { label: 'Field 2', checked: false }) act(() => { result.current.setCustomFields([field1, field2]) }) expect(result.current.customFields).toEqual([ { id: 'id-1', type: 'text', props: { label: 'Field 1' } }, { id: 'id-2', type: 'checkbox', props: { label: 'Field 2', checked: false } } ]) }) }) ================================================ FILE: src/hooks/useGetMultipleFiles.js ================================================ import { useEffect } from 'react' import { vaultGetFile } from '@tetherto/pearpass-lib-vault' import { logger } from '../utils/logger' /** * @param {object} [param0] * @param {string[]} [param0.fieldNames] * @param {(name: string, value: unknown) => void} [param0.updateValues] * @param {{ data?: Record } | undefined} [param0.initialRecord] */ export const useGetMultipleFiles = ({ fieldNames, updateValues, initialRecord } = {}) => { const getFilesAsync = async (fieldName) => { const attachments = initialRecord?.data?.[fieldName] || [] if (!attachments.length) { return } try { const files = await Promise.all( attachments.map(async (attachment) => { const file = await vaultGetFile( `record/${initialRecord.id}/file/${attachment.id}` ) return { ...attachment, buffer: file } }) ) updateValues(fieldName, files) } catch (error) { logger.error('useGetMultipleFiles', 'Error retrieving files:', error) } } useEffect(() => { ;(async () => { await Promise.all( fieldNames.map(async (fieldName) => { if (!fieldName || !updateValues || !initialRecord) { return } await getFilesAsync(fieldName) }) ) })() }, [initialRecord]) } ================================================ FILE: src/hooks/useGetMultipleFiles.test.js ================================================ import { renderHook, waitFor } from '@testing-library/react' import { vaultGetFile } from '@tetherto/pearpass-lib-vault' import { useGetMultipleFiles } from './useGetMultipleFiles' import { logger } from '../utils/logger' jest.mock('@tetherto/pearpass-lib-vault', () => ({ vaultGetFile: jest.fn() })) jest.mock('../utils/logger', () => ({ logger: { error: jest.fn() } })) describe('useGetMultipleFiles', () => { const mockUpdateValues = jest.fn() const initialRecord = { id: 'rec-1', data: { fieldA: [ { id: 'file-1', name: 'file1.txt' }, { id: 'file-2', name: 'file2.txt' } ], fieldB: [] } } beforeEach(() => { jest.clearAllMocks() }) it('fetches files for each field and calls updateValues', async () => { vaultGetFile .mockResolvedValueOnce('buffer1') .mockResolvedValueOnce('buffer2') await renderHook(() => useGetMultipleFiles({ fieldNames: ['fieldA', 'fieldB'], updateValues: mockUpdateValues, initialRecord }) ) await waitFor(() => { expect(vaultGetFile).toHaveBeenCalledTimes(2) expect(vaultGetFile).toHaveBeenCalledWith('record/rec-1/file/file-1') expect(vaultGetFile).toHaveBeenCalledWith('record/rec-1/file/file-2') expect(mockUpdateValues).toHaveBeenCalledWith('fieldA', [ { id: 'file-1', name: 'file1.txt', buffer: 'buffer1' }, { id: 'file-2', name: 'file2.txt', buffer: 'buffer2' } ]) expect(mockUpdateValues).toHaveBeenCalledTimes(1) }) }) it('does not call updateValues if attachments are empty', async () => { await renderHook(() => useGetMultipleFiles({ fieldNames: ['fieldB'], updateValues: mockUpdateValues, initialRecord }) ) await Promise.resolve() expect(vaultGetFile).not.toHaveBeenCalled() expect(mockUpdateValues).not.toHaveBeenCalled() }) it('logs error if getFile throws', async () => { vaultGetFile.mockRejectedValueOnce(new Error('fail')) await renderHook(() => useGetMultipleFiles({ fieldNames: ['fieldA'], updateValues: mockUpdateValues, initialRecord }) ) await waitFor(() => { expect(logger.error).toHaveBeenCalledWith( 'useGetMultipleFiles', 'Error retrieving files:', expect.any(Error) ) expect(mockUpdateValues).not.toHaveBeenCalled() }) }) it('does nothing if fieldNames, updateValues, or initialRecord are missing', async () => { await renderHook(() => useGetMultipleFiles({ fieldNames: [], updateValues: mockUpdateValues, initialRecord }) ) await renderHook(() => useGetMultipleFiles({ fieldNames: ['fieldA'], updateValues: null, initialRecord }) ) await renderHook(() => useGetMultipleFiles({ fieldNames: ['fieldA'], updateValues: mockUpdateValues, initialRecord: null }) ) await Promise.resolve() expect(vaultGetFile).not.toHaveBeenCalled() expect(mockUpdateValues).not.toHaveBeenCalled() }) }) ================================================ FILE: src/hooks/useLanguageOptions.js ================================================ import { useMemo } from 'react' import { useLingui } from '@lingui/react' import { LANGUAGES } from '@tetherto/pearpass-lib-constants' export const useLanguageOptions = () => { const { i18n } = useLingui() const languageOptions = useMemo(() => { const languageLabelByValue = { en: i18n._('English'), it: i18n._('Italian'), es: i18n._('Spanish'), fr: i18n._('French') } return LANGUAGES.map((lang) => ({ label: languageLabelByValue[lang.value], value: lang.value, testId: `settings-language-${lang.value}` })) }, [LANGUAGES, i18n]) return { languageOptions } } ================================================ FILE: src/hooks/useLanguageOptions.test.js ================================================ import { renderHook } from '@testing-library/react' import { useLanguageOptions } from './useLanguageOptions' jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (str) => str } }) })) jest.mock('@tetherto/pearpass-lib-constants', () => ({ LANGUAGES: [ { value: 'en' }, { value: 'it' }, { value: 'es' }, { value: 'fr' } ] })) describe('useLanguageOptions', () => { afterEach(() => { jest.clearAllMocks() }) it('should return language options with correct labels and values', () => { const { result } = renderHook(() => useLanguageOptions()) expect(result.current.languageOptions).toEqual([ { label: 'English', value: 'en', testId: 'settings-language-en' }, { label: 'Italian', value: 'it', testId: 'settings-language-it' }, { label: 'Spanish', value: 'es', testId: 'settings-language-es' }, { label: 'French', value: 'fr', testId: 'settings-language-fr' } ]) }) it('should memoize the language options', () => { const { result, rerender } = renderHook(() => useLanguageOptions()) const firstResult = result.current.languageOptions rerender() expect(result.current.languageOptions).toStrictEqual(firstResult) }) }) ================================================ FILE: src/hooks/useOutsideClick.js ================================================ import { useEffect, useRef } from 'react' /** * @typedef UseOutsideClickParams * @property {(event: Event) => void} onOutsideClick */ /** * @param {UseOutsideClickParams} params * @returns {import('react').MutableRefObject} */ export const useOutsideClick = ({ onOutsideClick }) => { const ref = useRef(null) useEffect(() => { const handleListener = (event) => { if (ref?.current && ref.current.contains(event.target)) { return } onOutsideClick(event) } document.addEventListener('mousedown', handleListener) document.addEventListener('touchstart', handleListener) return () => { document.removeEventListener('mousedown', handleListener) document.removeEventListener('touchstart', handleListener) } }, []) return ref } ================================================ FILE: src/hooks/useOutsideClick.test.js ================================================ import { renderHook, fireEvent } from '@testing-library/react' import { useOutsideClick } from './useOutsideClick' describe('useOutsideClick', () => { beforeEach(() => { const div = document.createElement('div') document.body.appendChild(div) }) afterEach(() => { document.body.innerHTML = '' jest.clearAllMocks() }) test('should add event listeners on mount', () => { const addEventListenerSpy = jest.spyOn(document, 'addEventListener') const mockOnOutsideClick = jest.fn() renderHook(() => useOutsideClick({ onOutsideClick: mockOnOutsideClick })) expect(addEventListenerSpy).toHaveBeenCalledWith( 'mousedown', expect.any(Function) ) expect(addEventListenerSpy).toHaveBeenCalledWith( 'touchstart', expect.any(Function) ) }) test('should remove event listeners on unmount', () => { const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener') const mockOnOutsideClick = jest.fn() const { unmount } = renderHook(() => useOutsideClick({ onOutsideClick: mockOnOutsideClick }) ) unmount() expect(removeEventListenerSpy).toHaveBeenCalledWith( 'mousedown', expect.any(Function) ) expect(removeEventListenerSpy).toHaveBeenCalledWith( 'touchstart', expect.any(Function) ) }) test('should call onOutsideClick when clicking outside the ref element', () => { const mockOnOutsideClick = jest.fn() const { result } = renderHook(() => useOutsideClick({ onOutsideClick: mockOnOutsideClick }) ) const divElement = document.createElement('div') document.body.appendChild(divElement) Object.defineProperty(result.current, 'current', { value: divElement, writable: true }) const outsideElement = document.createElement('button') document.body.appendChild(outsideElement) fireEvent.mouseDown(outsideElement) expect(mockOnOutsideClick).toHaveBeenCalled() }) test('should not call onOutsideClick when clicking inside the ref element', () => { const mockOnOutsideClick = jest.fn() const { result } = renderHook(() => useOutsideClick({ onOutsideClick: mockOnOutsideClick }) ) const divElement = document.createElement('div') document.body.appendChild(divElement) Object.defineProperty(result.current, 'current', { value: divElement, writable: true }) fireEvent.mouseDown(divElement) expect(mockOnOutsideClick).not.toHaveBeenCalled() }) }) ================================================ FILE: src/hooks/usePasteFromClipboard.js ================================================ import { useCallback } from 'react' import { useLingui } from '@lingui/react' import { useToast } from '../context/ToastContext' /** * @returns {{ * pasteFromClipboard: () => Promise * }} */ export const usePasteFromClipboard = () => { const { i18n } = useLingui() const { setToast } = useToast() const pasteFromClipboard = useCallback(async () => { try { let text = '' if (typeof navigator !== 'undefined' && navigator?.clipboard?.readText) { text = await navigator.clipboard.readText() } else { throw new Error('Clipboard API not available') } if (!text?.length) { setToast({ message: i18n._('No text found in clipboard') }) return null } setToast({ message: i18n._('Pasted from clipboard!') }) return text } catch { setToast({ message: i18n._('Failed to paste from clipboard') }) return null } }, [i18n, setToast]) return { pasteFromClipboard } } ================================================ FILE: src/hooks/usePasteFromClipboard.test.js ================================================ import React from 'react' import { render, act } from '@testing-library/react' import '@testing-library/jest-dom' import { usePasteFromClipboard } from './usePasteFromClipboard' jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (s) => s } }) })) const mockSetToast = jest.fn() jest.mock('../context/ToastContext', () => ({ useToast: () => ({ setToast: mockSetToast }) })) let exposedPaste const HookHost = () => { const { pasteFromClipboard } = usePasteFromClipboard() exposedPaste = pasteFromClipboard return null } describe('usePasteFromClipboard', () => { const originalNavigator = global.navigator const renderHost = () => { render() } afterEach(() => { mockSetToast.mockReset() // Restore navigator between tests Object.defineProperty(global, 'navigator', { value: originalNavigator, configurable: true }) }) test('returns text and shows success toast when clipboard has text', async () => { const readText = jest.fn().mockResolvedValue('hello world') Object.defineProperty(global, 'navigator', { value: { clipboard: { readText } }, configurable: true }) renderHost() let result await act(async () => { result = await exposedPaste() }) expect(readText).toHaveBeenCalled() expect(result).toBe('hello world') expect(mockSetToast).toHaveBeenCalledWith({ message: 'Pasted from clipboard!' }) }) test('returns null and warns when clipboard text is empty', async () => { const readText = jest.fn().mockResolvedValue('') Object.defineProperty(global, 'navigator', { value: { clipboard: { readText } }, configurable: true }) renderHost() let result await act(async () => { result = await exposedPaste() }) expect(readText).toHaveBeenCalled() expect(result).toBeNull() expect(mockSetToast).toHaveBeenCalledWith({ message: 'No text found in clipboard' }) }) test('returns null and shows failure toast when API unavailable or throws', async () => { // Case: navigator missing clipboard API Object.defineProperty(global, 'navigator', { value: {}, configurable: true }) renderHost() let result await act(async () => { result = await exposedPaste() }) expect(result).toBeNull() expect(mockSetToast).toHaveBeenCalledWith({ message: 'Failed to paste from clipboard' }) mockSetToast.mockReset() // Case: readText throws const readText = jest.fn().mockRejectedValue(new Error('boom')) Object.defineProperty(global, 'navigator', { value: { clipboard: { readText } }, configurable: true }) await act(async () => { result = await exposedPaste() }) expect(readText).toHaveBeenCalled() expect(result).toBeNull() expect(mockSetToast).toHaveBeenCalledWith({ message: 'Failed to paste from clipboard' }) }) }) ================================================ FILE: src/hooks/usePearUpdate.js ================================================ /** @typedef {import('pear-interface')} */ import { useEffect, useRef } from 'react' import { html } from 'htm/react' import { UpdateRequiredModalContent } from '../containers/Modal/UpdateRequiredModalContent' import { UpdateRequiredModalContentV2 } from '../containers/Modal/UpdateRequiredModalContentV2/UpdateRequiredModalContentV2' import { useModal } from '../context/ModalContext' import { isV2 } from '../utils/designVersion' export const usePearUpdate = () => { const { setModal } = useModal() const modalShownRef = useRef(false) const electronAPI = window.electronAPI const showUpdateRequiredModal = () => { if (modalShownRef.current || !Pear.config.key) return setModal( isV2() ? html`<${UpdateRequiredModalContentV2} onUpdate=${handleUpdateApp} />` : html`<${UpdateRequiredModalContent} onUpdate=${handleUpdateApp} />`, { closable: false } ) modalShownRef.current = true } const onPearEvent = (name, listener) => { if (!electronAPI) return () => {} if (name === 'updated') { return electronAPI.onRuntimeUpdated(() => listener('updated')) } return () => {} } useEffect(() => { // DEV: preserve hot-reload behaviour driven by Pear core. const checkUpdated = async () => { const isUpdated = await electronAPI.checkUpdated() if (isUpdated) { showUpdateRequiredModal() } } checkUpdated() if (!Pear.config.key) { const onPearUpdate = () => { Pear.reload() } Pear.updates(onPearUpdate) return () => { Pear.updates(() => {}) } } const offUpdated = onPearEvent('updated', async () => { showUpdateRequiredModal() }) return () => { offUpdated?.() } }, []) } async function handleUpdateApp() { const electronAPI = window.electronAPI if (!electronAPI) return electronAPI.restart() } ================================================ FILE: src/hooks/usePearUpdate.test.js ================================================ import { act, renderHook, waitFor } from '@testing-library/react' import { usePearUpdate } from './usePearUpdate' import { UpdateRequiredModalContent } from '../containers/Modal/UpdateRequiredModalContent' import { UpdateRequiredModalContentV2 } from '../containers/Modal/UpdateRequiredModalContentV2/UpdateRequiredModalContentV2' import { useModal } from '../context/ModalContext' import { isV2 } from '../utils/designVersion' global.Pear = { updates: jest.fn(), restart: jest.fn(), reload: jest.fn(), updated: jest.fn(() => Promise.resolve(false)), config: { tier: 'prod', key: 'some-key' } } window.electronAPI = { checkUpdated: jest.fn(() => Promise.resolve(false)), onRuntimeUpdated: jest.fn(() => () => {}), restart: jest.fn() } jest.mock('../context/ModalContext', () => ({ useModal: jest.fn() })) jest.mock('../utils/designVersion', () => ({ isV2: jest.fn() })) jest.mock( '../containers/Modal/UpdateRequiredModalContentV2/UpdateRequiredModalContentV2', () => ({ UpdateRequiredModalContentV2: function UpdateRequiredModalContentV2() { return null } }) ) describe('usePearUpdate', () => { let setModalMock beforeEach(() => { setModalMock = jest.fn() useModal.mockReturnValue({ setModal: setModalMock }) isV2.mockReturnValue(true) Pear.updates.mockClear() Pear.restart.mockClear() Pear.reload.mockClear() Pear.updated.mockClear() Pear.config.tier = 'prod' Pear.config.key = 'some-key' window.electronAPI.checkUpdated.mockClear() window.electronAPI.onRuntimeUpdated.mockClear() window.electronAPI.restart.mockClear() window.electronAPI.checkUpdated.mockResolvedValue(false) }) it('subscribes to electron runtime updates in prod mode', () => { renderHook(() => usePearUpdate()) expect(window.electronAPI.onRuntimeUpdated).toHaveBeenCalledTimes(1) }) it('shows modal when checkUpdated resolves with isUpdated true', async () => { window.electronAPI.checkUpdated.mockResolvedValue(true) await act(async () => { renderHook(() => usePearUpdate()) }) await waitFor(() => { expect(setModalMock).toHaveBeenCalledTimes(1) }) expect(Pear.restart).not.toHaveBeenCalled() expect(Pear.reload).not.toHaveBeenCalled() }) it('shows modal when onRuntimeUpdated fires (prod)', async () => { window.electronAPI.checkUpdated.mockResolvedValue(true) let updateCallback window.electronAPI.onRuntimeUpdated.mockImplementation((cb) => { updateCallback = cb return () => {} }) renderHook(() => usePearUpdate()) await act(async () => { updateCallback() }) expect(setModalMock).toHaveBeenCalledTimes(1) }) it('ignores updates in dev mode (no key)', async () => { Pear.config.key = null renderHook(() => usePearUpdate()) const callback = Pear.updates.mock.calls[0][0] await act(async () => { await callback({ diff: [{ key: '/app/file.js' }] }) }) expect(setModalMock).not.toHaveBeenCalled() expect(Pear.restart).not.toHaveBeenCalled() expect(Pear.reload).toHaveBeenCalled() }) it('triggers restart when update handler is called', async () => { window.electronAPI.checkUpdated.mockResolvedValue(true) await act(async () => { renderHook(() => usePearUpdate()) }) await waitFor(() => { expect(setModalMock).toHaveBeenCalled() }) const modalElement = setModalMock.mock.calls[0][0] await act(async () => { modalElement.props.onUpdate() }) expect(window.electronAPI.restart).toHaveBeenCalled() }) it('uses UpdateRequiredModalContentV2 when isV2() is true', async () => { isV2.mockReturnValue(true) window.electronAPI.checkUpdated.mockResolvedValue(true) let updateCallback window.electronAPI.onRuntimeUpdated.mockImplementation((cb) => { updateCallback = cb return () => {} }) await act(async () => { renderHook(() => usePearUpdate()) }) await act(async () => { updateCallback() }) expect(setModalMock).toHaveBeenCalledTimes(1) const modalElement = setModalMock.mock.calls[0][0] expect(modalElement.type).toBe(UpdateRequiredModalContentV2) }) it('uses UpdateRequiredModalContent when isV2() is false', async () => { isV2.mockReturnValue(false) window.electronAPI.checkUpdated.mockResolvedValue(true) let updateCallback window.electronAPI.onRuntimeUpdated.mockImplementation((cb) => { updateCallback = cb return () => {} }) await act(async () => { renderHook(() => usePearUpdate()) }) await act(async () => { updateCallback() }) expect(setModalMock).toHaveBeenCalledTimes(1) const modalElement = setModalMock.mock.calls[0][0] expect(modalElement.type).toBe(UpdateRequiredModalContent) }) }) ================================================ FILE: src/hooks/useRecordActionItems.d.ts ================================================ export type RecordActionItem = { name: string type: string click?: () => void } export function useRecordActionItems(params?: { excludeTypes?: string[] record?: { id?: string; type?: string; isFavorite?: boolean } & Record< string, unknown > recordType?: 'login' | 'otp' onSelect?: () => void onClose?: () => void }): { actions: RecordActionItem[] } ================================================ FILE: src/hooks/useRecordActionItems.js ================================================ import React from 'react' import { useLingui } from '@lingui/react' import { RECORD_TYPES, useRecords } from '@tetherto/pearpass-lib-vault' import { html } from 'htm/react' import { useCreateOrEditRecord } from './useCreateOrEditRecord' import { ConfirmationModalContent } from '../containers/Modal/ConfirmationModalContent' import { DeleteRecordsModalContentV2 } from '../containers/Modal/DeleteRecordsModalContentV2' import { MoveFolderModalContent } from '../containers/Modal/MoveFolderModalContent' import { MoveFolderModalContentV2 } from '../containers/Modal/MoveFolderModalContentV2/MoveFolderModalContentV2' import { useModal } from '../context/ModalContext' import { useRouter } from '../context/RouterContext' import { useToast } from '../context/ToastContext' import { isV2 } from '../utils/designVersion' /** * @param {{ * excludeType: Array * record: { * id: string * } * onSelect: () => void * onClose: () => void * }} * * @returns {{ * actions: Array<{ * name: string, * type: string * }>}} */ export const useRecordActionItems = ({ excludeTypes = [], record, recordType, onSelect, onClose } = {}) => { const { i18n } = useLingui() const { setModal, closeModal } = useModal() const { data: routerData, navigate, currentPage } = useRouter() const { setToast } = useToast() const { deleteRecords, updateRecords, updateFavoriteState } = useRecords() const { handleCreateOrEditRecord } = useCreateOrEditRecord() const isOtpContext = recordType === RECORD_TYPES.OTP || routerData?.recordType === RECORD_TYPES.OTP const isAuthenticatorLoginRecord = isOtpContext && record?.type === RECORD_TYPES.LOGIN const handleStripOtp = async () => { const { otpInput, otp, ...restData } = record?.data ?? {} const { otpPublic, ...recordWithoutOtp } = record ?? {} const updatedRecord = { ...recordWithoutOtp, data: restData } try { await updateRecords([updatedRecord]) if (routerData?.recordId === record?.id) { navigate(currentPage, { ...routerData, recordId: undefined }) } closeModal?.() } catch (err) { setToast({ message: err?.message ?? i18n._('Failed to remove authenticator code') }) } } const handleDeleteConfirm = () => { if (routerData?.recordId === record?.id) { navigate(currentPage, { ...routerData, recordId: undefined }) } deleteRecords([record?.id]) closeModal?.() } const handleDelete = () => { if (isV2()) { setModal( ) } else { setModal( ) } onClose?.() } const handleFavoriteToggle = () => { updateFavoriteState([record?.id], !record?.isFavorite) onClose?.() } const handleSelect = () => { onSelect?.(record) onClose?.() } const handleEdit = () => { handleCreateOrEditRecord({ recordType: isOtpContext ? RECORD_TYPES.OTP : record?.type, initialRecord: record }) onClose?.() } const handleMoveClick = () => { const VersionBasedMoveFolderModalContent = isV2() ? MoveFolderModalContentV2 : MoveFolderModalContent setModal(html` <${VersionBasedMoveFolderModalContent} records=${[record]} /> `) onClose?.() } const v2Actions = [ { name: i18n._('Select element'), type: 'select', click: handleSelect }, { name: i18n._('Edit'), type: 'edit', click: handleEdit }, { name: i18n._( record?.isFavorite ? 'Remove from Favorites' : 'Add to Favorites' ), type: 'favorite', click: handleFavoriteToggle }, { name: i18n._('Move to Another Folder'), type: 'move', click: handleMoveClick }, { name: i18n._('Delete Item'), type: 'delete', click: handleDelete } ] const v1Actions = [ { name: i18n._('Select element'), type: 'select', click: handleSelect }, { name: i18n._( record?.isFavorite ? 'Remove from Favorites' : 'Mark as favorite' ), type: 'favorite', click: handleFavoriteToggle }, { name: i18n._('Move to another folder'), type: 'move', click: handleMoveClick }, { name: i18n._('Delete element'), type: 'delete', click: handleDelete } ] const defaultActions = isV2() ? v2Actions : v1Actions const filteredActions = excludeTypes.length ? defaultActions.filter((action) => !excludeTypes.includes(action.type)) : defaultActions return { actions: filteredActions } } ================================================ FILE: src/hooks/useRecordActionItems.test.js ================================================ import { renderHook } from '@testing-library/react' import { useRecordActionItems } from './useRecordActionItems' import { useModal } from '../context/ModalContext' import { useRouter } from '../context/RouterContext' import { isV2 } from '../utils/designVersion' const mockDeleteRecord = jest.fn() const mockUpdateRecords = jest.fn().mockResolvedValue(undefined) const mockUpdateFavoriteState = jest.fn() jest.mock( '../containers/Modal/MoveFolderModalContentV2/MoveFolderModalContentV2', () => ({ MoveFolderModalContentV2: () => null }) ) jest.mock('../containers/Modal/DeleteRecordsModalContentV2', () => ({ DeleteRecordsModalContentV2: () => null })) jest.mock('../utils/designVersion', () => ({ isV2: jest.fn().mockReturnValue(true) })) jest.mock( '../containers/Modal/CreateFolderModalContentV2/CreateFolderModalContentV2', () => ({ CreateFolderModalContentV2: function MockCreateFolderModalContentV2() { return null } }) ) jest.mock('../context/ModalContext', () => ({ useModal: jest.fn() })) jest.mock('../context/ToastContext', () => ({ useToast: () => ({ setToast: jest.fn() }) })) jest.mock('../context/RouterContext', () => ({ useRouter: jest.fn() })) jest.mock('@tetherto/pearpass-lib-vault', () => ({ RECORD_TYPES: { LOGIN: 'login', OTP: 'otp' }, useRecords: () => ({ deleteRecords: mockDeleteRecord, updateRecords: mockUpdateRecords, updateFavoriteState: mockUpdateFavoriteState }) })) jest.mock('@lingui/react', () => ({ useLingui: () => ({ i18n: { _: (text) => text } }) })) jest.mock('./useCreateOrEditRecord', () => ({ useCreateOrEditRecord: () => ({ handleCreateOrEditRecord: jest.fn() }) })) describe('useRecordActionItems', () => { const mockRecord = { id: '123', isFavorite: false } const mockOnSelect = jest.fn() const mockOnClose = jest.fn() const mockSetModal = jest.fn() const mockCloseModal = jest.fn() const mockNavigate = jest.fn() beforeEach(() => { jest.clearAllMocks() isV2.mockReturnValue(true) useModal.mockReturnValue({ setModal: mockSetModal, closeModal: mockCloseModal }) useRouter.mockReturnValue({ data: {}, navigate: mockNavigate, currentPage: 'somePage' }) }) test('returns correct actions when no excludeTypes provided', () => { const { result } = renderHook(() => useRecordActionItems({ record: mockRecord, onSelect: mockOnSelect, onClose: mockOnClose }) ) expect(result.current.actions).toHaveLength(5) expect(result.current.actions[0].type).toBe('select') expect(result.current.actions[1].type).toBe('edit') expect(result.current.actions[2].type).toBe('favorite') expect(result.current.actions[3].type).toBe('move') expect(result.current.actions[4].type).toBe('delete') }) test('filters actions based on excludeTypes', () => { const { result } = renderHook(() => useRecordActionItems({ excludeTypes: ['delete', 'move'], record: mockRecord, onSelect: mockOnSelect, onClose: mockOnClose }) ) expect(result.current.actions).toHaveLength(3) expect(result.current.actions[0].type).toBe('select') expect(result.current.actions[1].type).toBe('edit') expect(result.current.actions[2].type).toBe('favorite') }) test('handles select action', () => { const { result } = renderHook(() => useRecordActionItems({ record: mockRecord, onSelect: mockOnSelect, onClose: mockOnClose }) ) result.current.actions[0].click() expect(mockOnSelect).toHaveBeenCalledWith(mockRecord) expect(mockOnClose).toHaveBeenCalled() }) test('handles favorite toggle action', () => { const { result } = renderHook(() => useRecordActionItems({ record: mockRecord, onSelect: mockOnSelect, onClose: mockOnClose }) ) const favoriteAction = result.current.actions.find( (action) => action.type === 'favorite' ) favoriteAction.click() expect(mockUpdateFavoriteState).toHaveBeenCalledWith([mockRecord.id], true) expect(mockOnClose).toHaveBeenCalled() }) test('handles move action', () => { const { result } = renderHook(() => useRecordActionItems({ record: mockRecord, onSelect: mockOnSelect, onClose: mockOnClose }) ) const moveAction = result.current.actions.find( (action) => action.type === 'move' ) moveAction.click() expect(mockSetModal).toHaveBeenCalled() expect(mockOnClose).toHaveBeenCalled() }) test('handles delete action', () => { const { result } = renderHook(() => useRecordActionItems({ record: mockRecord, onSelect: mockOnSelect, onClose: mockOnClose }) ) const deleteAction = result.current.actions.find( (action) => action.type === 'delete' ) deleteAction.click() expect(mockSetModal).toHaveBeenCalled() expect(mockOnClose).toHaveBeenCalled() }) test('handles delete confirmation', () => { isV2.mockReturnValue(false) useRouter.mockReturnValue({ data: { recordId: '123' }, navigate: mockNavigate, currentPage: 'somePage' }) const { result } = renderHook(() => useRecordActionItems({ record: mockRecord, onSelect: mockOnSelect, onClose: mockOnClose }) ) const deleteAction = result.current.actions.find( (action) => action.type === 'delete' ) deleteAction.click() const confirmationAction = mockSetModal.mock.calls[0][0].props.secondaryAction confirmationAction() expect(mockNavigate).toHaveBeenCalledWith('somePage', { recordId: undefined }) expect(mockDeleteRecord).toHaveBeenCalledWith(['123']) expect(mockCloseModal).toHaveBeenCalled() }) test('strips OTP fields from login record when deleting in authenticator context', async () => { const otpLoginRecord = { id: '123', type: 'login', isFavorite: false, otpPublic: { currentCode: '169462', timeRemaining: 18 }, data: { title: 'Test Account', username: 'user@test.com', otpInput: 'JBSWY3DPEHPK3PXP', otp: { secret: 'JBSWY3DPEHPK3PXP', type: 'TOTP', algorithm: 'SHA1', digits: 6, period: 30 } } } useRouter.mockReturnValue({ data: { recordId: '123', recordType: 'otp' }, navigate: mockNavigate, currentPage: 'vault' }) const { result } = renderHook(() => useRecordActionItems({ record: otpLoginRecord, onSelect: mockOnSelect, onClose: mockOnClose }) ) const deleteAction = result.current.actions.find( (action) => action.type === 'delete' ) deleteAction.click() const onConfirm = mockSetModal.mock.calls[0][0].props.onConfirm expect(onConfirm).toBeDefined() await onConfirm() expect(mockUpdateRecords).toHaveBeenCalledTimes(1) const updatedRecord = mockUpdateRecords.mock.calls[0][0][0] expect(updatedRecord.data.otpInput).toBeUndefined() expect(updatedRecord.data.otp).toBeUndefined() expect(updatedRecord.otpPublic).toBeUndefined() expect(updatedRecord.data.title).toBe('Test Account') expect(updatedRecord.data.username).toBe('user@test.com') expect(mockDeleteRecord).not.toHaveBeenCalled() expect(mockNavigate).toHaveBeenCalledWith('vault', { recordType: 'otp', recordId: undefined }) expect(mockCloseModal).toHaveBeenCalled() }) }) ================================================ FILE: src/hooks/useRecordMenuItems.js ================================================ import { RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { useTranslation } from './useTranslation' import { RECORD_COLOR_BY_TYPE } from '../constants/recordColorByType' import { RECORD_ICON_BY_TYPE } from '../constants/recordIconByType' /** * @returns {{ * categoriesItems: Array<{ * name: string, * type: string * }>, * defaultItems: Array<{ * name: string, * type: string * }>, * popupItems: Array<{ * name: string, * type: string * }>}} */ export const useRecordMenuItems = () => { const { t } = useTranslation() const defaultItems = [ { name: t('Login'), type: RECORD_TYPES.LOGIN, icon: RECORD_ICON_BY_TYPE.login, color: RECORD_COLOR_BY_TYPE.login }, { name: t('Identity'), type: RECORD_TYPES.IDENTITY, icon: RECORD_ICON_BY_TYPE.identity, color: RECORD_COLOR_BY_TYPE.identity }, { name: t('Credit Card'), type: RECORD_TYPES.CREDIT_CARD, icon: RECORD_ICON_BY_TYPE.creditCard, color: RECORD_COLOR_BY_TYPE.creditCard }, { name: t('Wi-Fi'), type: RECORD_TYPES.WIFI_PASSWORD, icon: RECORD_ICON_BY_TYPE.wifiPassword, color: RECORD_COLOR_BY_TYPE.wifiPassword }, { name: t('Recovery phrase'), type: RECORD_TYPES.PASS_PHRASE, icon: RECORD_ICON_BY_TYPE.passPhrase, color: RECORD_COLOR_BY_TYPE.passPhrase }, { name: t('Note'), type: RECORD_TYPES.NOTE, icon: RECORD_ICON_BY_TYPE.note, color: RECORD_COLOR_BY_TYPE.note }, { name: t('Custom'), type: RECORD_TYPES.CUSTOM, icon: RECORD_ICON_BY_TYPE.custom, color: RECORD_COLOR_BY_TYPE.custom } ] const menuItems = [ { name: t('All'), type: 'all' }, ...defaultItems ] const popupItems = [ ...defaultItems, { name: t('Password'), type: 'password' } ] return { menuItems, popupItems, defaultItems } } ================================================ FILE: src/hooks/useRecordMenuItems.test.js ================================================ import { useLingui } from '@lingui/react' import { renderHook } from '@testing-library/react' import { RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { useRecordMenuItems } from './useRecordMenuItems' import { RECORD_COLOR_BY_TYPE } from '../constants/recordColorByType' import { RECORD_ICON_BY_TYPE } from '../constants/recordIconByType' jest.mock('@lingui/react', () => ({ useLingui: jest.fn() })) jest.mock('@tetherto/pearpass-lib-vault', () => ({ RECORD_TYPES: { LOGIN: 'login', IDENTITY: 'identity', CREDIT_CARD: 'credit_card', NOTE: 'note', CUSTOM: 'custom', WIFI_PASSWORD: 'wifi_password', PASS_PHRASE: 'pass_phrase' } })) describe('useRecordMenuItems', () => { const mockI18n = { _: jest.fn((str) => str) } beforeEach(() => { useLingui.mockReturnValue({ i18n: mockI18n }) }) it('should return menu items with correct structure', () => { const { result } = renderHook(() => useRecordMenuItems()) expect(result.current).toHaveProperty('menuItems') expect(result.current).toHaveProperty('popupItems') expect(result.current).toHaveProperty('defaultItems') }) it('should have "All" as first menuItem', () => { const { result } = renderHook(() => useRecordMenuItems()) expect(result.current.menuItems[0]).toEqual({ name: 'All', type: 'all' }) }) it('should have default items in menuItems after "All"', () => { const { result } = renderHook(() => useRecordMenuItems()) expect(result.current.menuItems[1].type).toBe(RECORD_TYPES.LOGIN) expect(result.current.menuItems[2].type).toBe(RECORD_TYPES.IDENTITY) expect(result.current.menuItems[3].type).toBe(RECORD_TYPES.CREDIT_CARD) expect(result.current.menuItems[4].type).toBe(RECORD_TYPES.WIFI_PASSWORD) expect(result.current.menuItems[5].type).toBe(RECORD_TYPES.PASS_PHRASE) expect(result.current.menuItems[6].type).toBe(RECORD_TYPES.NOTE) expect(result.current.menuItems[7].type).toBe(RECORD_TYPES.CUSTOM) }) it('should include icons and colors in default items', () => { const { result } = renderHook(() => useRecordMenuItems()) const loginItem = result.current.defaultItems.find( (item) => item.type === RECORD_TYPES.LOGIN ) expect(loginItem.icon).toBe(RECORD_ICON_BY_TYPE.login) expect(loginItem.color).toBe(RECORD_COLOR_BY_TYPE.login) }) it('should include "Password" in popupItems but not in defaultItems', () => { const { result } = renderHook(() => useRecordMenuItems()) const passwordInPopup = result.current.popupItems.find( (item) => item.type === 'password' ) const passwordInDefault = result.current.defaultItems.find( (item) => item.type === 'password' ) expect(passwordInPopup).toBeTruthy() expect(passwordInPopup.name).toBe('Password') expect(passwordInDefault).toBeFalsy() }) it('should call i18n._ for all item names', () => { renderHook(() => useRecordMenuItems()) expect(mockI18n._).toHaveBeenCalledWith('Login') expect(mockI18n._).toHaveBeenCalledWith('Identity') expect(mockI18n._).toHaveBeenCalledWith('Credit Card') expect(mockI18n._).toHaveBeenCalledWith('Wi-Fi') expect(mockI18n._).toHaveBeenCalledWith('Recovery phrase') expect(mockI18n._).toHaveBeenCalledWith('Note') expect(mockI18n._).toHaveBeenCalledWith('Custom') expect(mockI18n._).toHaveBeenCalledWith('All') expect(mockI18n._).toHaveBeenCalledWith('Password') }) }) ================================================ FILE: src/hooks/useRecordMenuItemsV2.test.ts ================================================ import { renderHook } from '@testing-library/react' import { ALL_ITEMS_TYPE, useRecordMenuItemsV2 } from './useRecordMenuItemsV2' jest.mock('./useTranslation', () => ({ useTranslation: () => ({ t: (s: string) => s }) })) jest.mock('@tetherto/pearpass-lib-vault', () => ({ RECORD_TYPES: { LOGIN: 'login', IDENTITY: 'identity', CREDIT_CARD: 'credit_card', NOTE: 'note', CUSTOM: 'custom', WIFI_PASSWORD: 'wifi_password', PASS_PHRASE: 'pass_phrase' } })) const iconStub = () => null jest.mock('@tetherto/pearpass-lib-ui-kit/icons', () => ({ AccountCircleFilled: iconStub, AccountCircleOutlined: iconStub, AssignmentInd: iconStub, CreditCard: iconStub, FormatQuote: iconStub, GridView: iconStub, LayerFilled: iconStub, Layers: iconStub, Note: iconStub, WiFi: iconStub })) describe('useRecordMenuItemsV2', () => { it('returns categoriesItems and defaultItems', () => { const { result } = renderHook(() => useRecordMenuItemsV2()) expect(result.current).toHaveProperty('categoriesItems') expect(result.current).toHaveProperty('defaultItems') }) it('prepends ALL_ITEMS_TYPE ("All Items") to categoriesItems', () => { const { result } = renderHook(() => useRecordMenuItemsV2()) expect(result.current.categoriesItems[0]).toMatchObject({ type: ALL_ITEMS_TYPE, label: 'All Items' }) }) it('orders defaultItems to match the Figma sidebar order', () => { const { result } = renderHook(() => useRecordMenuItemsV2()) expect(result.current.defaultItems.map((i) => i.type)).toEqual([ 'login', 'credit_card', 'identity', 'note', 'pass_phrase', 'wifi_password', 'custom' ]) }) it('uses pluralized labels', () => { const { result } = renderHook(() => useRecordMenuItemsV2()) const labels = Object.fromEntries( result.current.defaultItems.map((i) => [i.type, i.label]) ) expect(labels).toMatchObject({ login: 'Logins', credit_card: 'Credit Card', identity: 'Identities', note: 'Notes', pass_phrase: 'Recovery Phrases', wifi_password: 'Wi-Fi', custom: 'Other' }) }) it('provides both OutlinedIcon and FilledIcon per item', () => { const { result } = renderHook(() => useRecordMenuItemsV2()) for (const item of result.current.categoriesItems) { expect(typeof item.OutlinedIcon).toBe('function') expect(typeof item.FilledIcon).toBe('function') } }) }) ================================================ FILE: src/hooks/useRecordMenuItemsV2.ts ================================================ import React, { useMemo } from 'react' import { RECORD_TYPES } from '@tetherto/pearpass-lib-vault' import { AccountCircleFilled, AccountCircleOutlined, AssignmentInd, CreditCard, FormatQuote, GridView, LayerFilled, Layers, Note, WiFi } from '@tetherto/pearpass-lib-ui-kit/icons' import { useTranslation } from './useTranslation' type IconComponent = React.ComponentType> export type RecordMenuItemV2 = { type: string label: string OutlinedIcon: IconComponent FilledIcon: IconComponent } export const ALL_ITEMS_TYPE = 'all' export const useRecordMenuItemsV2 = () => { const { t } = useTranslation() const defaultItems: RecordMenuItemV2[] = useMemo( () => [ { type: RECORD_TYPES.LOGIN, label: t('Logins'), OutlinedIcon: AccountCircleOutlined, FilledIcon: AccountCircleFilled }, { type: RECORD_TYPES.CREDIT_CARD, label: t('Credit Card'), OutlinedIcon: CreditCard, FilledIcon: CreditCard }, { type: RECORD_TYPES.IDENTITY, label: t('Identities'), OutlinedIcon: AssignmentInd, FilledIcon: AssignmentInd }, { type: RECORD_TYPES.NOTE, label: t('Notes'), OutlinedIcon: Note, FilledIcon: Note }, { type: RECORD_TYPES.PASS_PHRASE, label: t('Recovery Phrases'), OutlinedIcon: FormatQuote, FilledIcon: FormatQuote }, { type: RECORD_TYPES.WIFI_PASSWORD, label: t('Wi-Fi'), OutlinedIcon: WiFi, FilledIcon: WiFi }, { type: RECORD_TYPES.CUSTOM, label: t('Other'), OutlinedIcon: GridView, FilledIcon: GridView } ], [t] ) const categoriesItems: RecordMenuItemV2[] = useMemo( () => [ { type: ALL_ITEMS_TYPE, label: t('All Items'), OutlinedIcon: Layers, FilledIcon: LayerFilled }, ...defaultItems ], [t, defaultItems] ) return { categoriesItems, defaultItems } } ================================================ FILE: src/hooks/useRiveWithRetry.ts ================================================ import { useRive } from '@rive-app/react-webgl2' import { UseRiveParameters, UseRiveOptions, RiveState } from '@rive-app/react-webgl2' import { useState, useCallback, useMemo } from 'react' const MAX_RETRIES = 20 const RETRY_DELAY_MS = 1000 type UseRiveWithRetryReturn = RiveState & { key: string } export const useRiveWithRetry = (params: { riveParams: UseRiveParameters riveOptions?: Partial }): UseRiveWithRetryReturn => { const { riveParams, riveOptions } = params const [retryCount, setRetryCount] = useState(0) const handleLoadError = useCallback( (_err: unknown): void => { if (retryCount < MAX_RETRIES) { setTimeout(() => { setRetryCount((prev) => prev + 1) }, RETRY_DELAY_MS) } }, [retryCount] ) const modifiedParams = useMemo((): UseRiveParameters => { if (!riveParams) return null return { ...riveParams, onLoadError: handleLoadError, } }, [riveParams, handleLoadError]) const riveState = useRive(modifiedParams, riveOptions) const key = useMemo((): string => `retry-rive-${retryCount}`, [retryCount]) return { ...riveState, key } } ================================================ FILE: src/hooks/useScrollOverflow.test.js ================================================ import { useRef } from 'react' import { renderHook, act } from '@testing-library/react' import { useScrollOverflow } from './useScrollOverflow' function MockResizeObserver(callback) { this.callback = callback this.observed = new Set() MockResizeObserver.lastInstance = this } MockResizeObserver.prototype.observe = function (el) { this.observed.add(el) } MockResizeObserver.prototype.disconnect = function () { this.observed.clear() } MockResizeObserver.prototype.fire = function () { this.callback([], this) } MockResizeObserver.lastInstance = null function MockMutationObserver(callback) { this.callback = callback MockMutationObserver.lastInstance = this } MockMutationObserver.prototype.observe = function () {} MockMutationObserver.prototype.disconnect = function () {} MockMutationObserver.prototype.fire = function () { this.callback([], this) } MockMutationObserver.lastInstance = null const setRect = (el, rect) => { el.getBoundingClientRect = () => ({ top: 0, left: 0, right: 0, width: 0, height: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}), ...rect }) } const setClientHeight = (el, h) => { Object.defineProperty(el, 'clientHeight', { configurable: true, value: h }) } const buildContainer = ({ containerHeight, lastChildBottom, childCount = 1 }) => { const container = document.createElement('div') setRect(container, { top: 0, bottom: containerHeight, height: containerHeight }) setClientHeight(container, containerHeight) container.scrollTop = 0 for (let i = 0; i < childCount - 1; i++) { container.appendChild(document.createElement('div')) } const last = document.createElement('div') setRect(last, { top: lastChildBottom - 20, bottom: lastChildBottom }) container.appendChild(last) return container } describe('useScrollOverflow', () => { const realResizeObserver = global.ResizeObserver const realMutationObserver = global.MutationObserver beforeEach(() => { global.ResizeObserver = MockResizeObserver global.MutationObserver = MockMutationObserver MockResizeObserver.lastInstance = null MockMutationObserver.lastInstance = null }) afterEach(() => { global.ResizeObserver = realResizeObserver global.MutationObserver = realMutationObserver }) const renderWith = (container, deps = []) => renderHook(() => { const ref = useRef(container) return useScrollOverflow(ref, deps) }) it('returns false when the container has no children', () => { const container = document.createElement('div') setClientHeight(container, 100) setRect(container, { bottom: 100, height: 100 }) const { result } = renderWith(container) expect(result.current).toBe(false) }) it('returns false when the last child fits within the container', () => { const container = buildContainer({ containerHeight: 220, lastChildBottom: 200 }) const { result } = renderWith(container) expect(result.current).toBe(false) }) it('returns true when the last child extends past the container height', () => { const container = buildContainer({ containerHeight: 220, lastChildBottom: 400 }) const { result } = renderWith(container) expect(result.current).toBe(true) }) it('ignores absolutely-positioned descendants that inflate scrollHeight', () => { // The defining test: emulate what a favorite badge does. scrollHeight is // huge, but the last child (an actual list row) sits within the viewport. const container = buildContainer({ containerHeight: 220, lastChildBottom: 180 }) Object.defineProperty(container, 'scrollHeight', { configurable: true, value: 9999 }) const { result } = renderWith(container) expect(result.current).toBe(false) }) it('treats sub-pixel overhang as non-overflow', () => { const container = buildContainer({ containerHeight: 220, lastChildBottom: 220.3 }) const { result } = renderWith(container) expect(result.current).toBe(false) }) it('re-measures when ResizeObserver fires', () => { const container = buildContainer({ containerHeight: 220, lastChildBottom: 200 }) const { result } = renderWith(container) expect(result.current).toBe(false) // Simulate a child resize that pushes the last row past the bottom. const last = container.lastElementChild setRect(last, { top: 280, bottom: 300 }) act(() => { MockResizeObserver.lastInstance.fire() }) expect(result.current).toBe(true) }) it('re-measures when MutationObserver fires (children added)', () => { const container = buildContainer({ containerHeight: 220, lastChildBottom: 200 }) const { result } = renderWith(container) expect(result.current).toBe(false) const newLast = document.createElement('div') setRect(newLast, { top: 380, bottom: 400 }) container.appendChild(newLast) act(() => { MockMutationObserver.lastInstance.fire() }) expect(result.current).toBe(true) }) it('disconnects observers on unmount', () => { const container = buildContainer({ containerHeight: 220, lastChildBottom: 200 }) const ro = [] const mo = [] global.ResizeObserver = function (cb) { MockResizeObserver.call(this, cb) this.disconnected = false ro.push(this) } global.ResizeObserver.prototype = Object.create( MockResizeObserver.prototype ) global.ResizeObserver.prototype.disconnect = function () { MockResizeObserver.prototype.disconnect.call(this) this.disconnected = true } global.MutationObserver = function (cb) { MockMutationObserver.call(this, cb) this.disconnected = false mo.push(this) } global.MutationObserver.prototype = Object.create( MockMutationObserver.prototype ) global.MutationObserver.prototype.disconnect = function () { this.disconnected = true } const { unmount } = renderWith(container) unmount() expect(ro.every((o) => o.disconnected)).toBe(true) expect(mo.every((o) => o.disconnected)).toBe(true) }) }) ================================================ FILE: src/hooks/useScrollOverflow.ts ================================================ import { RefObject, useLayoutEffect, useState } from 'react' const SUBPIXEL_TOLERANCE = 0.5 const measureOverflow = (element: HTMLElement): boolean => { // Use last child's rect, not scrollHeight: absolutely positioned descendants // (e.g. favorite badges) inflate scrollHeight and produce false positives. const last = element.lastElementChild if (!last) return false const elementRect = element.getBoundingClientRect() const lastRect = last.getBoundingClientRect() const lastBottomInContent = lastRect.bottom - elementRect.top + element.scrollTop return lastBottomInContent - element.clientHeight > SUBPIXEL_TOLERANCE } export const useScrollOverflow = ( ref: RefObject, deps: ReadonlyArray = [] ): boolean => { const [hasOverflow, setHasOverflow] = useState(false) useLayoutEffect(() => { const element = ref.current if (!element) { setHasOverflow(false) return } const check = () => setHasOverflow(measureOverflow(element)) check() const ro = new ResizeObserver(check) ro.observe(element) // Also observe children: container is clamped at maxHeight, so its own // box doesn't resize when children reflow (e.g. favicon load). const observeChildren = () => { for (const child of Array.from(element.children)) { ro.observe(child) } } observeChildren() const mo = new MutationObserver(() => { observeChildren() check() }) mo.observe(element, { childList: true }) return () => { ro.disconnect() mo.disconnect() } }, deps) return hasOverflow } ================================================ FILE: src/hooks/useSimulatedLoading.js ================================================ import { useState, useEffect } from 'react' /** * @param {number} duration * @returns {boolean} */ export const useSimulatedLoading = (duration = 2000) => { const [loading, setLoading] = useState(true) useEffect(() => { const timer = setTimeout(() => { setLoading(false) }, duration) return () => clearTimeout(timer) }, [duration]) return loading } ================================================ FILE: src/hooks/useSimulatedLoading.test.js ================================================ import { renderHook, act } from '@testing-library/react' import { useSimulatedLoading } from './useSimulatedLoading' describe('useSimulatedLoading', () => { beforeEach(() => { jest.useFakeTimers() }) afterEach(() => { jest.useRealTimers() }) it('should initially return true', () => { const { result } = renderHook(() => useSimulatedLoading()) expect(result.current).toBe(true) }) it('should return false after the duration', () => { const { result } = renderHook(() => useSimulatedLoading(1000)) expect(result.current).toBe(true) act(() => { jest.advanceTimersByTime(1000) }) expect(result.current).toBe(false) }) it('should use the default duration when not provided', () => { const { result } = renderHook(() => useSimulatedLoading()) expect(result.current).toBe(true) act(() => { jest.advanceTimersByTime(1999) }) expect(result.current).toBe(true) act(() => { jest.advanceTimersByTime(1) }) expect(result.current).toBe(false) }) it('should clear timeout on unmount', () => { const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout') const { unmount } = renderHook(() => useSimulatedLoading()) unmount() expect(clearTimeoutSpy).toHaveBeenCalled() }) }) ================================================ FILE: src/hooks/useTranslation.ts ================================================ import { useCallback } from 'react' import { useLingui } from '@lingui/react' /** Values for ICU-style placeholders in messages, e.g. `t('Hello {name}', { name: 'Ada' })` */ export type TranslationValues = Record /** * Custom hook for handling translations using Lingui * @returns {Object} Object containing the translation function * @returns {Function} t - Translation function: `t(key)` or `t(key, values)` for interpolated messages */ export const useTranslation = () => { const { i18n } = useLingui() const t = useCallback( (key: string, values?: TranslationValues): string => { if (values === undefined) { return i18n._(key) } return i18n._(key, values) }, [i18n] ) return { t } } ================================================ FILE: src/hooks/useVaultSwitch.test.tsx ================================================ import React from 'react' import { act, renderHook } from '@testing-library/react' import '@testing-library/jest-dom' import { type Vault, useVault } from '@tetherto/pearpass-lib-vault' import { useVaultSwitch } from './useVaultSwitch' const mockSetIsLoading = jest.fn() const mockSetModal = jest.fn() const mockCloseModal = jest.fn() const mockRefetchVault = jest.fn(() => Promise.resolve()) const mockIsVaultProtected = jest.fn(() => Promise.resolve(false)) const mockLoggerError = jest.fn() const vaultA: Vault = { id: 'vault-a', name: 'Alpha' } const vaultB: Vault = { id: 'vault-b', name: 'Bravo' } const mockUseVault = jest.mocked(useVault) jest.mock('@tetherto/pearpass-lib-vault', () => ({ useVault: jest.fn() })) jest.mock('../context/LoadingContext', () => ({ useLoadingContext: () => ({ setIsLoading: mockSetIsLoading }) })) jest.mock('../context/ModalContext', () => ({ useModal: () => ({ setModal: mockSetModal, closeModal: mockCloseModal }) })) jest.mock('../utils/logger', () => ({ logger: { error: (...args: unknown[]) => mockLoggerError(...args) } })) jest.mock('../containers/Modal/VaultPasswordFormModalContent', () => ({ VaultPasswordFormModalContent: () => null })) describe('useVaultSwitch', () => { beforeEach(() => { jest.clearAllMocks() mockIsVaultProtected.mockResolvedValue(false) mockRefetchVault.mockResolvedValue(undefined) mockUseVault.mockReturnValue({ data: vaultA, isVaultProtected: mockIsVaultProtected, refetch: mockRefetchVault } as unknown as ReturnType) }) it('returns switchVault', () => { const { result } = renderHook(() => useVaultSwitch()) expect(result.current).toEqual({ switchVault: expect.any(Function) }) }) it('when target is already active, runs onSuccess only (no refetch or password check)', async () => { const onSuccess = jest.fn(async () => {}) const { result } = renderHook(() => useVaultSwitch()) await act(async () => { await result.current.switchVault(vaultA, onSuccess) }) expect(mockIsVaultProtected).not.toHaveBeenCalled() expect(mockRefetchVault).not.toHaveBeenCalled() expect(mockSetModal).not.toHaveBeenCalled() expect(onSuccess).toHaveBeenCalledTimes(1) expect(mockSetIsLoading).toHaveBeenCalledWith(true) expect(mockSetIsLoading).toHaveBeenCalledWith(false) }) it('when target is another vault and unprotected, refetches then runs onSuccess', async () => { const onSuccess = jest.fn<() => void | Promise>() const { result } = renderHook(() => useVaultSwitch()) await act(async () => { await result.current.switchVault(vaultB, onSuccess) }) expect(mockIsVaultProtected).toHaveBeenCalledWith('vault-b') expect(mockRefetchVault).toHaveBeenCalledWith('vault-b') expect(mockRefetchVault).toHaveBeenCalledTimes(1) expect(onSuccess).toHaveBeenCalledTimes(1) expect(mockSetModal).not.toHaveBeenCalled() expect(mockSetIsLoading).toHaveBeenCalledWith(false) }) it('when vault is protected, opens password modal instead of refetching immediately', async () => { mockIsVaultProtected.mockResolvedValueOnce(true) const onSuccess = jest.fn<() => void | Promise>() const { result } = renderHook(() => useVaultSwitch()) await act(async () => { await result.current.switchVault(vaultB, onSuccess) }) expect(mockSetModal).toHaveBeenCalledTimes(1) expect(mockRefetchVault).not.toHaveBeenCalled() expect(onSuccess).not.toHaveBeenCalled() const modalEl = mockSetModal.mock.calls[0][0] as React.ReactElement<{ vault: Vault onSubmit: (password: string) => Promise }> expect(modalEl.props.vault).toEqual(vaultB) expect(typeof modalEl.props.onSubmit).toBe('function') }) it('when protected, modal onSubmit refetches with password, closes modal, and runs onSuccess', async () => { mockIsVaultProtected.mockResolvedValueOnce(true) const onSuccess = jest.fn<() => void | Promise>() const { result } = renderHook(() => useVaultSwitch()) await act(async () => { await result.current.switchVault(vaultB, onSuccess) }) const modalEl = mockSetModal.mock.calls[0][0] as React.ReactElement<{ onSubmit: (password: string) => Promise }> await act(async () => { await modalEl.props.onSubmit('secret-password') }) expect(mockRefetchVault).toHaveBeenCalledWith('vault-b', { password: 'secret-password' }) expect(mockCloseModal).toHaveBeenCalled() expect(onSuccess).toHaveBeenCalledTimes(1) }) it('logs and rethrows when refetch fails for an unprotected vault', async () => { const err = new Error('network') mockRefetchVault.mockRejectedValueOnce(err) const { result } = renderHook(() => useVaultSwitch()) let thrown: unknown const noopSuccess = async () => {} await act(async () => { try { await result.current.switchVault(vaultB, noopSuccess) } catch (e) { thrown = e } }) expect(thrown).toBe(err) expect(mockLoggerError).toHaveBeenCalledWith( 'useVaultSwitch', 'Error switching to vault:', err ) }) }) ================================================ FILE: src/hooks/useVaultSwitch.tsx ================================================ import React, { useCallback } from 'react' import { useVault, type Vault } from '@tetherto/pearpass-lib-vault' import { VaultPasswordFormModalContent } from '../containers/Modal/VaultPasswordFormModalContent' import { useLoadingContext } from '../context/LoadingContext' import { useModal } from '../context/ModalContext' import { logger } from '../utils/logger' /** * Switch active vault with the same flow everywhere: optional password modal * when the vault is protected, then {@link refetch} and an optional success callback. */ export function useVaultSwitch() { const { setIsLoading } = useLoadingContext() const { setModal, closeModal } = useModal() const { data: activeVault, isVaultProtected, refetch: refetchVault } = useVault() const switchVault = useCallback( async ( vault: Vault, onSuccess: () => void | Promise = async () => {} ) => { setIsLoading(true) try { if (vault.id === activeVault?.id) { await onSuccess() return } const isProtected = await isVaultProtected(vault.id) if (isProtected) { setModal( { setIsLoading(true) try { await refetchVault(vault.id, { password }) closeModal() await onSuccess() } finally { setIsLoading(false) } }} /> ) return } await refetchVault(vault.id) await onSuccess() } catch (error) { logger.error('useVaultSwitch', 'Error switching to vault:', error) throw error } finally { setIsLoading(false) } }, [activeVault?.id, closeModal, isVaultProtected, setIsLoading, setModal] ) return { switchVault } } ================================================ FILE: src/hooks/useWindowResize.js ================================================ import { useState, useEffect } from 'react' import { useThrottle } from '@tetherto/pear-apps-lib-ui-react-hooks' /** * Hook to get the window size with throttling. * @param {number} delay - The throttle delay in milliseconds. * @returns {{ width: number, height: number }} - The throttled window size. */ export const useWindowResize = (interval = 350) => { const [windowSize, setWindowSize] = useState({ width: window.innerWidth, height: window.innerHeight }) const { throttle } = useThrottle({ value: windowSize, interval: interval }) useEffect(() => { const handleResize = () => { throttle(() => { setWindowSize({ width: window.innerWidth, height: window.innerHeight }) }) } window.addEventListener('resize', handleResize) return () => { window.removeEventListener('resize', handleResize) } }, [throttle]) return windowSize } ================================================ FILE: src/hooks/useWindowResize.test.js ================================================ import { renderHook, act } from '@testing-library/react' import { useThrottle } from '@tetherto/pear-apps-lib-ui-react-hooks' import { useWindowResize } from './useWindowResize' jest.mock('@tetherto/pear-apps-lib-ui-react-hooks', () => ({ useThrottle: jest.fn() })) describe('useWindowResize', () => { const originalInnerWidth = window.innerWidth const originalInnerHeight = window.innerHeight const mockThrottle = jest.fn((callback) => callback()) beforeEach(() => { jest.clearAllMocks() useThrottle.mockReturnValue({ throttle: mockThrottle, value: { width: window.innerWidth, height: window.innerHeight } }) Object.defineProperty(window, 'innerWidth', { writable: true, value: originalInnerWidth }) Object.defineProperty(window, 'innerHeight', { writable: true, value: originalInnerHeight }) }) afterAll(() => { Object.defineProperty(window, 'innerWidth', { writable: true, value: originalInnerWidth }) Object.defineProperty(window, 'innerHeight', { writable: true, value: originalInnerHeight }) }) test('should initialize with current window dimensions', () => { const { result } = renderHook(() => useWindowResize()) expect(result.current).toEqual({ width: window.innerWidth, height: window.innerHeight }) }) test('should update dimensions when window resizes', () => { const { result } = renderHook(() => useWindowResize()) act(() => { Object.defineProperty(window, 'innerWidth', { writable: true, value: 1024 }) Object.defineProperty(window, 'innerHeight', { writable: true, value: 768 }) window.dispatchEvent(new Event('resize')) }) expect(mockThrottle).toHaveBeenCalled() expect(result.current).toEqual({ width: 1024, height: 768 }) }) test('should use the provided interval for throttling', () => { const customInterval = 500 renderHook(() => useWindowResize(customInterval)) expect(useThrottle).toHaveBeenCalledWith({ value: expect.any(Object), interval: customInterval }) }) test('should use default interval of 350ms when not specified', () => { renderHook(() => useWindowResize()) expect(useThrottle).toHaveBeenCalledWith({ value: expect.any(Object), interval: 350 }) }) test('should remove event listener on unmount', () => { const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener') const { unmount } = renderHook(() => useWindowResize()) unmount() expect(removeEventListenerSpy).toHaveBeenCalledWith( 'resize', expect.any(Function) ) }) }) ================================================ FILE: src/lib-react-components/components/ButtonCreate/index.js ================================================ import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' import { html } from 'htm/react' import { ButtonContainer, IconWrapper } from './styles' /** * @param {{ * startIcon?: import('react').ElementType, * endIcon?: import('react').ElementType, * children: import('react').ReactNode, * type?: 'button' | 'submit', * onClick: () => void, * testId?: string * }} props */ export const ButtonCreate = ({ startIcon, endIcon, children, type = 'button', onClick, testId }) => html` <${ButtonContainer} onClick=${() => onClick()} type=${type} data-testid=${testId} > <${IconWrapper}> ${startIcon && html`<${startIcon} color=${colors.black.mode1} size="24" />`} ${children} <${IconWrapper}> ${endIcon && html`<${endIcon} color=${colors.black.mode1} size="24" />`} ` ================================================ FILE: src/lib-react-components/components/ButtonCreate/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { ButtonCreate } from './index' import { ArrowDownIcon } from '../../icons/ArrowDownIcon' import '@testing-library/jest-dom' const DummyIcon = ArrowDownIcon describe('ButtonCreate Component', () => { test('renders correctly with children and onClick handler', () => { const handleClick = jest.fn() const { getByText } = render( Click Me ) const buttonText = getByText('Click Me') expect(buttonText).toBeInTheDocument() fireEvent.click(buttonText) expect(handleClick).toHaveBeenCalledTimes(1) }) test('renders with startIcon and endIcon correctly', () => { const handleClick = jest.fn() const { getByText, container } = render( Icon Button ) expect(getByText('Icon Button')).toBeInTheDocument() const icons = container.querySelectorAll('svg') expect(icons.length).toBe(2) icons.forEach((icon) => { expect(icon.getAttribute('width')).toBe('24') expect(icon.getAttribute('height')).toBe('24') }) }) test('matches snapshot', () => { const handleClick = jest.fn() const { container } = render( Snapshot Button ) expect(container.firstChild).toMatchSnapshot() }) test('renders with testId attribute', () => { const handleClick = jest.fn() const { getByTestId } = render( Test Button ) expect(getByTestId('test-button-id')).toBeInTheDocument() }) }) ================================================ FILE: src/lib-react-components/components/ButtonCreate/styles.js ================================================ import styled from 'styled-components' export const ButtonContainer = styled.button` display: flex; justify-content: space-between; align-items: center; position: relative; width: 100%; padding: 8px; background: ${({ theme }) => theme.colors.primary300.mode1}; border: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; border-radius: 10px; cursor: pointer; font-family: 'Inter'; font-size: 12px; font-weight: 600; &:hover { background: ${({ theme }) => theme.colors.primary400.mode1}; } ` export const IconWrapper = styled.div` display: flex; ` ================================================ FILE: src/lib-react-components/components/ButtonFilter/index.js ================================================ import { html } from 'htm/react' import { Button } from './styles' /** * @param {{ * children: import('react').ReactNode * variant?: 'primary' | 'secondary' * startIcon?: import('react').ElementType * isDisabled?: boolean * type?: 'button' | 'submit' * onClick: () => void, * testId?: string * }} props */ export const ButtonFilter = ({ children, startIcon, variant = 'primary', type = 'button', isDisabled, onClick, testId }) => { const handleClick = isDisabled ? () => {} : onClick return html` <${Button} variant=${variant} isDisabled=${isDisabled} type=${type} onClick=${handleClick} data-testid=${testId} aria-disabled=${isDisabled} > ${startIcon && html`<${startIcon} size="24" />`} ${children} ` } ================================================ FILE: src/lib-react-components/components/ButtonFilter/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { ButtonFilter } from './index' import { ArrowDownIcon } from '../../icons/ArrowDownIcon' import '@testing-library/jest-dom' const DummyIcon = ArrowDownIcon describe('ButtonFilter Component', () => { test('renders correctly with children and onClick handler', () => { const handleClick = jest.fn() const { getByText } = render( Filter Me ) const buttonText = getByText('Filter Me') expect(buttonText).toBeInTheDocument() fireEvent.click(buttonText) expect(handleClick).toHaveBeenCalledTimes(1) }) test('renders with startIcon correctly for primary variant', () => { const handleClick = jest.fn() const { container } = render( Primary Filter ) const dummyIcon = container.querySelector('svg') expect(dummyIcon).toBeInTheDocument() expect(dummyIcon.getAttribute('width')).toBe('24') expect(dummyIcon.getAttribute('height')).toBe('24') }) test('renders with startIcon correctly for secondary variant', () => { const handleClick = jest.fn() const { container } = render( Secondary Filter ) const dummyIcon = container.querySelector('svg') expect(dummyIcon).toBeInTheDocument() expect(dummyIcon.getAttribute('width')).toBe('24') expect(dummyIcon.getAttribute('height')).toBe('24') }) test('matches snapshot', () => { const handleClick = jest.fn() const { container } = render( Snapshot Filter ) expect(container.firstChild).toMatchSnapshot() }) test('does not call onClick when disabled', () => { const handleClick = jest.fn() const { getByText } = render( Disabled Filter ) const buttonText = getByText('Disabled Filter') fireEvent.click(buttonText) expect(handleClick).not.toHaveBeenCalled() }) }) ================================================ FILE: src/lib-react-components/components/ButtonFilter/styles.js ================================================ import styled, { css } from 'styled-components' const getDisabledGradient = (theme) => `linear-gradient(0deg, rgba(5, 11, 6, 0.40) 0%, rgba(5, 11, 6, 0.40) 100%), ${theme.colors.secondary200.mode1};` export const Button = styled.div.withConfig({ shouldForwardProp: (prop) => !['isDisabled'].includes(prop) })` display: inline-flex; align-items: center; gap: 6px; font-family: 'Inter'; font-size: 12px; font-weight: 400; cursor: pointer; pointer-events: ${({ isDisabled }) => (isDisabled ? 'none' : 'auto')}; ${({ variant, theme, isDisabled }) => { if (variant === 'primary') { return css` border-radius: 10px; background: ${isDisabled ? getDisabledGradient(theme) : theme.colors.secondary200.mode1}; padding: 4px; color: ${theme.colors.secondary400.mode1}; & svg path { fill: ${theme.colors.secondary400.mode1}; } &:hover { background: ${theme.colors.secondary100.mode1}; } ` } if (variant === 'secondary') { return css` border-radius: 10px; background: ${theme.colors.grey500.mode1}; padding: 5px 8px; color: ${theme.colors.white.mode1}; & svg path { fill: ${theme.colors.white.mode1}; } &:hover { background: ${theme.colors.grey100.mode1}; color: ${theme.colors.black.mode1}; & svg path { fill: ${theme.colors.black.mode1}; } } ` } }}; ` ================================================ FILE: src/lib-react-components/components/ButtonFolder/index.js ================================================ import { html } from 'htm/react' import { Button } from './styles' import { FolderIcon } from '../../icons/FolderIcon' /** * @param {{ * children: import('react').ReactNode * type?: 'button' | 'submit' * onClick: () => void * }} props */ export const ButtonFolder = ({ children, type = 'button', onClick }) => html` <${Button} onClick=${onClick} type=${type}> ${html`<${FolderIcon} size="21" />`} ${children} ` ================================================ FILE: src/lib-react-components/components/ButtonFolder/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { ButtonFolder } from './index' import '@testing-library/jest-dom' describe('ButtonFolder Component', () => { test('renders correctly with children and onClick handler', () => { const handleClick = jest.fn() const { getByText } = render( Open Folder ) const buttonText = getByText('Open Folder') expect(buttonText).toBeInTheDocument() fireEvent.click(buttonText) expect(handleClick).toHaveBeenCalledTimes(1) }) test('renders FolderIcon with correct size', () => { const handleClick = jest.fn() const { container } = render( Open Folder ) const svg = container.querySelector('svg') expect(svg).toBeInTheDocument() expect(svg.getAttribute('width')).toBe('21') expect(svg.getAttribute('height')).toBe('21') }) test('matches snapshot', () => { const handleClick = jest.fn() const { container } = render( Snapshot Folder ) expect(container.firstChild).toMatchSnapshot() }) }) ================================================ FILE: src/lib-react-components/components/ButtonFolder/styles.js ================================================ import styled from 'styled-components' export const Button = styled.div` display: inline-flex; align-items: center; justify-content: center; padding: 10px; gap: 10px; border-radius: 10px; cursor: pointer; font-family: 'Inter'; font-size: 16px; font-weight: 500; background: 'transparent'; color: ${({ theme }) => theme.colors.white.mode1}; border: 1px solid ${({ theme }) => theme.colors.primary400.mode1}; & svg path { fill: ${({ theme }) => theme.colors.white.mode1}; } &:hover { border: 1px solid ${({ theme }) => theme.colors.primary300.mode1}; background: ${({ theme }) => theme.colors.primary300.mode1}; color: ${({ theme }) => theme.colors.black.mode1}; & svg path { fill: ${({ theme }) => theme.colors.black.mode1}; } } &:active { border: 1px solid ${({ theme }) => theme.colors.primary400.mode1}; background: ${({ theme }) => theme.colors.primary400.mode1}; color: ${({ theme }) => theme.colors.black.mode1}; & svg path { fill: ${({ theme }) => theme.colors.black.mode1}; } } ` ================================================ FILE: src/lib-react-components/components/ButtonLittle/index.js ================================================ import { html } from 'htm/react' import { Button } from './styles' /** * @param {{ * children: import('react').ReactNode * variant?: 'primary' | 'secondary' * startIcon?: import('react').ElementType * type?: 'button' | 'submit' * onClick: () => void, * testId?: string, * dataId?: string * }} props */ export const ButtonLittle = ({ children, startIcon, variant = 'primary', type = 'button', onClick, testId, dataId }) => html` <${Button} data-testid=${testId} data-id=${dataId} type=${type} variant=${variant} onClick=${onClick} isIconOnly=${!children} > ${startIcon && html`<${startIcon} size="24px" />`} ${children} ` ================================================ FILE: src/lib-react-components/components/ButtonLittle/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { ButtonLittle } from './index' import { ArrowDownIcon } from '../../icons/ArrowDownIcon' import '@testing-library/jest-dom' const DummyIcon = ArrowDownIcon describe('ButtonLittle Component', () => { test('renders correctly with children and onClick handler', () => { const handleClick = jest.fn() const { getByText } = render( Little Button ) const buttonText = getByText('Little Button') expect(buttonText).toBeInTheDocument() fireEvent.click(buttonText) expect(handleClick).toHaveBeenCalledTimes(1) }) test('renders with startIcon correctly', () => { const handleClick = jest.fn() const { container } = render( Little Button ) const dummyIcon = container.querySelector('svg') expect(dummyIcon).toBeInTheDocument() expect(dummyIcon.getAttribute('width')).toBe('24px') expect(dummyIcon.getAttribute('height')).toBe('24px') }) test('renders as icon-only when no children provided', () => { const handleClick = jest.fn() const { queryByText, container } = render( ) const text = queryByText(/./) expect(text).toBeNull() const dummyIcon = container.querySelector('svg') expect(dummyIcon).toBeInTheDocument() }) test('applies type prop correctly', () => { const handleClick = jest.fn() const { container } = render( Submit Button ) const buttonElement = container.querySelector('button') expect(buttonElement).toBeInTheDocument() expect(buttonElement.getAttribute('type')).toBe('submit') }) test('matches snapshot', () => { const handleClick = jest.fn() const { container } = render( Snapshot Little Button ) expect(container.firstChild).toMatchSnapshot() }) }) ================================================ FILE: src/lib-react-components/components/ButtonLittle/styles.js ================================================ import styled, { css } from 'styled-components' export const Button = styled.button.withConfig({ shouldForwardProp: (prop) => !['isIconOnly'].includes(prop) })` width: fit-content; display: inline-flex; align-items: center; justify-content: center; border-radius: ${({ isIconOnly }) => (isIconOnly ? '50%' : '10px')}; padding: 4px 10px; gap: 5px; cursor: pointer; font-weight: 500; font-family: 'Inter'; ${({ variant, theme }) => { if (variant === 'primary') { return css` background: ${theme.colors.primary300.mode1}; color: ${theme.colors.black.mode1}; border: 1px solid ${theme.colors.primary300.mode1}; & svg path { fill: ${theme.colors.black.mode1}; } &:hover { border: 1px solid ${theme.colors.primary400.mode1}; background: ${theme.colors.primary400.mode1}; } ` } if (variant === 'secondary') { return css` background: ${theme.colors.black.mode1}; color: ${theme.colors.primary300.mode1}; border: 1px solid ${theme.colors.black.mode1}; & svg path { fill: ${theme.colors.primary300.mode1}; } &:hover { border-color: ${theme.colors.primary400.mode1}; color: ${theme.colors.primary400.mode1}; & svg path { fill: ${theme.colors.primary400.mode1}; } } ` } }}; ` ================================================ FILE: src/lib-react-components/components/ButtonPrimary/index.js ================================================ import { html } from 'htm/react' import { Button } from './styles' /** * @param {{ * children: import('react').ReactNode, * size?: 'sm' | 'md' | 'lg', * onClick: () => void * type?: 'button' | 'submit' * testId?: string * width?: string * }} props */ export const ButtonPrimary = ({ children, size = 'md', onClick, type = 'button', testId = 'button-primary', width }) => html` <${Button} size=${size} onClick=${onClick} type=${type} data-testid=${testId} width=${width} > ${children} ` ================================================ FILE: src/lib-react-components/components/ButtonPrimary/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { ButtonPrimary } from './index' import '@testing-library/jest-dom' describe('ButtonPrimary Component', () => { test('renders correctly with children and onClick handler', () => { const handleClick = jest.fn() const { getByText } = render( Primary Button ) const buttonText = getByText('Primary Button') expect(buttonText).toBeInTheDocument() fireEvent.click(buttonText) expect(handleClick).toHaveBeenCalledTimes(1) }) test('applies type prop correctly', () => { const handleClick = jest.fn() const { container } = render( Submit Button ) const buttonElement = container.querySelector('button') expect(buttonElement).toBeInTheDocument() expect(buttonElement.getAttribute('type')).toBe('submit') }) test('matches snapshot with default size ("md")', () => { const handleClick = jest.fn() const { container } = render( Snapshot Button ) expect(container.firstChild).toMatchSnapshot() }) test('matches snapshot with small size ("sm")', () => { const handleClick = jest.fn() const { container } = render( Small Button ) expect(container.firstChild).toMatchSnapshot() }) test('matches snapshot with large size ("lg")', () => { const handleClick = jest.fn() const { container } = render( Large Button ) expect(container.firstChild).toMatchSnapshot() }) }) ================================================ FILE: src/lib-react-components/components/ButtonPrimary/styles.js ================================================ import styled from 'styled-components' export const Button = styled.button` ${({ width }) => width && `width: ${width};`} box-sizing: border-box; background: ${({ theme }) => theme.colors.primary400.mode1}; color: ${({ theme }) => theme.colors.black.mode1}; padding: 10px 15px; border: none; cursor: pointer; border-radius: 10px; font-size: ${({ size }) => { switch (size) { case 'sm': return '12px' case 'lg': return '16px' default: return '14px' } }}; font-family: 'Inter'; font-weight: 600; line-height: 17px; &:hover { background: ${({ theme }) => theme.colors.primary300.mode1}; } &:active { background: ${({ theme }) => theme.colors.primary500.mode1}; } ` ================================================ FILE: src/lib-react-components/components/ButtonRadio/index.js ================================================ import { ButtonRadio } from './styles' export { ButtonRadio } ================================================ FILE: src/lib-react-components/components/ButtonRadio/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { ButtonRadio } from './index' import '@testing-library/jest-dom' describe('ButtonRadio Component', () => { test('handles onClick event', () => { const handleClick = jest.fn() const { container } = render( ) fireEvent.click(container.querySelector('button')) expect(handleClick).toHaveBeenCalledTimes(1) }) test('renders as a button element', () => { const { container } = render( {}} /> ) expect(container.querySelector('button')).toBeInTheDocument() expect(container.firstChild).toMatchSnapshot() }) }) ================================================ FILE: src/lib-react-components/components/ButtonRadio/styles.js ================================================ import styled from 'styled-components' export const ButtonRadio = styled.button.withConfig({ shouldForwardProp: (prop) => !['isActive'].includes(prop) })` display: inline-flex; align-items: center; justify-content: center; width: 15px; height: 15px; flex-shrink: 0; border-radius: 50%; background: transparent; border: 2px solid ${({ theme, isActive }) => isActive ? theme.colors.primary400.mode1 : theme.colors.primary300.mode1}; padding: 2px; cursor: pointer; &:before { content: ''; width: 100%; height: 100%; border-radius: 50%; background: ${({ theme }) => theme.colors.primary400.mode1}; opacity: ${({ isActive }) => (isActive ? 1 : 0)}; transition: opacity 300ms ease-in-out; } ` ================================================ FILE: src/lib-react-components/components/ButtonRoundIcon/index.js ================================================ import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' import { html } from 'htm/react' import { Button } from './styles' /** * @param {{ * children?: import('react').ReactNode * startIcon: import('react').ElementType * onClick: () => void * iconSize?: string, * testId?: string, * dataId?: string * }} props */ export const ButtonRoundIcon = ({ children, startIcon, onClick, iconSize, testId = 'button-round-icon', dataId }) => html` <${Button} type="button" onClick=${onClick} data-testid=${testId} data-id=${dataId} > ${startIcon && html`<${startIcon} color=${colors.primary400.mode1} size=${iconSize || '24'} />`} ${children} ` ================================================ FILE: src/lib-react-components/components/ButtonRoundIcon/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { ButtonRoundIcon } from './index' import { ArrowDownIcon } from '../../icons/ArrowDownIcon' import '@testing-library/jest-dom' const DummyIcon = ArrowDownIcon describe('ButtonRoundIcon Component', () => { test('renders correctly with children and onClick handler', () => { const handleClick = jest.fn() const { getByText, getByRole } = render( Round Button ) const buttonText = getByText('Round Button') expect(buttonText).toBeInTheDocument() fireEvent.click(getByRole('button')) expect(handleClick).toHaveBeenCalledTimes(1) }) test('renders with startIcon correctly', () => { const handleClick = jest.fn() const { container } = render( Round Button With Icon ) const icons = container.querySelectorAll('svg') expect(icons.length).toBe(1) icons.forEach((icon) => { expect(icon.getAttribute('width') || icon.getAttribute('size')).toBe('24') expect(icon.getAttribute('height') || icon.getAttribute('size')).toBe( '24' ) }) }) test('does not render startIcon if not provided', () => { const { container } = render( {}}>No Icon ) const icons = container.querySelectorAll('svg') expect(icons.length).toBe(0) }) test('matches snapshot for default rendering', () => { const handleClick = jest.fn() const { container } = render( Snapshot Round Button ) expect(container.firstChild).toMatchSnapshot() }) test('renders correctly when children is a React element', () => { const handleClick = jest.fn() const { getByText } = render( Element Child ) expect(getByText('Element Child')).toBeInTheDocument() }) test('button has type="button"', () => { const { getByRole } = render( {}}>Type Button ) expect(getByRole('button')).toHaveAttribute('type', 'button') }) }) ================================================ FILE: src/lib-react-components/components/ButtonRoundIcon/styles.js ================================================ import styled from 'styled-components' export const Button = styled.button` display: inline-flex; width: 30px; height: 30px; padding: 4px; justify-content: center; align-items: center; border: 1px solid ${({ theme }) => theme.colors.black.mode1}; border-radius: 50%; cursor: pointer; background: ${({ theme }) => theme.colors.black.mode1}; color: ${({ theme }) => theme.colors.primary400.mode1}; font-family: 'Inter'; font-size: 12px; font-weight: 500; &:hover { border-color: ${({ theme }) => theme.colors.primary400.mode1}; } ` ================================================ FILE: src/lib-react-components/components/ButtonSecondary/index.js ================================================ import { html } from 'htm/react' import { Button } from './styles' /** * @param {{ * children: import('react').ReactNode, * size?: 'sm' | 'md' | 'lg', * onClick: () => void * type?: 'button' | 'submit' * disabled?: boolean * }} props */ export const ButtonSecondary = ({ children, size = 'md', onClick, type = 'button', disabled = false, testId }) => html` <${Button} size=${size} onClick=${onClick} type=${type} disabled=${disabled} data-testid=${testId} > ${children} ` ================================================ FILE: src/lib-react-components/components/ButtonSecondary/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { ButtonSecondary } from './index' import '@testing-library/jest-dom' describe('ButtonSecondary Component', () => { test('renders correctly with children and onClick handler', () => { const handleClick = jest.fn() const { getByText } = render( Secondary Button ) const buttonText = getByText('Secondary Button') expect(buttonText).toBeInTheDocument() fireEvent.click(buttonText) expect(handleClick).toHaveBeenCalledTimes(1) }) test('applies type prop correctly', () => { const handleClick = jest.fn() const { container } = render( Submit Button ) const buttonElement = container.querySelector('button') expect(buttonElement).toBeInTheDocument() expect(buttonElement.getAttribute('type')).toBe('submit') }) test('matches snapshot with default size ("md")', () => { const handleClick = jest.fn() const { container } = render( Snapshot Button ) expect(container.firstChild).toMatchSnapshot() }) test('matches snapshot with small size ("sm")', () => { const handleClick = jest.fn() const { container } = render( Small Button ) expect(container.firstChild).toMatchSnapshot() }) test('matches snapshot with large size ("lg")', () => { const handleClick = jest.fn() const { container } = render( Large Button ) expect(container.firstChild).toMatchSnapshot() }) }) ================================================ FILE: src/lib-react-components/components/ButtonSecondary/styles.js ================================================ import styled from 'styled-components' export const Button = styled.button` background: transparent; box-sizing: border-box; color: ${({ theme }) => theme.colors.white.mode1}; padding: 10px 15px; border: none; cursor: pointer; border-radius: 10px; border: 1px solid ${({ theme }) => theme.colors.primary400.mode1}; font-size: ${({ size }) => { switch (size) { case 'sm': return '12px' case 'lg': return '16px' default: return '14px' } }}; font-family: 'Inter'; font-weight: 600; line-height: 17px; opacity: ${({ disabled }) => (disabled ? 0.5 : 1)}; pointer-events: ${({ disabled }) => (disabled ? 'none' : 'auto')}; &:hover { background: ${({ theme }) => theme.colors.grey400.mode1}; box-shadow: 0 0 0 1px ${({ theme }) => theme.colors.primary500.mode1}; } &:active { background: ${({ theme }) => theme.colors.black.mode1}; } ` ================================================ FILE: src/lib-react-components/components/ButtonSingleInput/index.js ================================================ import { html } from 'htm/react' import { Button } from './styles' /** * @param {{ * children: import('react').ReactNode * startIcon: import('react').ElementType * variant: 'default' | 'bordered', * rounded: 'default' | 'md' * type?: 'button' | 'submit' * onClick: () => void, * testId?: string * }} props */ export const ButtonSingleInput = ({ children, startIcon, variant = 'default', rounded = 'default', type = 'button', onClick, testId = 'button-single-input' }) => html` <${Button} data-testid=${testId} onClick=${onClick} variant=${variant} rounded=${rounded} type=${type} > ${startIcon && html`<${startIcon} size="24" />`} ${children} ` ================================================ FILE: src/lib-react-components/components/ButtonSingleInput/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { ButtonSingleInput } from './index' import { ArrowDownIcon } from '../../icons/ArrowDownIcon' import '@testing-library/jest-dom' const DummyIcon = ArrowDownIcon describe('ButtonSingleInput Component', () => { test('renders correctly with children and onClick handler', () => { const handleClick = jest.fn() const { getByText } = render( Single Input Button ) const buttonText = getByText('Single Input Button') expect(buttonText).toBeInTheDocument() fireEvent.click(buttonText) expect(handleClick).toHaveBeenCalledTimes(1) }) test('renders startIcon correctly with size "20"', () => { const handleClick = jest.fn() const { container } = render( Single Input Button ) const dummyIcon = container.querySelector('svg') expect(dummyIcon).toBeInTheDocument() expect(dummyIcon.getAttribute('width')).toBe('24') expect(dummyIcon.getAttribute('height')).toBe('24') }) test('matches snapshot with default props', () => { const handleClick = jest.fn() const { container } = render( Snapshot Button ) expect(container.firstChild).toMatchSnapshot() }) test('matches snapshot with variant "bordered" and rounded "md"', () => { const handleClick = jest.fn() const { container } = render( Bordered Button ) expect(container.firstChild).toMatchSnapshot() }) }) ================================================ FILE: src/lib-react-components/components/ButtonSingleInput/styles.js ================================================ import styled from 'styled-components' export const Button = styled.button.withConfig({ shouldForwardProp: (prop) => !['variant'].includes(prop) })` width: fit-content; display: inline-flex; padding: 5px 10px; border: 1px solid ${({ theme, variant }) => variant === 'bordered' ? theme.colors.primary400.mode1 : theme.colors.black.mode1}; align-items: center; gap: 7px; border-radius: 10px; cursor: pointer; background: ${({ theme }) => theme.colors.black.mode1}; color: ${({ theme }) => theme.colors.primary400.mode1}; font-family: 'Inter'; font-size: 12px; font-weight: 500; & svg path { fill: ${({ theme }) => theme.colors.primary400.mode1}; } &:hover { border-color: 1px solid ${({ theme }) => theme.colors.primary400.mode1}; } ` ================================================ FILE: src/lib-react-components/components/ButtonThin/index.js ================================================ import { html } from 'htm/react' import { Button } from './styles' /** * @param {{ * children: import('react').ReactNode * startIcon: import('react').ElementType * endIcon: import('react').ElementType * variant: 'black' | 'grey' * type?: 'button' | 'submit' * onClick: () => void, * testId?: string * }} props */ export const ButtonThin = ({ children, startIcon, endIcon, variant = 'black', type = 'button', onClick, testId = 'button-thin' }) => html` <${Button} data-testid=${testId} variant=${variant} onClick=${onClick} type=${type} > ${startIcon && html`<${startIcon} size="24" />`} ${children} ${endIcon && html`<${endIcon} size="24" />`} ` ================================================ FILE: src/lib-react-components/components/ButtonThin/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { ButtonThin } from './index' import { ArrowDownIcon } from '../../icons/ArrowDownIcon' import '@testing-library/jest-dom' const DummyIcon = ArrowDownIcon describe('ButtonThin Component', () => { test('renders correctly with children and onClick handler', () => { const handleClick = jest.fn() const { getByText } = render( Thin Button ) const buttonText = getByText('Thin Button') expect(buttonText).toBeInTheDocument() fireEvent.click(buttonText) expect(handleClick).toHaveBeenCalledTimes(1) }) test('renders with startIcon and endIcon correctly', () => { const handleClick = jest.fn() const { container } = render( Thin Button With Icons ) const icons = container.querySelectorAll('svg') expect(icons.length).toBe(2) icons.forEach((icon) => { expect(icon.getAttribute('width')).toBe('24') expect(icon.getAttribute('height')).toBe('24') }) }) test('matches snapshot for default variant', () => { const handleClick = jest.fn() const { container } = render( Snapshot Thin Button ) expect(container.firstChild).toMatchSnapshot() }) test('matches snapshot for grey variant', () => { const handleClick = jest.fn() const { container } = render( Grey Thin Button ) expect(container.firstChild).toMatchSnapshot() }) }) ================================================ FILE: src/lib-react-components/components/ButtonThin/styles.js ================================================ import styled, { css } from 'styled-components' export const Button = styled.div` width: fit-content; height: auto; display: inline-flex; padding: 5px 10px; border: 1px solid transparent; align-items: center; gap: 7px; border-radius: 10px; cursor: pointer; ${({ variant, theme }) => { if (variant === 'black') { return css` background: ${theme.colors.black.mode1}; color: ${theme.colors.primary300.mode1}; & svg path { fill: ${theme.colors.primary300.mode1}; } &:hover { border: 1px solid ${theme.colors.primary400.mode1}; color: ${theme.colors.primary400.mode1}; & svg path { fill: ${theme.colors.primary400.mode1}; } } ` } if (variant === 'grey') { return css` background: ${theme.colors.grey300.mode1}; color: ${theme.colors.white.mode1}; ` } }}; ` ================================================ FILE: src/lib-react-components/components/CompoundField/index.js ================================================ import { html } from 'htm/react' import { CompoundFieldComponent } from './styles' /** * @param {{ * children: import('react').ReactNode * isDisabled?: boolean * }} props */ export const CompoundField = ({ children, isDisabled }) => html` <${CompoundFieldComponent} isDisabled=${isDisabled}> ${children} ` ================================================ FILE: src/lib-react-components/components/CompoundField/index.test.js ================================================ import React from 'react' import { render } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { CompoundField } from './index' import '@testing-library/jest-dom' describe('CompoundField Component', () => { test('renders children correctly', () => { const { getByText } = render(
Compound Field Content
) expect(getByText('Compound Field Content')).toBeInTheDocument() }) test('matches snapshot when not disabled', () => { const { container } = render( Compound Field Content ) expect(container.firstChild).toMatchSnapshot() }) test('matches snapshot when disabled', () => { const { container } = render( Compound Field Content ) expect(container.firstChild).toMatchSnapshot() }) }) ================================================ FILE: src/lib-react-components/components/CompoundField/styles.js ================================================ import styled from 'styled-components' export const CompoundFieldComponent = styled.div.withConfig({ shouldForwardProp: (prop) => !['isDisabled'].includes(prop) })` border-radius: 10px; padding: 8px 10px; border: 1px solid ${({ theme }) => theme.colors.grey100.dark}; background-color: ${({ theme }) => theme.colors.grey400.dark}; &:hover { border-color: ${({ theme, isDisabled }) => isDisabled ? theme.colors.grey100.dark : theme.colors.primary300.mode1}; } max-height: 100%; overflow-y: auto; ` ================================================ FILE: src/lib-react-components/components/HighlightString/index.js ================================================ import { html } from 'htm/react' import { HighlightedText, NumberSpan, SymbolSpan } from './styles' /** * * @param {{ * text: string, * testId: string, * }} props */ export const HighlightString = ({ text, testId }) => { const highlightText = (text) => { const regex = /(\d+|[^a-zA-Z\d\s])/g const parts = text.split(regex) return parts.map((part, index) => { if (/^\d+$/.test(part)) { return html`<${NumberSpan} key=${index}>${part}` } if (/[^a-zA-Z\d\s]/.test(part)) { return html`<${SymbolSpan} key=${index}>${part}` } return part }) } return html`<${HighlightedText} data-testid=${testId}> ${highlightText(text)} ` } ================================================ FILE: src/lib-react-components/components/HighlightString/index.test.js ================================================ import React from 'react' import { render } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { HighlightString } from './index' import '@testing-library/jest-dom' describe('HighlightString Component', () => { test('renders plain text without numbers or symbols', () => { const { getByText } = render( ) expect(getByText('Hello world')).toBeInTheDocument() }) test('renders and highlights numbers and symbols correctly', () => { const { container, getByText } = render( ) expect(container).toHaveTextContent('Hello 123!') const numberElement = getByText('123') expect(numberElement).toBeInTheDocument() const symbolElement = getByText('!') expect(symbolElement).toBeInTheDocument() expect(numberElement.tagName.toLowerCase()).toBe('span') expect(symbolElement.tagName.toLowerCase()).toBe('span') }) test('matches snapshot', () => { const { container } = render( ) expect(container.firstChild).toMatchSnapshot() }) }) ================================================ FILE: src/lib-react-components/components/HighlightString/styles.js ================================================ import styled from 'styled-components' export const HighlightedText = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; span { white-space: pre-wrap; } ` export const NumberSpan = styled.span` color: ${({ theme }) => theme.colors.primary400.mode1}; font-weight: bold; ` export const SymbolSpan = styled.span` color: ${({ theme }) => theme.colors.categoryLogin.mode1}; font-weight: bold; ` ================================================ FILE: src/lib-react-components/components/InputField/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { InputField } from './index' import { ArrowDownIcon } from '../../icons/ArrowDownIcon' import '@testing-library/jest-dom' const DummyIcon = ArrowDownIcon describe('InputField Component', () => { test('renders label, placeholder, and error message', () => { const { getByText, getByPlaceholderText } = render( ) expect(getByText('Test Label')).toBeInTheDocument() expect(getByPlaceholderText('Enter text')).toBeInTheDocument() expect(getByText('Error occurred')).toBeInTheDocument() }) test('calls onChange when input changes if not disabled', () => { const handleChange = jest.fn() const { getByPlaceholderText } = render( ) const input = getByPlaceholderText('Enter text') fireEvent.change(input, { target: { value: 'new value' } }) expect(handleChange).toHaveBeenCalledWith('new value') }) test('does not call onChange when disabled', () => { const handleChange = jest.fn() const { getByPlaceholderText } = render( ) const input = getByPlaceholderText('Enter text') fireEvent.change(input, { target: { value: 'should not change' } }) expect(handleChange).not.toHaveBeenCalled() }) test('calls onClick when outer element is clicked and focuses input', () => { const handleClick = jest.fn() const { container, getByPlaceholderText } = render( ) const outerElement = container.firstChild fireEvent.click(outerElement) expect(handleClick).toHaveBeenCalledWith('click test') const input = getByPlaceholderText('Enter text') expect(document.activeElement).toBe(input) }) test('renders overlay correctly: visible when not focused, hidden when focused', async () => { const overlayText = 'Overlay Content' const { queryByText, getByPlaceholderText } = render( ) expect(queryByText(overlayText)).toBeInTheDocument() const input = getByPlaceholderText('Enter text') fireEvent.focus(input) expect(queryByText(overlayText)).not.toBeInTheDocument() }) test('renders icon if provided', () => { const { container } = render( ) const icon = container.querySelector('svg') expect(icon).toBeInTheDocument() }) test('renders additionalItems if provided', () => { const additionalItemText = 'Additional' const { getByText } = render( {additionalItemText}
} /> ) expect(getByText(additionalItemText)).toBeInTheDocument() }) test('matches snapshot for default variant', () => { const { container } = render( ) expect(container.firstChild).toMatchSnapshot() }) test('matches snapshot for outline variant', () => { const { container } = render( ) expect(container.firstChild).toMatchSnapshot() }) test('autoFocus prop focuses input automatically', () => { const { getByPlaceholderText } = render( ) const input = getByPlaceholderText('Enter text') expect(document.activeElement).toBe(input) }) }) ================================================ FILE: src/lib-react-components/components/InputField/index.tsx ================================================ import React, { useRef, useState } from 'react' import { MainWrapper, Label, Input, AdditionalItems, IconWrapper, DefaultInputWrapper, OutlineInputWrapper, NoticeWrapper, InputAreaWrapper, InputOverlay, InsideWrapper } from './styles' import { NoticeText } from '../NoticeText' type InputType = 'text' | 'password' | 'url' type InputVariant = 'default' | 'outline' interface Props { value?: string onChange?: (value: string) => void icon?: React.FC<{ size: string }> label?: string error?: string additionalItems?: React.ReactNode belowInputContent?: React.ReactNode placeholder?: string isDisabled?: boolean onClick?: (value: string) => void type?: InputType variant?: InputVariant overlay?: React.ReactNode autoFocus?: boolean testId?: string dataId?: string onPaste?: (e: React.ClipboardEvent) => void } const InputField = (props: Props): React.ReactElement => { const { value, onChange, icon: Icon, label, error, additionalItems, belowInputContent, placeholder, isDisabled, onClick, type = 'text', variant = 'default', overlay, autoFocus, testId = 'input-field', dataId, onPaste } = props const inputRef = useRef(null) const [isFocused, setIsFocused] = useState(false) const handleChange = (e: React.ChangeEvent): void => { if (isDisabled) { return } onChange?.(e.target.value) } const handleClick = (): void => { inputRef.current?.focus() onClick?.(value || '') if (!isDisabled) { setIsFocused(true) } } const getStyedWrapperByVariant = (): typeof DefaultInputWrapper | typeof OutlineInputWrapper => { if (variant === 'outline') { return OutlineInputWrapper } return DefaultInputWrapper } const StyledWrapper = getStyedWrapperByVariant() return ( {Icon && ( )} setIsFocused(true)} onBlur={() => setIsFocused(false)} type={type} hasOverlay={!!overlay && !isFocused} autoFocus={autoFocus} isDisabled={isDisabled} onPaste={onPaste} /> {!isFocused && {overlay}} {!!error?.length && ( )} {!!additionalItems && ( e.stopPropagation()}> {additionalItems} )} {!!belowInputContent && belowInputContent} ) } export { InputField } ================================================ FILE: src/lib-react-components/components/InputField/styles.ts ================================================ import styled from 'styled-components' interface InputProps { hasOverlay?: boolean isDisabled?: boolean type?: string } export const InputWrapper = styled.div` display: flex; flex-direction: column; align-items: flex-start; gap: 10px; width: 100%; position: relative; ` export const OutlineInputWrapper = styled(InputWrapper)` border: 1px solid; border-color: ${({ theme }) => theme.colors.grey100.mode1}; border-bottom: none; background: ${({ theme }) => theme.colors.grey400.mode1}; margin-top: 0; padding: 8px 10px; &:first-child { border-top-left-radius: 10px; border-top-right-radius: 10px; } &:last-child { border-bottom-left-radius: 10px; border-bottom-right-radius: 10px; border-bottom: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; } &:hover, &:focus-within { border-color: ${({ theme }) => theme.colors.primary400.mode1}; } &:hover + &, &:focus-within + & { border-top-color: ${({ theme }) => theme.colors.primary400.mode1}; } ` export const DefaultInputWrapper = styled(InputWrapper)` &:not(:first-child) { margin-top: 10px; padding-top: 10px; border-top: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; } ` export const InsideWrapper = styled.div` display: flex; align-items: flex-start; align-items: center; gap: 10px; width: 100%; position: relative; ` export const IconWrapper = styled.div` display: flex; flex-shrink: 0; margin-top: 9px; ` export const MainWrapper = styled.div` flex: 1; display: flex; flex-direction: column; ` export const Label = styled.span` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 12px; font-weight: 400; ` export const InputAreaWrapper = styled.div` position: relative; margin-top: 5px; overflow-x: auto; white-space: nowrap; display: flex; align-items: center; ` const getInputColor = (params: { theme: { colors: Record> } type?: string hasOverlay?: boolean }): string => { const { theme, type, hasOverlay } = params if (hasOverlay) { return 'transparent' } if (type === 'url') { return theme.colors.primary400.mode1 } return theme.colors.white.mode1 } export const Input = styled.input.withConfig({ shouldForwardProp: (prop) => !['hasOverlay', 'isDisabled'].includes(prop) }) ` color: ${({ theme, type, hasOverlay }) => getInputColor({ theme, type, hasOverlay })}; font-family: 'Inter'; font-size: 16px; font-weight: 700; caret-color: ${({ theme, hasOverlay }) => hasOverlay ? theme.colors.primary400.mode1 : ''}; width: 100%; user-select: ${({ isDisabled }) => (isDisabled ? 'none' : 'auto')}; cursor: ${({ isDisabled }) => (isDisabled ? 'default' : 'text')}; &::selection { color: ${({ hasOverlay }) => (hasOverlay ? 'transparent' : '')}; } &::placeholder { color: ${({ theme }) => theme.colors.grey100.mode1}; } ` export const InputOverlay = styled.div` position: absolute; top: 0; left: -6px; width: 100%; height: 100%; font-family: 'Inter'; font-size: 16px; font-weight: 700; z-index: 1; pointer-events: none; display: flex; align-items: center; padding: 8px; white-space: nowrap; & span { display: flex; white-space: nowrap; } ` export const NoticeWrapper = styled.div` margin-top: 2px; ` export const AdditionalItems = styled.div` display: flex; justify-content: flex-end; align-items: center; gap: 10px; align-self: center; ` ================================================ FILE: src/lib-react-components/components/NoticeText/index.js ================================================ import { html } from 'htm/react' import { NoticeTextComponent, NoticeTextWrapper } from './styles' import { ErrorIcon } from '../../icons/ErrorIcon' import { OkayIcon } from '../../icons/OkayIcon' import { YellowErrorIcon } from '../../icons/YellowErrorIcon' /** * @param {{ * text: string * type: 'success' | 'error' | 'warning' * testId?: string * }} props */ export const NoticeText = ({ text, type = 'success', testId }) => { const getIconByType = () => { switch (type) { case 'success': return OkayIcon case 'error': return ErrorIcon case 'warning': return YellowErrorIcon default: return null } } return html` <${NoticeTextWrapper}> <${getIconByType()} size="10px" /> <${NoticeTextComponent} data-testid=${testId} type=${type}> ${text} ` } ================================================ FILE: src/lib-react-components/components/NoticeText/index.test.js ================================================ import React from 'react' import { render } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { NoticeText } from './index' import '@testing-library/jest-dom' describe('NoticeText Component', () => { test('renders success type correctly', () => { const { getByText, container } = render( ) expect(getByText('Success message')).toBeInTheDocument() const svgElement = container.querySelector('svg') expect(svgElement).toBeInTheDocument() expect(svgElement.getAttribute('width')).toBe('10px') }) test('renders error type correctly', () => { const { getByText, container } = render( ) expect(getByText('Error message')).toBeInTheDocument() const svgElement = container.querySelector('svg') expect(svgElement).toBeInTheDocument() expect(svgElement.getAttribute('width')).toBe('10px') }) test('renders warning type correctly', () => { const { getByText, container } = render( ) expect(getByText('Warning message')).toBeInTheDocument() const svgElement = container.querySelector('svg') expect(svgElement).toBeInTheDocument() expect(svgElement.getAttribute('width')).toBe('10px') }) test('matches snapshot for success type', () => { const { container } = render( ) expect(container.firstChild).toMatchSnapshot() }) }) ================================================ FILE: src/lib-react-components/components/NoticeText/styles.js ================================================ import styled from 'styled-components' export const NoticeTextWrapper = styled.div` display: flex; justify-content: flex-start; align-items: center; gap: 5px; ` export const NoticeTextComponent = styled.div` color: ${({ theme, type }) => { switch (type) { case 'success': return theme.colors.primary400.mode1 case 'error': return theme.colors.errorRed.dark case 'warning': return theme.colors.errorYellow.mode1 default: return theme.colors.white.mode1 } }}; font-family: 'Inter'; font-size: 8px; font-weight: 500; ` ================================================ FILE: src/lib-react-components/components/PasswordField/index.js ================================================ import React, { useState } from 'react' import { checkPassphraseStrength, checkPasswordStrength } from '@tetherto/pearpass-utils-password-check' import { html } from 'htm/react' import { PasswordStrongnessWrapper } from './styles' import { useTranslation } from '../../../hooks/useTranslation' import { ErrorIcon } from '../../icons/ErrorIcon' import { EyeClosedIcon } from '../../icons/EyeClosedIcon' import { EyeIcon } from '../../icons/EyeIcon' import { KeyIcon } from '../../icons/KeyIcon' import { OkayIcon } from '../../icons/OkayIcon' import { YellowErrorIcon } from '../../icons/YellowErrorIcon' import { ButtonRoundIcon } from '../ButtonRoundIcon' import { HighlightString } from '../HighlightString' import { InputField } from '../InputField' const PASSWORD_STRENGTH_ICONS = { error: ErrorIcon, warning: YellowErrorIcon, success: OkayIcon } /** * @param {{ * value: string, * onChange: (value: string) => void, * label: string, * error: string, * passType: 'password' | 'passphrase', * additionalItems: import('react').ReactNode, * belowInputContent: import('react').ReactNode, * placeholder: string, * isDisabled: boolean, * hasStrongness: boolean, * onClick: () => void, * variant?: 'default' | 'outline' * icon: import('react').ReactNode, * testId?: string * }} props */ export const PasswordField = ({ value, onChange, label, error, passType = 'password', additionalItems, belowInputContent, placeholder, isDisabled, hasStrongness = false, onClick, variant = 'default', icon, testId = 'password-field' }) => { const { t } = useTranslation() const [isPasswordVisible, setIsPasswordVisible] = useState(false) const handleChange = (value) => { onChange?.(value) } const getPasswordStrongness = () => { if (!value?.length) { return null } const { success, type, strengthType, strengthText } = passType === 'password' ? checkPasswordStrength(value) : checkPassphraseStrength(value) if (!success) { return null } const icon = PASSWORD_STRENGTH_ICONS[strengthType] return html` <${PasswordStrongnessWrapper} strength=${type}> <${icon} /> ${t(strengthText)} ` } return html` <${InputField} testId=${testId} label=${label || 'Password'} icon=${icon || KeyIcon} isDisabled=${isDisabled} value=${value} overlay=${isPasswordVisible ? html` <${HighlightString} text=${value} /> ` : null} onChange=${handleChange} onClick=${onClick} placeholder=${placeholder} error=${error} variant=${variant} belowInputContent=${belowInputContent} additionalItems=${html` <${React.Fragment}> ${!!hasStrongness && getPasswordStrongness()} <${ButtonRoundIcon} testId="passwordfield-button-togglevisibility" startIcon=${isPasswordVisible ? EyeClosedIcon : EyeIcon} onClick=${(e) => { e.stopPropagation() setIsPasswordVisible(!isPasswordVisible) }} /> ${additionalItems} `} type=${isPasswordVisible ? 'text' : 'password'} /> ` } ================================================ FILE: src/lib-react-components/components/PasswordField/index.test.js ================================================ import React from 'react' jest.mock('@lingui/react', () => ({ useLingui: jest.fn(() => ({ i18n: { _: (key) => key } })) })) jest.mock('@tetherto/pearpass-utils-password-check', () => ({ checkPasswordStrength: jest.fn(), checkPassphraseStrength: jest.fn(), PASSWORD_STRENGTH: { SAFE: 'safe', VULNERABLE: 'vulnerable', WEAK: 'weak' } })) import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { checkPasswordStrength } from '@tetherto/pearpass-utils-password-check' import { PasswordField } from './index' import '@testing-library/jest-dom' const DummyAdditionalItem = () => (
Additional
) describe('PasswordField Component', () => { const setup = (props) => render( ) beforeEach(() => { jest.clearAllMocks() }) test('renders label, placeholder, and error message', () => { const { getByText, getByPlaceholderText } = setup({ value: 'secret', label: 'Password', error: 'Error occurred', placeholder: 'Enter password', onChange: jest.fn(), onClick: jest.fn(), isDisabled: false }) expect(getByText('Password')).toBeInTheDocument() expect(getByPlaceholderText('Enter password')).toBeInTheDocument() expect(getByText('Error occurred')).toBeInTheDocument() }) test('calls onChange when input value changes', () => { const handleChange = jest.fn() const { getByPlaceholderText } = setup({ value: '', placeholder: 'Enter password', onChange: handleChange }) const input = getByPlaceholderText('Enter password') fireEvent.change(input, { target: { value: 'newpass' } }) expect(handleChange).toHaveBeenCalledWith('newpass') }) test('toggles password visibility when toggle button is clicked', () => { const { container, getByPlaceholderText, queryByText } = setup({ value: 'secret', placeholder: 'Enter password', onChange: jest.fn() }) const input = getByPlaceholderText('Enter password') expect(input).toHaveAttribute('type', 'password') const toggleButton = container.querySelector('button') expect(toggleButton).toBeInTheDocument() fireEvent.click(toggleButton) expect(input).toHaveAttribute('type', 'text') expect(queryByText('secret')).toBeInTheDocument() fireEvent.click(toggleButton) expect(input).toHaveAttribute('type', 'password') }) test('displays password strongness as "Strong" when safe', () => { checkPasswordStrength.mockReturnValue({ success: true, type: 'strong', strengthType: 'success', strengthText: 'Strong' }) const { getByText } = setup({ value: 'safePassword', label: 'Password', onChange: jest.fn(), hasStrongness: true, passType: 'password' }) expect(getByText('Strong')).toBeInTheDocument() }) test('displays password strongness as "Weak" when not safe', () => { checkPasswordStrength.mockReturnValue({ success: true, type: 'weak', strengthType: 'error', strengthText: 'Weak' }) const { getByText } = setup({ value: 'weak', label: 'Password', onChange: jest.fn(), hasStrongness: true, passType: 'password' }) expect(getByText('Weak')).toBeInTheDocument() }) test('renders additionalItems if provided', () => { const { getByTestId } = setup({ value: 'secret', placeholder: 'Enter password', onChange: jest.fn(), additionalItems: }) expect(getByTestId('additional-item')).toBeInTheDocument() }) test('matches snapshot for default variant', () => { const { container } = setup({ value: 'snapshot test', label: 'Password', placeholder: 'Enter password', onChange: jest.fn(), variant: 'default' }) expect(container.firstChild).toMatchSnapshot() }) test('matches snapshot for outline variant', () => { const { container } = setup({ value: 'outline snapshot', label: 'Password', placeholder: 'Enter password', onChange: jest.fn(), variant: 'outline' }) expect(container.firstChild).toMatchSnapshot() }) }) ================================================ FILE: src/lib-react-components/components/PasswordField/styles.js ================================================ import { PASSWORD_STRENGTH } from '@tetherto/pearpass-utils-password-check' import styled from 'styled-components' export const PasswordStrongnessWrapper = styled.div.withConfig({ shouldForwardProp: (prop) => !['isStrong'].includes(prop) })` display: flex; align-items: center; gap: 5px; color: ${({ theme, strength }) => { switch (strength) { case PASSWORD_STRENGTH.SAFE: return theme.colors.primary400.mode1 case PASSWORD_STRENGTH.VULNERABLE: return theme.colors.errorRed.dark case PASSWORD_STRENGTH.WEAK: return theme.colors.errorYellow.mode1 default: return theme.colors.white.mode1 } }}; font-family: 'Inter'; font-size: 8px; font-weight: 500; ` ================================================ FILE: src/lib-react-components/components/PearPassInputField/index.js ================================================ import { html } from 'htm/react' import { Input, InputAreaWrapper, InputWrapper, MainWrapper, NoticeWrapper } from './styles' import { NoticeText } from '../../../lib-react-components' /** * @param {{ * value: string, * onChange: (value: string) => void, * placeholder: string, * isDisabled: boolean, * error: string, * }} props */ export const PearPassInputField = ({ placeholder, value, onChange, isDisabled, error }) => { const handleChange = (e) => { if (isDisabled) { return } onChange?.(e.target.value) } return html` <${InputWrapper}> <${MainWrapper}> <${InputAreaWrapper}> <${Input} value=${value} onChange=${handleChange} disabled=${isDisabled} placeholder=${placeholder} /> ${!!error?.length && html` <${NoticeWrapper}> <${NoticeText} text=${error} type="error" /> `} ` } ================================================ FILE: src/lib-react-components/components/PearPassInputField/index.test.js ================================================ import React from 'react' jest.mock('@tetherto/pearpass-utils-password-check', () => ({ checkPasswordStrength: jest.fn(), checkPassphraseStrength: jest.fn() })) import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import '@testing-library/jest-dom' import { PearPassInputField } from '.' describe('PearPassInputField Component', () => { test('renders placeholder and error message', () => { const { getByPlaceholderText, getByText } = render( ) expect(getByPlaceholderText('Enter text')).toBeInTheDocument() expect(getByText('Error occurred')).toBeInTheDocument() }) test('calls onChange when input changes if not disabled', () => { const handleChange = jest.fn() const { getByPlaceholderText } = render( ) const input = getByPlaceholderText('Enter text') fireEvent.change(input, { target: { value: 'new value' } }) expect(handleChange).toHaveBeenCalledWith('new value') }) test('does not call onChange when disabled', () => { const handleChange = jest.fn() const { getByPlaceholderText } = render( ) const input = getByPlaceholderText('Enter text') fireEvent.change(input, { target: { value: 'should not change' } }) expect(handleChange).not.toHaveBeenCalled() }) test('displays an error message when error prop is provided', () => { const { getByText } = render( ) expect(getByText('This field is required')).toBeInTheDocument() }) test('does not display error message when error prop is empty', () => { const { queryByText } = render( ) expect(queryByText('This field is required')).not.toBeInTheDocument() }) test('renders input as disabled when isDisabled is true', () => { const { getByPlaceholderText } = render( ) const input = getByPlaceholderText('Enter text') expect(input).toBeDisabled() }) test('matches snapshot for default variant', () => { const { container } = render( ) expect(container.firstChild).toMatchSnapshot() }) test('matches snapshot for outline variant', () => { const { container } = render( ) expect(container.firstChild).toMatchSnapshot() }) }) ================================================ FILE: src/lib-react-components/components/PearPassInputField/styles.js ================================================ import styled from 'styled-components' export const InputWrapper = styled.div` display: flex; align-items: center; gap: 10px; width: 100%; position: relative; border-radius: 10px; border: 1px solid; border-color: ${({ theme }) => theme.colors.grey100.mode1}; margin-top: 0; padding: 10px 10px; &:hover, &:focus-within { border-color: ${({ theme }) => theme.colors.primary400.mode1}; } ` export const MainWrapper = styled.div` flex: 1; display: flex; flex-direction: column; ` export const InputAreaWrapper = styled.div` flex: 1; position: relative; overflow-x: auto; white-space: nowrap; display: flex; align-items: center; ` export const Input = styled.input` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-weight: 700; pointer-events: ${({ disabled }) => (disabled ? 'none' : 'auto')}; width: 100%; &::placeholder { color: ${({ theme }) => theme.colors.grey100.mode1}; } ` export const NoticeWrapper = styled.div` margin-top: 2px; ` ================================================ FILE: src/lib-react-components/components/PearPassPasswordField/index.js ================================================ import { useState } from 'react' import { html } from 'htm/react' import { AdditionalItems, IconWrapper, Input, InputAreaWrapper, InputWrapper, MainWrapper, NoticeWrapper } from './styles' import { EyeClosedIcon } from '../../icons/EyeClosedIcon' import { EyeIcon } from '../../icons/EyeIcon' import { LockCircleIcon } from '../../icons/LockCircleIcon' import { ButtonRoundIcon } from '../ButtonRoundIcon' import { NoticeText } from '../NoticeText' /** * @param {{ * value: string, * placeholder?: string, * onChange: (value: string) => void, * isDisabled: boolean, * error: string, * testId?: string * errorTestId?: string * }} props */ export const PearPassPasswordField = ({ value, placeholder, onChange, isDisabled, error, testId = 'pearpass-password-field' }) => { const [isPasswordVisible, setIsPasswordVisible] = useState(false) const handleChange = (e) => { if (isDisabled) { return } onChange?.(e.target.value) } return html` <${InputWrapper}> <${IconWrapper}> <${LockCircleIcon} size="24" /> <${MainWrapper}> <${InputAreaWrapper}> <${Input} data-testid=${testId} placeholder=${placeholder} value=${value} onChange=${handleChange} disabled=${isDisabled} type=${isPasswordVisible ? 'text' : 'password'} /> ${!!error?.length && html` <${NoticeWrapper}> <${NoticeText} text=${error} type="error" testId=${`password-error-${error}`} /> `} <${AdditionalItems}> <${ButtonRoundIcon} testId="password-visibility-button" startIcon=${isPasswordVisible ? EyeClosedIcon : EyeIcon} onClick=${() => setIsPasswordVisible(!isPasswordVisible)} /> ` } ================================================ FILE: src/lib-react-components/components/PearPassPasswordField/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { PearPassPasswordField } from './index' import '@testing-library/jest-dom' describe('PearPassPasswordField Component', () => { test('renders input with type "password" initially and displays LockCircleIcon', () => { const { getByDisplayValue, container } = render( ) const input = getByDisplayValue('secret') expect(input).toBeInTheDocument() expect(input).toHaveAttribute('type', 'password') const iconSvg = container.querySelector('svg') expect(iconSvg).toBeInTheDocument() expect(iconSvg.getAttribute('width')).toBe('24') }) test('calls onChange when input value changes if not disabled', () => { const handleChange = jest.fn() const { getByDisplayValue } = render( ) const input = getByDisplayValue('') fireEvent.change(input, { target: { value: 'newsecret' } }) expect(handleChange).toHaveBeenCalledWith('newsecret') }) test('does not call onChange when disabled', () => { const handleChange = jest.fn() const { getByDisplayValue } = render( ) const input = getByDisplayValue('') fireEvent.change(input, { target: { value: 'newsecret' } }) expect(handleChange).not.toHaveBeenCalled() }) test('toggles password visibility when toggle button is clicked', () => { const { getByDisplayValue, container } = render( ) const input = getByDisplayValue('secret') expect(input).toHaveAttribute('type', 'password') const toggleButton = container.querySelector('button') expect(toggleButton).toBeInTheDocument() fireEvent.click(toggleButton) expect(input).toHaveAttribute('type', 'text') fireEvent.click(toggleButton) expect(input).toHaveAttribute('type', 'password') }) test('renders error message when error prop is provided', () => { const errorMessage = 'Error occurred' const { getByText } = render( ) expect(getByText(errorMessage)).toBeInTheDocument() }) test('matches snapshot', () => { const { container } = render( ) expect(container.firstChild).toMatchSnapshot() }) }) ================================================ FILE: src/lib-react-components/components/PearPassPasswordField/styles.js ================================================ import styled from 'styled-components' export const InputWrapper = styled.div` display: flex; align-items: center; gap: 10px; width: 100%; position: relative; border-radius: 10px; border: 1px solid; border-color: ${({ theme }) => theme.colors.grey100.mode1}; margin-top: 0; padding: 5px 10px; &:hover, &:focus-within { border-color: ${({ theme }) => theme.colors.primary400.mode1}; } ` export const MainWrapper = styled.div` flex: 1; display: flex; flex-direction: column; ` export const IconWrapper = styled.div` display: flex; flex-shrink: 0; align-items: center; align-self: 'stretch'; ` export const InputAreaWrapper = styled.div` flex: 1; position: relative; overflow-x: auto; white-space: nowrap; display: flex; align-items: center; ` export const Input = styled.input` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-weight: 700; pointer-events: ${({ disabled }) => (disabled ? 'none' : 'auto')}; width: 100%; &::placeholder { color: ${({ theme }) => theme.colors.grey100.mode1}; } ` export const NoticeWrapper = styled.div` margin-top: 2px; ` export const AdditionalItems = styled.div` display: flex; justify-content: flex-end; align-items: center; gap: 10px; align-self: center; ` ================================================ FILE: src/lib-react-components/components/PearPassPasswordFieldV2/index.test.ts ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { PearPassPasswordFieldV2 } from './index' import '@testing-library/jest-dom' describe('PearPassPasswordFieldV2 Component', () => { const renderWithTheme = (ui: React.ReactElement) => render( React.createElement( ThemeProvider as React.ComponentType<{ children: React.ReactNode }>, null, ui ) ) test('renders input with type "password" initially', () => { const { getByTestId } = renderWithTheme( React.createElement(PearPassPasswordFieldV2, { value: 'secret', placeholder: 'Insert master password', onChange: jest.fn(), isDisabled: false, error: '' }) ) const input = getByTestId('@tetherto/pearpass-password-field-v2') as HTMLInputElement expect(input).toBeInTheDocument() expect(input).toHaveAttribute('type', 'password') expect(input.value).toBe('secret') }) test('calls onChange when input value changes if not disabled', () => { const handleChange = jest.fn() const { getByTestId } = renderWithTheme( React.createElement(PearPassPasswordFieldV2, { value: '', placeholder: 'Insert master password', onChange: handleChange, isDisabled: false, error: '' }) ) const input = getByTestId('@tetherto/pearpass-password-field-v2') fireEvent.change(input, { target: { value: 'newsecret' } }) expect(handleChange).toHaveBeenCalledWith('newsecret') }) test('does not call onChange when disabled', () => { const handleChange = jest.fn() const { getByTestId } = renderWithTheme( React.createElement(PearPassPasswordFieldV2, { value: '', placeholder: 'Insert master password', onChange: handleChange, isDisabled: true, error: '' }) ) const input = getByTestId('@tetherto/pearpass-password-field-v2') fireEvent.change(input, { target: { value: 'newsecret' } }) expect(handleChange).not.toHaveBeenCalled() }) test('toggles password visibility when toggle control is clicked', () => { const { getByTestId } = renderWithTheme( React.createElement(PearPassPasswordFieldV2, { value: 'secret', placeholder: 'Insert master password', onChange: jest.fn(), isDisabled: false, error: '' }) ) const input = getByTestId('@tetherto/pearpass-password-field-v2') const toggle = getByTestId('@tetherto/pearpass-password-field-v2-toggle') expect(input).toHaveAttribute('type', 'password') fireEvent.click(toggle) expect(input).toHaveAttribute('type', 'text') fireEvent.click(toggle) expect(input).toHaveAttribute('type', 'password') }) test('renders error message when error prop is provided', () => { const errorMessage = 'Error occurred' const { getByText } = renderWithTheme( React.createElement(PearPassPasswordFieldV2, { value: 'secret', placeholder: 'Insert master password', onChange: jest.fn(), isDisabled: false, error: errorMessage }) ) expect(getByText(errorMessage)).toBeInTheDocument() }) }) ================================================ FILE: src/lib-react-components/components/PearPassPasswordFieldV2/index.tsx ================================================ import { useState } from 'react' import { html } from 'htm/react' import { AdditionalItems, Input, InputAreaWrapper, InputWrapper, MainWrapper, NoticeWrapper } from './styles' import { EyeClosedIcon } from '../../icons/EyeClosedIcon' import { EyeIcon } from '../../icons/EyeIcon' import { NoticeText } from '../NoticeText' import { colors } from '@tetherto/pearpass-lib-ui-theme-provider' import { PearPassPasswordFieldV2Props } from './types' export const PearPassPasswordFieldV2 = ({ value, placeholder, onChange, isDisabled, error, testId = '@tetherto/pearpass-password-field-v2' }: PearPassPasswordFieldV2Props) => { const [isPasswordVisible, setIsPasswordVisible] = useState(false) const handleChange = (e: React.ChangeEvent) => { if (isDisabled) { return } onChange?.(e.target.value) } return html` <${InputWrapper}> <${MainWrapper}> <${InputAreaWrapper}> <${Input} data-testid=${testId} placeholder=${placeholder} value=${value} onChange=${handleChange} disabled=${isDisabled} type=${isPasswordVisible ? 'text' : 'password'} /> ${!!error?.length && html` <${NoticeWrapper}> <${NoticeText} text=${error} type="error" testId=${`password-error-${error}`} /> `} <${AdditionalItems}>
setIsPasswordVisible(!isPasswordVisible)} > ${isPasswordVisible ? html`<${EyeClosedIcon} color=${colors.primary400.mode1} />` : html`<${EyeIcon} color=${colors.primary400.mode1} />`}
` } ================================================ FILE: src/lib-react-components/components/PearPassPasswordFieldV2/styles.ts ================================================ import styled from 'styled-components' export const InputWrapper = styled.div` display: flex; align-items: center; gap: 10px; width: 100%; position: relative; border-radius: 10px; margin-top: 0; padding: 20px 20px 20px 10px; background-color: ${({ theme }) => theme.colors.grey400.mode1}; ` export const MainWrapper = styled.div` flex: 1; display: flex; flex-direction: column; ` export const IconWrapper = styled.div` display: flex; flex-shrink: 0; align-items: center; align-self: 'stretch'; ` export const InputAreaWrapper = styled.div` flex: 1; position: relative; overflow-x: auto; white-space: nowrap; display: flex; align-items: center; ` export const Input = styled.input` color: ${({ theme }) => theme.colors.white.mode1}; font-family: 'Inter'; font-size: 16px; font-weight: 700; pointer-events: ${({ disabled }) => (disabled ? 'none' : 'auto')}; width: 100%; &::placeholder { color: ${({ theme }) => theme.colors.grey100.mode1}; } ` export const NoticeWrapper = styled.div` margin-top: 2px; ` export const AdditionalItems = styled.div` display: flex; justify-content: flex-end; align-items: center; gap: 10px; align-self: center; ` ================================================ FILE: src/lib-react-components/components/PearPassPasswordFieldV2/types.ts ================================================ export type PearPassPasswordFieldV2Props = { value: string placeholder: string onChange: (value: string) => void isDisabled: boolean error: string testId?: string } ================================================ FILE: src/lib-react-components/components/Slider/index.js ================================================ import { html } from 'htm/react' import { StyledRange } from './styles' /** * @param {{ * value: number, * onChange: (value: number) => void, * testId?: string, * }} props */ export const Slider = ({ value, onChange, min = 0, max = 100, step = 1, testId = 'slider' }) => { const handleChange = (event) => { const value = event.target.value onChange?.(parseFloat(value)) } return html` <${StyledRange} value=${value} onChange=${handleChange} min=${min} max=${max} step=${step} data-testid=${testId} /> ` } ================================================ FILE: src/lib-react-components/components/Slider/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { Slider } from './index' import '@testing-library/jest-dom' describe('Slider Component', () => { test('renders with correct attributes', () => { const { getByRole } = render( {}} min={10} max={50} step={5} /> ) const slider = getByRole('slider') expect(slider).toHaveAttribute('min', '10') expect(slider).toHaveAttribute('max', '50') expect(slider).toHaveAttribute('step', '5') expect(slider.value).toBe('30') }) test('calls onChange with correct numeric value on change', () => { const handleChange = jest.fn() const { getByRole } = render( ) const slider = getByRole('slider') fireEvent.change(slider, { target: { value: '40' } }) expect(handleChange).toHaveBeenCalledWith(40) }) }) ================================================ FILE: src/lib-react-components/components/Slider/styles.js ================================================ import styled from 'styled-components' export const StyledRange = styled.input.attrs({ type: 'range' })` -webkit-appearance: none; appearance: none; width: 100%; cursor: pointer; outline: none; border-radius: 15px; height: 10px; background: ${({ theme, value = 0, max = 0, min = 0 }) => { const maxValue = parseFloat(max) const minValue = parseFloat(min) const progress = ((value - minValue) / (maxValue - minValue)) * 100 return `linear-gradient(to right, ${theme.colors.primary300.mode1} ${progress}%, ${theme.colors.grey200.mode1} ${progress}%)` }}; &::-webkit-slider-runnable-track { } &::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 15px; width: 15px; background-color: ${({ theme }) => theme.colors.primary400.mode1}; border-radius: 50%; border: none; transition: 300ms ease-in-out; &:hover { box-shadow: 0 0 0 4px ${({ theme }) => `${theme.colors.primary400.mode1}66`}; } } &:active::-webkit-slider-thumb { box-shadow: 0 0 0 4px ${({ theme }) => `${theme.colors.primary400.mode1}66`}; } &:focus::-webkit-slider-thumb { box-shadow: 0 0 0 4px ${({ theme }) => `${theme.colors.primary400.mode1}66`}; } ` ================================================ FILE: src/lib-react-components/components/Switch/index.js ================================================ import { html } from 'htm/react' import { SwitchBackground, SwitchThumb } from './styles' /** * @param {{ * isOn: boolean, * onChange: (isOn: boolean) => void, * testId?: string * }} props */ export const Switch = ({ isOn, onChange, testId = 'switch' }) => { const toggleSwitch = () => { onChange?.(!isOn) } return html` <${SwitchBackground} onClick=${toggleSwitch} isOn=${isOn} data-testid=${testId} > <${SwitchThumb} isOn=${isOn} /> ` } ================================================ FILE: src/lib-react-components/components/Switch/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { Switch } from './index' import '@testing-library/jest-dom' describe('Switch Component', () => { test('toggles switch correctly when clicked', () => { const handleChange = jest.fn() const { container } = render( ) const background = container.firstChild fireEvent.click(background) expect(handleChange).toHaveBeenCalledWith(false) }) test('matches snapshot when switched on', () => { const { container } = render( {}} /> ) expect(container.firstChild).toMatchSnapshot() }) test('matches snapshot when switched off', () => { const { container } = render( {}} /> ) expect(container.firstChild).toMatchSnapshot() }) }) ================================================ FILE: src/lib-react-components/components/Switch/styles.js ================================================ import styled from 'styled-components' const TRANSITION_PROPERTIES = '300ms ease-in-out' export const SwitchBackground = styled.div.withConfig({ shouldForwardProp: (prop) => !['isOn'].includes(prop) })` border: 1px solid ${({ theme }) => theme.colors.grey100.mode1}; width: 30px; height: 16px; background-color: ${({ theme, isOn }) => isOn ? theme.colors.grey400.mode1 : theme.colors.grey100.mode1}; border-radius: 8px; position: relative; transition: background-color ${TRANSITION_PROPERTIES}; ` export const SwitchThumb = styled.div.withConfig({ shouldForwardProp: (prop) => !['isOn'].includes(prop) })` width: 16px; height: 16px; background-color: ${({ isOn, theme }) => isOn ? theme.colors.primary400.mode1 : theme.colors.grey400.mode1}; border: 1px solid ${({ theme, isOn }) => isOn ? theme.colors.primary400.mode1 : theme.colors.grey200.mode1}; border-radius: 50%; position: absolute; top: -1px; left: ${({ isOn }) => (isOn ? '14px' : '0')}; transition: left ${TRANSITION_PROPERTIES}, background-color ${TRANSITION_PROPERTIES}; ` ================================================ FILE: src/lib-react-components/components/TextArea/index.test.js ================================================ import React from 'react' import { render, fireEvent } from '@testing-library/react' import { ThemeProvider } from '@tetherto/pearpass-lib-ui-theme-provider' import { TextArea } from './index' import '@testing-library/jest-dom' describe('TextArea Component', () => { const setup = (props) => render(