Compare commits

...

248 Commits

Author SHA1 Message Date
Leendert de Borst
83d9eadeea Bump version to 0.19.2 (#943) 2025-06-19 15:08:19 +02:00
Leendert de Borst
1cdd8f456e Make admin redirects work with custom ports through nginx docker (#940) 2025-06-19 11:52:43 +02:00
Leendert de Borst
395f881bd0 Bump version to 0.19.1 (#938) 2025-06-18 13:49:13 +02:00
Leendert de Borst
293ae102c5 Update history handling (#935) 2025-06-18 13:30:14 +02:00
Leendert de Borst
8f5852bb86 Optimize load and persist flow (#935) 2025-06-18 13:30:14 +02:00
Leendert de Borst
9ccaff74cd Update imports (#935) 2025-06-18 13:30:14 +02:00
Leendert de Borst
ee6b40dd3d Refactor navigation logic from Home.tsx to NavigationContext (#935) 2025-06-18 13:30:14 +02:00
Leendert de Borst
3ca4c0a78d Update icons folder casing (#935) 2025-06-18 13:30:14 +02:00
Leendert de Borst
b246def212 Refactor persist logic to protect data at rest (#935) 2025-06-18 13:30:14 +02:00
Leendert de Borst
1eecb8be38 Clear persisted form values if time has expired (#935) 2025-06-18 13:30:14 +02:00
Leendert de Borst
9a7fbe7d2a Add form persist and restore logic (#935) 2025-06-18 13:30:14 +02:00
Leendert de Borst
7776fb6d82 Remember last visited page in browser extension and navigate back on reopen (#928) 2025-06-18 13:30:14 +02:00
Leendert de Borst
0eebaddf04 Move notes to bottom for view mode in mobile app and browser extension (#933) 2025-06-17 19:39:25 +02:00
Leendert de Borst
8b145e66b5 Only show email preview if email is supported by AliasVault public or private (#928) 2025-06-17 19:39:16 +02:00
Leendert de Borst
4e3c992c24 Update ErrorVaultDecrypt.razor typo (#928) 2025-06-17 19:39:16 +02:00
Leendert de Borst
65944b1523 Fix toast text color on dark mode (#931) 2025-06-17 19:39:07 +02:00
Leendert de Borst
d05114fddc Make view details and edit buttons work in iOS autofill popup (#931) 2025-06-17 19:39:07 +02:00
Leendert de Borst
8e0fef4b16 Add x-forwarded-prefix header to admin to support running on non-default ports (#929) 2025-06-17 19:38:56 +02:00
Leendert de Borst
1bf8b7ee04 Bump version to 0.19.0 (#926) 2025-06-16 12:34:40 +02:00
Leendert de Borst
8545b2c1fd Merge pull request #925 from lanedirt/890-feature-request-add-create-credential-button-in-bottom-right-corner-for-easier-access
Move create credential button to bottom right corner for easier access
2025-06-16 00:27:47 +02:00
Leendert de Borst
2f22e4db56 Make user avatar dynamic instead of showing old icon (#890) 2025-06-15 14:00:36 +02:00
Leendert de Borst
54bbbb0647 Change create credential button into floating action button (#890) 2025-06-15 13:44:25 +02:00
Leendert de Borst
0b127a4a3e Update Android to use adaptive icon with gradient bg (#922) 2025-06-13 21:17:17 +02:00
Leendert de Borst
241f17868b Update Android app icon to use black background (#922) 2025-06-13 21:17:17 +02:00
Leendert de Borst
be536741c5 Update iOS app to use dark background (#922) 2025-06-13 21:17:17 +02:00
Leendert de Borst
7638879aa9 Update disabled email cleanup task log notice (#920) 2025-06-13 18:56:54 +02:00
Leendert de Borst
499f6e451e Add integration test for disabled email alias delete task (#920) 2025-06-13 18:02:12 +02:00
Leendert de Borst
73ad8f6acd Add disabled email cleanup task to TaskRunner (#920) 2025-06-13 18:02:12 +02:00
Leendert de Borst
c5ea7d0143 Ensure email claim UpdatedAt is properly triggered and re-enabled if claimed again by same user (#920) 2025-06-13 18:02:12 +02:00
Leendert de Borst
0473ec21bf Add disabled email retention setting to admin (#920) 2025-06-13 18:02:12 +02:00
Leendert de Borst
0eb7e97383 Add QuickCreate state service to persist values when switching between quick and advanced mode (#916) 2025-06-13 18:01:56 +02:00
Leendert de Borst
7d35777c93 Add browser extension missing AppInfo.ts to bump version script (#917) 2025-06-12 18:14:40 +02:00
Leendert de Borst
08e39ef3e9 Fix admin base url protocol mismatch on some environments (#914) 2025-06-12 17:50:25 +02:00
Leendert de Borst
fe10acb925 Add HTTP security headers to nginx reverse proxy config (#914) 2025-06-12 15:07:10 +02:00
Leendert de Borst
061f846b66 Update browser extension and mobile app download UI (#914) 2025-06-12 15:07:10 +02:00
Leendert de Borst
eb64d86c78 Remove console writelines (#914) 2025-06-12 15:07:10 +02:00
Leendert de Borst
ef2a58f784 Remove unused css import (#914) 2025-06-12 15:07:10 +02:00
Leendert de Borst
a43d50f047 Add confirmation modal to credential and email delete (#911) 2025-06-12 14:55:00 +02:00
Leendert de Borst
0d5fd55133 Make browser extension popout use full height/width in all browsers (#909) 2025-06-12 14:54:50 +02:00
Leendert de Borst
d9942844e2 Fix attachment download in browser extension and mobile app (#902) 2025-06-12 09:56:50 +02:00
Leendert de Borst
15a1276d42 Tweak android autofill item display preview (#904) 2025-06-12 09:56:39 +02:00
Leendert de Borst
37d6ead41d Clear dbcontext after loading a (new) vault from server (#906) 2025-06-12 09:56:31 +02:00
dependabot[bot]
fa99cb77d7 Bump the npm_and_yarn group across 2 directories with 1 update
Bumps the npm_and_yarn group with 1 update in the /apps/server/AliasVault.Admin directory: [brace-expansion](https://github.com/juliangruber/brace-expansion).
Bumps the npm_and_yarn group with 1 update in the /apps/server/AliasVault.Client directory: [brace-expansion](https://github.com/juliangruber/brace-expansion).


Updates `brace-expansion` from 2.0.1 to 2.0.2
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.0.1...v2.0.2)

Updates `brace-expansion` from 2.0.1 to 2.0.2
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.0.1...v2.0.2)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 2.0.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: brace-expansion
  dependency-version: 2.0.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-12 09:56:22 +02:00
Leendert de Borst
f9987b5e2a Add email error response parsing to browser extension and mobile app (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
ec11ab0817 Move shared projects to dist/shared (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
ecd592e74f Allow null values in credential add edit (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
a3208e72bf Reduce min loading duration for client (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
d66dee3583 Fix auto sync on extension open, update icon sizes (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
68471b7c88 Tweak loading animation on credential list refresh (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
3d8c2b7086 Add (re)generate username and password controls (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
a93a7f7fff Add random alias / manual toggle icons to browser extension and mobile app (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
1b84fd1dad Fix margin issue when loading popup shows (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
c673a20fd1 Add favicon extractor (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
7e81e70ec4 Focus service name field on create mode (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
c688764831 Add credential add page (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
3da40f42c9 Add form validation to credential edit (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
fd74b7b056 Add loading animation to add edit submit (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
0ccbeb683d Make credential edit flow work (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
34d00dc7d6 Add logout section to settings page (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
ffe1a36df3 Move page primary actions to header (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
0f9c2d1f7c Make basic vault update in browser extension work with delete call (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
19499f02d6 Add edit page scaffolding (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
330a92fbb3 Add useVaultMutate hook compatible with browser extension (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
5ca29a33d0 Refactor shared metadata models, update browser extension to use vaultsync hook (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
ab6191ac62 Refactor browser extension to use shared vault models (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
f8bf575ab5 Refactor mobile app to use shared vault models (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
3576b32821 Refactor shared models to subdir structure (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
4619fe615c Add AuthEventType enum to shared models (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
e8ba964064 Update mobile app to use shared webapi models (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
4af1a127cf Apply sort lint rules to mobile app imports (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
22acea0e35 Refactor browser extension to use shared types, add import order lint rules (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
c6d7d16b27 Add import resolve checking during linting (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
aba377ac65 Update models build (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
5a0d1eabb7 Update build-and-distribute.sh (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
eb2c4c1cd3 Add models build script (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
62224c86cd Add separate build file for password-generator (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
6ab20501e9 Add separate build file for identity-generator (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
dd82803f87 Add shared models scaffolding (#900) 2025-06-11 21:52:21 +02:00
Leendert de Borst
27d19759c8 Update MinDurationLoadingService.cs (#897) 2025-06-07 14:14:37 +02:00
Leendert de Borst
c6faa4db97 Add wait to E2E email test due to new loading animation (#897) 2025-06-07 14:14:37 +02:00
Leendert de Borst
f35d46256f Add title tag to lock and refresh buttons (#897) 2025-06-07 14:14:37 +02:00
Leendert de Borst
4683d6bea6 Add skeleton loading animation to recent emails (#897) 2025-06-07 14:14:37 +02:00
Leendert de Borst
566d4259bd Add skeleton loading animation to email page (#897) 2025-06-07 14:14:37 +02:00
Leendert de Borst
afee07885d Update credential card UI to prevent overflow (#897) 2025-06-07 14:14:37 +02:00
Leendert de Borst
8e8ef8fd5d Remove top level dictionaries which is now stored in shared utils (#896) 2025-06-06 14:23:54 +02:00
Leendert de Borst
5589042606 Remove .NET generator projects (#896) 2025-06-06 14:23:54 +02:00
Leendert de Borst
cbe8b2c471 Make shared generators work when called from .NET Blazor interop (#896) 2025-06-06 14:23:54 +02:00
Leendert de Borst
4c7bef2a5a Refactor to use new factory methods for identity and password generators (#896) 2025-06-06 14:23:54 +02:00
Leendert de Borst
bc6479bf5e Update sonarcloud analysis excludes (#896) 2025-06-06 14:23:54 +02:00
Leendert de Borst
845f780707 Update shared utils in browser extension and mobile app (#896) 2025-06-06 14:23:54 +02:00
Leendert de Borst
1089e8299f Update add-edit.tsx (#896) 2025-06-06 14:23:54 +02:00
Leendert de Borst
ce9b37d299 Add generated header to ignore sonarcloud for compiled TS (#896) 2025-06-06 14:23:54 +02:00
Leendert de Borst
538675f391 Replace SpamOK.PasswordGenerator with shared TS implementation (#896) 2025-06-06 14:23:54 +02:00
Leendert de Borst
260aec34ce Add shared libraries to AliasVault.Client (#896) 2025-06-06 14:23:54 +02:00
Leendert de Borst
a7ffc33d56 Add factories to shared generators so it can be called from Blazor (#896) 2025-06-06 14:23:54 +02:00
Leendert de Borst
89a57b6047 Push shared libraries to AliasVault.Client (#886) 2025-06-06 14:23:54 +02:00
Leendert de Borst
a66e8b6b0d Update UI margins (#886) 2025-06-04 17:13:06 +02:00
Leendert de Borst
5de0806bcc Add clear button to input field components (#886) 2025-06-04 17:13:06 +02:00
Leendert de Borst
a1d2bcbe3b Update CredentialCard.tsx (#882) 2025-06-04 17:12:55 +02:00
Leendert de Borst
fbc085439c Add native context menu to credential list (#880) 2025-06-04 17:12:55 +02:00
Leendert de Borst
4a35a1a7d3 Update project.pbxproj 2025-06-03 17:36:43 +02:00
Leendert de Borst
bd82037d8c Bump version to 0.18.1 2025-06-02 23:39:08 +02:00
Leendert de Borst
9615634bf9 Add docker build and push back to release.yml (#887) 2025-06-02 23:38:54 +02:00
Leendert de Borst
dfd2b534e6 Add iOS build workflow action (#887) 2025-06-02 23:30:40 +02:00
Leendert de Borst
314c757fe6 Refactor build android step to reusable action (#887) 2025-06-02 23:30:40 +02:00
Leendert de Borst
771abe9cc1 Update bump version script to also bump browser package.json (#887) 2025-06-02 23:30:40 +02:00
Leendert de Borst
22aaf17cd1 Refactor browser extension build to reusable workflow (#887) 2025-06-02 23:30:40 +02:00
Leendert de Borst
2134b61a78 Make release app build use the correct file location (#887) 2025-06-02 23:30:40 +02:00
Leendert de Borst
0059e31892 Update README.md 2025-06-02 17:14:26 +02:00
Leendert de Borst
2f7a4370b7 Improve sanity checks for if biometrics are not available (#880) 2025-06-02 14:21:43 +02:00
Leendert de Borst
5fc2889a03 Make username case insensitive for mobile apps (#884) 2025-06-02 11:56:43 +02:00
Leendert de Borst
f43bc402ba Make username case insensitive during login for browser extension (#884) 2025-06-02 11:56:43 +02:00
Leendert de Borst
2e6d4fbe20 Update README.md 2025-06-01 11:06:26 +02:00
Leendert de Borst
38db3c5054 Update docs 2025-05-31 15:56:50 +02:00
Leendert de Borst
971a21a16a Update README.md 2025-05-31 15:39:59 +02:00
Leendert de Borst
8058912eee Bump iOS app version and tweak bump version script (#878) 2025-05-31 15:39:59 +02:00
Leendert de Borst
8a9e1dc9a3 Update create-new-release docs (#878) 2025-05-31 15:39:59 +02:00
Leendert de Borst
cde78650b9 Bump version to 0.18.0 (#878) 2025-05-31 15:39:59 +02:00
Leendert de Borst
4ef9e58665 Update StartupTasks.cs (#876) 2025-05-31 12:12:55 +02:00
Leendert de Borst
b6b1d9dec9 Add amount of emails stored per user to admin user listing (#876) 2025-05-31 12:12:55 +02:00
Leendert de Borst
fa2dedb05a Unblock admin user when a password request has been requested (#876) 2025-05-31 12:12:55 +02:00
Leendert de Borst
f148ccdeba Add revoke all option to admin user refresh tokens (#874) 2025-05-31 11:42:43 +02:00
Leendert de Borst
9b038cb76c Truncate credential name/preview if too long (#872) 2025-05-31 08:47:41 +02:00
Leendert de Borst
aa726706a4 Make browser extension auth settings less strict (#872) 2025-05-31 08:47:41 +02:00
Leendert de Borst
d0017d9207 Add android app download link (#870) 2025-05-31 08:37:42 +02:00
Leendert de Borst
cde4b87371 Return fake login response if username is invalid (#868) 2025-05-31 07:45:40 +02:00
Leendert de Borst
431d8d4fca Only trigger autofill popup on username/email/password field types (#866) 2025-05-30 23:41:50 +02:00
Leendert de Borst
9fddb5f450 Reset client url on wrong input (#858) 2025-05-30 22:59:16 +02:00
Leendert de Borst
dbb6cf5b94 Add yup validation schema to auth settings (#858) 2025-05-30 22:59:16 +02:00
Leendert de Borst
bd41507ef9 Use absolute path for docker volume bind mounts (#859) 2025-05-30 18:03:53 +02:00
Leendert de Borst
ebb0e7cf68 Merge pull request #863 from lanedirt/846-add-native-android-app
Add native Android app
2025-05-30 18:03:42 +02:00
Leendert de Borst
4603051a91 Build and push docker images even if other optional steps fail (#846) 2025-05-30 18:01:26 +02:00
Leendert de Borst
f66fb53706 Update mobile-app-build.yml (#846) 2025-05-30 17:54:45 +02:00
Leendert de Borst
b603160d99 Add autofill screenshots to Android docs (#846) 2025-05-30 16:42:11 +02:00
Leendert de Borst
096b0277f3 Update mobile-app-build.yml (#846) 2025-05-30 15:52:05 +02:00
Leendert de Borst
f271040ff4 Improve android autofill settings open, bump version (#846) 2025-05-30 15:35:41 +02:00
Leendert de Borst
f313950112 Make safari extension project version the same for all projects (#846) 2025-05-30 15:11:24 +02:00
Leendert de Borst
ef1ad127e3 Update mobile-app-build.yml (#846) 2025-05-30 15:06:33 +02:00
Leendert de Borst
cac691a43d Delete lowercase duplicate validationSchema.ts (#846) 2025-05-30 13:50:36 +02:00
Leendert de Borst
4efe201224 Add iOS app build (#846) 2025-05-30 13:37:14 +02:00
Leendert de Borst
ca477c310c Make android app signed build manual dispatch (#846) 2025-05-30 12:40:21 +02:00
Leendert de Borst
77189373ba Add signed android app build (#846) 2025-05-30 11:57:36 +02:00
Leendert de Borst
1aaa5c2d55 Update mobile-app-build.yml (#846) 2025-05-30 11:04:51 +02:00
Leendert de Borst
163e5c51c2 Merge branch '846-add-native-android-app' of https://github.com/lanedirt/AliasVault into 846-add-native-android-app
* '846-add-native-android-app' of https://github.com/lanedirt/AliasVault:
  Make unit tests work from CLI (#846)
2025-05-30 10:58:59 +02:00
Leendert de Borst
29895f375f Split tasks in mobile-app-build.yml (#846) 2025-05-30 10:58:56 +02:00
Leendert de Borst
2803dcf02c Add bump-version.sh script (#846) 2025-05-30 10:56:32 +02:00
Leendert de Borst
a8e075d932 Update version to be equal for all subprojects (#846) 2025-05-30 09:59:30 +02:00
Leendert de Borst
49ba704135 Update docs (#846) 2025-05-30 09:38:59 +02:00
Leendert de Borst
9669307480 Make unit tests work from CLI (#846) 2025-05-29 21:22:09 +02:00
Leendert de Borst
343ced5b38 Make unit tests work from CLI (#846) 2025-05-29 21:08:38 +02:00
Leendert de Borst
8f66670804 Update mobile-app-build.yml (#846) 2025-05-29 20:12:33 +02:00
Leendert de Borst
c2d1fcfcd4 Update linting (#846) 2025-05-29 20:01:49 +02:00
Leendert de Borst
e5a340b67d Add android build to workflow (#846) 2025-05-29 20:00:48 +02:00
Leendert de Borst
6a0e8909a8 Refactor default auth method setting to be part of login flow (#846) 2025-05-29 18:40:10 +02:00
Leendert de Borst
5a90b4271c Fix android crash on back button (#846) 2025-05-29 18:39:41 +02:00
Leendert de Borst
f0bd837d5e Improve security (#846) 2025-05-29 17:16:55 +02:00
Leendert de Borst
de45c286b1 Fix android header issues (#846) 2025-05-29 16:26:13 +02:00
Leendert de Borst
fac0fd5f32 Add android edge-to-edge module to fix menu bar height issues (#846) 2025-05-29 16:05:42 +02:00
Leendert de Borst
5a8b6b7f29 Refactor android to satisfy linting rules (#846) 2025-05-29 13:48:19 +02:00
Leendert de Borst
c864bfcab5 Npx expo-doctor fixes (#846) 2025-05-28 20:23:02 +02:00
Leendert de Borst
c9c692ce6e Add detekt.yml for kotlin code style analysis (#846) 2025-05-28 20:20:07 +02:00
Leendert de Borst
a640e4d280 Update kotlin linting settings (#846) 2025-05-28 19:55:17 +02:00
Leendert de Borst
2f03db7951 Remove unnecessary call (#846) 2025-05-28 19:26:32 +02:00
Leendert de Borst
9e5b733c8a Update logo icons (#846) 2025-05-28 18:44:45 +02:00
Leendert de Borst
09c380afdd Rebuild Android via npx expo rebuild (#846) 2025-05-28 18:04:39 +02:00
Leendert de Borst
7d9cc6118e Rebuild iOS via npx expo prebuild to standardize (#846) 2025-05-28 17:17:49 +02:00
Leendert de Borst
c7ab42e9f2 Add android linting checks and integrate in build process (#846) 2025-05-28 16:43:05 +02:00
Leendert de Borst
1b07c5de9f Update Android UI (#846) 2025-05-28 16:00:49 +02:00
Leendert de Borst
84df5b7d98 Add native settings page open callback for android (#846) 2025-05-28 15:32:27 +02:00
Leendert de Borst
347721a575 Update docs (#846) 2025-05-28 13:51:10 +02:00
Leendert de Borst
463c31641d Make system bar transparent on android (#846) 2025-05-28 13:30:04 +02:00
Leendert de Borst
67759a814e Linting fixes (#846) 2025-05-28 12:33:46 +02:00
Leendert de Borst
763a859e22 Update UI margins to work with Android and iOS (#846) 2025-05-28 12:32:21 +02:00
Leendert de Borst
d7db5a4e76 Refactor UrlUtility to be app-specific (#846) 2025-05-28 10:37:44 +02:00
Leendert de Borst
85bb5cf944 Optimize create new credential for Android (#846) 2025-05-28 10:30:07 +02:00
Leendert de Borst
cdc59e43a9 Update android-autofill.tsx (#846) 2025-05-28 09:19:46 +02:00
Leendert de Borst
9d0a003b2d Refactor (#846) 2025-05-27 17:16:12 +02:00
Leendert de Borst
e430ae9f4f Refactor FieldFinder to separate file (#846) 2025-05-27 16:58:48 +02:00
Leendert de Borst
41ba1260d7 Add SVG icon support (#846) 2025-05-27 16:49:44 +02:00
Leendert de Borst
c7572ac3f7 Fix issue where open app was not displayed always (#846) 2025-05-27 16:32:45 +02:00
Leendert de Borst
fe5c50b3c4 Add vault locked notice (#846) 2025-05-27 15:53:48 +02:00
Leendert de Borst
2a8ed28ff9 Improve password field type detection (#846) 2025-05-27 15:35:21 +02:00
Leendert de Borst
f6764b2f33 Simplify logic (#846) 2025-05-27 15:16:40 +02:00
Leendert de Borst
1afa153381 Improve field type detection (#846) 2025-05-27 14:52:09 +02:00
Leendert de Borst
ac59273161 Trigger on both password and likely username fields (#846) 2025-05-27 13:55:00 +02:00
Leendert de Borst
551fc42de1 Show service logo if it has one in autofill suggestion (#846) 2025-05-27 13:50:46 +02:00
Leendert de Borst
4b844189bc Add aliasvault logo to autofill list item (#846) 2025-05-27 13:05:39 +02:00
Leendert de Borst
5c277e747f Refactor FieldFinder (#846) 2025-05-27 12:00:49 +02:00
Leendert de Borst
8cbd275134 Improve credential matching (#846) 2025-05-27 11:21:27 +02:00
Leendert de Borst
765625b163 Add credentialmatcher and autofill test scaffolding (#846) 2025-05-26 20:16:28 +02:00
Leendert de Borst
b3df153128 Remove obsolete sharedcredentialstore (#846) 2025-05-26 20:15:57 +02:00
Leendert de Borst
604cffc622 Add autofill docs (#846) 2025-05-26 19:34:25 +02:00
Leendert de Borst
3b114445a3 Add android docs (#846) 2025-05-26 19:34:17 +02:00
Leendert de Borst
e8942c9833 Make basic autofill dropdown work in chrome (#846) 2025-05-26 14:46:50 +02:00
Leendert de Borst
b1da32ceae Add inline suggestion flag (#846) 2025-05-26 13:12:35 +02:00
Leendert de Borst
ef58217ed3 Update autocomplete logic to only trigger for username or password fields (#846) 2025-05-26 12:07:36 +02:00
Leendert de Borst
e0dd04263c Refactor AutofillService to use VaultStore (#846) 2025-05-26 11:53:26 +02:00
Leendert de Borst
29c52c844f Add vaultstore generic instance for sharing main app and autofill component (#846) 2025-05-26 11:41:01 +02:00
Leendert de Borst
b99025c48a Remove deprecated files (#846) 2025-05-26 11:40:02 +02:00
Leendert de Borst
8ba8eb684e Add android autofill instructions page (#846) 2025-05-26 09:49:40 +02:00
Leendert de Borst
b736edbb68 Update skeleton loader color for light mode (#846) 2025-05-25 12:16:52 +02:00
Leendert de Borst
1fa0d275cc Update search input style (#846) 2025-05-24 19:21:52 +02:00
Leendert de Borst
4a05cd00e3 Fix add-edit on Android (#846) 2025-05-23 16:50:17 +02:00
Leendert de Borst
574b5ff693 Add generic ThemedContainer component (#846) 2025-05-23 16:32:35 +02:00
Leendert de Borst
e6b7d1afa1 Display add button on android (#846) 2025-05-23 15:44:56 +02:00
Leendert de Borst
cbe224385d Refactor function naming (#846) 2025-05-23 15:08:07 +02:00
Leendert de Borst
adb2f9a3d6 Add Android specific header style (#846) 2025-05-23 14:05:55 +02:00
Leendert de Borst
6790391d37 Use Base64.NO_WRAP for android to be compatible with other RFC 4648 clients (#846) 2025-05-23 12:35:14 +02:00
Leendert de Borst
2a7855e1dc Refactor (#846) 2025-05-23 11:50:07 +02:00
Leendert de Borst
f3e47d7e67 Add autolock timer to Android logic (#846) 2025-05-22 18:09:17 +02:00
Leendert de Borst
bc76e85a9c Update function naming (#846) 2025-05-22 16:54:37 +02:00
Leendert de Borst
890025cd49 Allow PIN fallback on Android unlock flow (#846) 2025-05-22 13:41:08 +02:00
Leendert de Borst
1868370d8f Make basic biometric keystore flow work (#846) 2025-05-22 13:01:38 +02:00
Leendert de Borst
9a4fc7fb37 Update vault unlock page for android (#846) 2025-05-21 17:56:16 +02:00
Leendert de Borst
199fdebd5d Add KeystoreProvider scaffolding (#846) 2025-05-21 16:11:35 +02:00
Leendert de Borst
d5f17ef99c Add base64 conversion logic (#846) 2025-05-21 14:56:18 +02:00
Leendert de Borst
3b1e039d75 Implement commitTransaction (#846) 2025-05-21 14:05:55 +02:00
Leendert de Borst
01cdd28e32 Add .code-workspace to .vscode folder (#846) 2025-05-20 22:39:00 +02:00
Leendert de Borst
95a71f6ab2 Merge pull request #855 from lanedirt/854-prepare-0173-release
Prepare 0.17.3 release
2025-05-20 15:42:46 +02:00
Leendert de Borst
41cb92befd Merge branch 'main' into 854-prepare-0173-release 2025-05-20 15:42:32 +02:00
Leendert de Borst
2cfd1a922f Merge pull request #853 from lanedirt/852-bug-vault-import-fails-if-one-or-more-2fa-tokens-cannot-be-read
Vault import fails if one or more 2FA tokens cannot be parsed
2025-05-20 15:37:30 +02:00
Leendert de Borst
511ec31d17 Bump version to 0.17.3 (#854) 2025-05-20 15:31:22 +02:00
Leendert de Borst
080e505991 Merge branch '850-prepare-0172-release' into 854-prepare-0173-release
* 850-prepare-0172-release:
  Bump version to 0.17.2 (#850)
2025-05-20 15:29:11 +02:00
Leendert de Borst
461c1a042d Silently fail incorrect 2FA codes during import instead of throwing exception (#852) 2025-05-20 15:22:09 +02:00
Leendert de Borst
f30fcf4624 Make SQLite in-memory writable, add test to verify (#846) 2025-05-20 12:57:57 +02:00
Leendert de Borst
522eeefda4 Update docs (#846) 2025-05-20 12:19:22 +02:00
Leendert de Borst
94656c4d14 Update iOS podfile (#846) 2025-05-20 11:48:14 +02:00
Leendert de Borst
bbba8d1393 Make icon symbols generic between Android and iOS platforms (#846) 2025-05-20 11:47:48 +02:00
Leendert de Borst
680f5ba926 Proxy all calls from NativeVaultManager to VaultStore (#846) 2025-05-20 11:24:23 +02:00
Leendert de Borst
04d3f80019 Add getMetadata call (#846) 2025-05-20 11:06:14 +02:00
Leendert de Borst
a4d78cf7fc Make login and vault store/get flow work (#846) 2025-05-20 10:43:35 +02:00
Leendert de Borst
9713c8ed11 Implement getAllCredentials in kotlin, make all unit tests work (#846) 2025-05-19 10:04:39 +02:00
Leendert de Borst
2f4dbf34ba Update formatting (#846) 2025-05-19 10:04:10 +02:00
Leendert de Borst
232d110e49 Update license in index.template.html (#846) 2025-05-18 16:30:01 +02:00
Leendert de Borst
0af1507686 Implement basic vault decrypt/unlock flow (#846) 2025-05-18 16:18:27 +02:00
Leendert de Borst
e481769198 Add storage provider abstraction, move vaultstore its own namespace (#846) 2025-05-18 15:47:02 +02:00
Leendert de Borst
830c390b95 Update Android unit test docs (#846) 2025-05-18 13:51:02 +02:00
Leendert de Borst
c733a60571 Refactor query specific logic to VaultStore instead of NativeVaultManager (#846) 2025-05-18 13:45:22 +02:00
Leendert de Borst
d164d8e785 Merge pull request #851 from lanedirt/850-prepare-0172-release
Prepare 0.17.2 release
2025-05-17 17:41:30 +02:00
Leendert de Borst
826bd23767 Restore docker-compose.yml container versions to :latest (#848) 2025-05-17 17:35:51 +02:00
Leendert de Borst
a70f6fca56 Add Android native vault manager unit test scaffolding (#846) 2025-05-17 12:00:12 +02:00
Leendert de Borst
1480fd88d1 Implement NativeVaultManager kotlin scaffolding (#846) 2025-05-17 11:04:22 +02:00
Leendert de Borst
11a5e10f4b Update comments (#846) 2025-05-17 11:00:39 +02:00
Leendert de Borst
eecf61b8b2 Fix packages to make android buildable (#846) 2025-05-16 17:46:55 +02:00
Leendert de Borst
6c620e34e6 Update docs (#846) 2025-05-16 17:14:05 +02:00
Leendert de Borst
aa99bbc111 Remove sqlite migration scripts (#494) 2025-05-15 16:37:39 +02:00
Leendert de Borst
e34b5f586c Remove SQLite server database implementation in code (#494) 2025-05-15 16:37:39 +02:00
Leendert de Borst
80c0992eb4 Update docs (#494) 2025-05-15 16:37:39 +02:00
599 changed files with 24656 additions and 35949 deletions

View File

@@ -0,0 +1,132 @@
name: "Build Android App"
description: "Builds Android APK/AAB, optionally signs and uploads to GitHub Release"
inputs:
run_tests:
description: "Whether to run Android unit tests"
required: false
default: "false"
signed:
description: "Whether to sign the Android build"
required: false
default: "false"
upload_to_release:
description: "Whether to upload the APK to GitHub Release"
required: false
default: "false"
runs:
using: "composite"
steps:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: apps/mobile-app/package-lock.json
- name: Install dependencies
run: npm ci
shell: bash
working-directory: apps/mobile-app
- name: Extract version
run: |
VERSION=$(node -p "require('./app.json').expo.version")
echo "VERSION=$VERSION" >> $GITHUB_ENV
shell: bash
working-directory: apps/mobile-app
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Build JS bundle (Expo)
run: |
mkdir -p build
npx expo export \
--dev \
--output-dir ./build \
--platform android
shell: bash
working-directory: apps/mobile-app
- name: Run Android Unit Tests
if: ${{ inputs.run_tests == 'true' }}
run: |
cd android
./gradlew :app:testDebugUnitTest --tests "net.aliasvault.app.*"
shell: bash
working-directory: apps/mobile-app
- name: Upload Android Test Reports
if: ${{ inputs.run_tests == 'true' }}
uses: actions/upload-artifact@v4
with:
name: android-test-reports
path: apps/mobile-app/android/app/build/reports/tests/testDebugUnitTest/
retention-days: 7
- name: Decode keystore
if: ${{ inputs.signed == 'true' }}
run: echo "${{ env.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
shell: bash
working-directory: apps/mobile-app
- name: Configure signing
if: ${{ inputs.signed == 'true' }}
run: |
cat >> android/gradle.properties <<EOF
ALIASVAULT_UPLOAD_STORE_FILE=keystore.jks
ALIASVAULT_UPLOAD_KEY_ALIAS=${{ env.ANDROID_KEY_ALIAS }}
ALIASVAULT_UPLOAD_STORE_PASSWORD=${{ env.ANDROID_KEYSTORE_PASSWORD }}
ALIASVAULT_UPLOAD_KEY_PASSWORD=${{ env.ANDROID_KEY_PASSWORD }}
EOF
shell: bash
working-directory: apps/mobile-app
- name: Build APK & AAB (Release only if signed)
if: ${{ inputs.signed == 'true' }}
run: |
cd android
./gradlew bundleRelease
./gradlew assembleRelease
shell: bash
working-directory: apps/mobile-app
- name: Rename APK and AAB files
if: ${{ inputs.signed == 'true' }}
run: |
mv android/app/build/outputs/apk/release/app-release.apk android/app/build/outputs/apk/release/aliasvault-${VERSION}-android.apk
mv android/app/build/outputs/bundle/release/app-release.aab android/app/build/outputs/bundle/release/aliasvault-${VERSION}-android.aab
shell: bash
working-directory: apps/mobile-app
- name: Upload AAB as artifact
if: ${{ inputs.signed == 'true' }}
uses: actions/upload-artifact@v4
with:
name: aliasvault-${{ env.VERSION }}-android.aab
path: apps/mobile-app/android/app/build/outputs/bundle/release/aliasvault-${{ env.VERSION }}-android.aab
retention-days: 14
- name: Upload APK as artifact
if: ${{ inputs.signed == 'true' }}
uses: actions/upload-artifact@v4
with:
name: aliasvault-${{ env.VERSION }}-android.apk
path: apps/mobile-app/android/app/build/outputs/apk/release/aliasvault-${{ env.VERSION }}-android.apk
retention-days: 14
- name: Upload APK to release
if: ${{ inputs.upload_to_release == 'true' }}
uses: softprops/action-gh-release@v2
with:
files: apps/mobile-app/android/app/build/outputs/apk/release/aliasvault-${{ env.VERSION }}-android.apk
env:
GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }}

View File

@@ -0,0 +1,104 @@
name: "Build Browser Extension"
description: "Builds, tests, lints, zips, and optionally uploads a browser extension"
inputs:
browser:
description: "Target browser to build for (chrome, firefox, edge)"
required: true
upload_to_release:
description: "Whether to upload the resulting zip to GitHub Release"
required: false
default: "false"
runs:
using: "composite"
steps:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: apps/browser-extension/package-lock.json
- name: Install dependencies
run: npm ci
shell: bash
working-directory: apps/browser-extension
- name: Build extension
run: npm run build:${{ inputs.browser }}
shell: bash
working-directory: apps/browser-extension
- name: Run tests
run: npm run test
shell: bash
working-directory: apps/browser-extension
- name: Run linting
run: npm run lint
shell: bash
working-directory: apps/browser-extension
- name: Zip Extension
run: npm run zip:${{ inputs.browser }}
shell: bash
working-directory: apps/browser-extension
- name: Extract version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "VERSION=$VERSION" >> $GITHUB_ENV
shell: bash
working-directory: apps/browser-extension
- name: Unzip extension
run: |
mkdir -p dist/${{ inputs.browser }}-unpacked
unzip dist/aliasvault-browser-extension-${{ env.VERSION }}-${{ inputs.browser }}.zip -d dist/${{ inputs.browser }}-unpacked
shell: bash
working-directory: apps/browser-extension
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: aliasvault-${{ env.VERSION }}-${{ inputs.browser }}
path: apps/browser-extension/dist/${{ inputs.browser }}-unpacked
- name: Unzip and upload Firefox sources
if: ${{ inputs.browser == 'firefox' }}
run: |
mkdir -p dist/sources-unpacked
unzip dist/aliasvault-browser-extension-${{ env.VERSION }}-sources.zip -d dist/sources-unpacked
shell: bash
working-directory: apps/browser-extension
- name: Upload Firefox sources artifact
if: ${{ inputs.browser == 'firefox' }}
uses: actions/upload-artifact@v4
with:
name: aliasvault-${{ env.VERSION }}-browser-extension-sources
path: apps/browser-extension/dist/sources-unpacked
- name: Rename zip files
run: |
mv apps/browser-extension/dist/aliasvault-browser-extension-${{ env.VERSION }}-${{ inputs.browser }}.zip apps/browser-extension/dist/aliasvault-${{ env.VERSION }}-${{ inputs.browser }}.zip
if [ -f apps/browser-extension/dist/aliasvault-browser-extension-${{ env.VERSION }}-sources.zip ]; then
mv apps/browser-extension/dist/aliasvault-browser-extension-${{ env.VERSION }}-sources.zip apps/browser-extension/dist/aliasvault-${{ env.VERSION }}-browser-extension-sources.zip
fi
shell: bash
- name: Upload to GitHub Release
if: ${{ inputs.upload_to_release == 'true' }}
uses: softprops/action-gh-release@v1
with:
files: |
apps/browser-extension/dist/aliasvault-${{ env.VERSION }}-${{ inputs.browser }}.zip
env:
GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }}
- name: Upload Firefox sources to Release
if: ${{ inputs.upload_to_release == 'true' && inputs.browser == 'firefox' }}
uses: softprops/action-gh-release@v1
with:
files: apps/browser-extension/dist/aliasvault-${{ env.VERSION }}-browser-extension-sources.zip
env:
GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }}

118
.github/actions/build-ios-app/action.yml vendored Normal file
View File

@@ -0,0 +1,118 @@
name: "Build iOS App"
description: "Builds iOS App, optionally signs and uploads to App Store Connect"
inputs:
run_tests:
description: "Whether to run iOS unit tests"
required: false
default: "false"
signed:
description: "Whether to sign the iOS build"
required: false
default: "false"
upload_to_app_store_connect:
description: "Whether to upload the iOS App to App Store Connect"
required: false
default: "false"
runs:
using: "composite"
steps:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: apps/mobile-app/package-lock.json
- name: Install dependencies
run: npm ci
shell: bash
working-directory: apps/mobile-app
- name: Extract version
run: |
VERSION=$(node -p "require('./app.json').expo.version")
echo "VERSION=$VERSION" >> $GITHUB_ENV
shell: bash
working-directory: apps/mobile-app
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Install Fastlane
run: gem install fastlane
shell: bash
- name: Install CocoaPods
run: |
sudo gem install cocoapods
shell: bash
- name: Create ASC private key file
if: ${{ inputs.signed == 'true' }}
run: |
mkdir -p $RUNNER_TEMP/asc
echo "${{ env.ASC_PRIVATE_KEY_BASE64 }}" | base64 --decode > $RUNNER_TEMP/asc/AuthKey.p8
shell: bash
- name: Install CocoaPods
run: |
cd ios
pod install
shell: bash
working-directory: apps/mobile-app
- name: Build iOS IPA
if: ${{ inputs.signed == 'true' }}
env:
IDEFileSystemSynchronizedGroupsAreEnabled: NO
XCODE_WORKSPACE: AliasVault.xcworkspace
XCODE_SCHEME: AliasVault
XCODE_CONFIGURATION: Release
XCODE_ARCHIVE_PATH: AliasVault.xcarchive
XCODE_EXPORT_PATH: ./build
XCODE_SKIP_FILESYSTEM_SYNC: true
run: |
cd ios
xcodebuild clean -workspace "$XCODE_WORKSPACE" \
-scheme "$XCODE_SCHEME" \
-configuration "$XCODE_CONFIGURATION"
xcodebuild -workspace "$XCODE_WORKSPACE" \
-scheme "$XCODE_SCHEME" \
-configuration "$XCODE_CONFIGURATION" \
-archivePath "$XCODE_ARCHIVE_PATH" \
-destination 'generic/platform=iOS' \
-allowProvisioningUpdates \
-authenticationKeyPath $RUNNER_TEMP/asc/AuthKey.p8 \
-authenticationKeyID ${{ env.ASC_KEY_ID }} \
-authenticationKeyIssuerID ${{ env.ASC_ISSUER_ID }} \
archive
xcodebuild -exportArchive \
-archivePath "$XCODE_ARCHIVE_PATH" \
-exportOptionsPlist ../exportOptions.plist \
-exportPath "$XCODE_EXPORT_PATH"
shell: bash
working-directory: apps/mobile-app
- name: Upload IPA as artifact
if: ${{ inputs.signed == 'true' }}
uses: actions/upload-artifact@v4
with:
name: aliasvault-${{ env.VERSION }}-ios.ipa
path: apps/mobile-app/ios/build/AliasVault.ipa
retention-days: 14
- name: Upload to App Store Connect via Fastlane
if: ${{ inputs.upload_to_app_store_connect == 'true' }}
env:
ASC_KEY_ID: ${{ env.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ env.ASC_ISSUER_ID }}
run: |
cd apps/mobile-app/ios
fastlane pilot upload \
--ipa "./build/AliasVault.ipa" \
--api_key_path "$RUNNER_TEMP/asc/AuthKey.p8" \
--skip_waiting_for_build_processing true
shell: bash

View File

@@ -32,8 +32,9 @@ jobs:
run: |
# Check if files exist and were recently modified
TARGET_DIRS=(
"apps/browser-extension/src/utils/shared/identity-generator"
"apps/browser-extension/src/utils/shared/password-generator"
"apps/browser-extension/src/utils/dist/shared/identity-generator"
"apps/browser-extension/src/utils/dist/shared/password-generator"
"apps/browser-extension/src/utils/dist/shared/models"
)
for dir in "${TARGET_DIRS[@]}"; do
@@ -42,15 +43,6 @@ jobs:
exit 1
fi
# Check for required files
REQUIRED_FILES=("index.js" "index.mjs" "index.d.ts" "index.js.map" "index.mjs.map")
for file in "${REQUIRED_FILES[@]}"; do
if [ ! -f "$dir/$file" ]; then
echo "❌ Required file $dir/$file does not exist"
exit 1
fi
done
# Check if files were modified in the last 5 minutes
find "$dir" -type f -mmin -5 | grep -q . || {
echo "❌ Files in $dir were not recently modified"
@@ -63,157 +55,32 @@ jobs:
build-chrome-extension:
needs: build-shared-libraries
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/browser-extension
steps:
- uses: actions/checkout@v4
- name: Get short SHA
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Build Chrome Extension
uses: ./.github/actions/build-browser-extension
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: apps/browser-extension/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build extension
run: npm run build:chrome
- name: Run tests
run: npm run test
- name: Run linting
run: npm run lint
- name: Zip Chrome Extension
run: npm run zip:chrome
- name: Unzip for artifact
run: |
mkdir -p dist/chrome-unpacked
unzip dist/aliasvault-browser-extension-*-chrome.zip -d dist/chrome-unpacked
- name: Upload dist artifact Chrome
uses: actions/upload-artifact@v4
with:
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', steps.vars.outputs.sha_short) || steps.vars.outputs.sha_short) }}-chrome
path: apps/browser-extension/dist/chrome-unpacked
outputs:
sha_short: ${{ steps.vars.outputs.sha_short }}
browser: chrome
build-firefox-extension:
needs: build-shared-libraries
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/browser-extension
steps:
- uses: actions/checkout@v4
- name: Get short SHA
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Build Firefox Extension
uses: ./.github/actions/build-browser-extension
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: apps/browser-extension/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build extension
run: npm run build:firefox
- name: Run tests
run: npm run test
- name: Run linting
run: npm run lint
- name: Zip Firefox Extension
run: npm run zip:firefox
- name: Unzip for artifact
run: |
mkdir -p dist/firefox-unpacked
unzip dist/aliasvault-browser-extension-*-firefox.zip -d dist/firefox-unpacked
mkdir -p dist/sources-unpacked
unzip dist/aliasvault-browser-extension-*-sources.zip -d dist/sources-unpacked
- name: Upload dist artifact Firefox
uses: actions/upload-artifact@v4
with:
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', steps.vars.outputs.sha_short) || steps.vars.outputs.sha_short) }}-firefox
path: apps/browser-extension/dist/firefox-unpacked
- name: Upload dist artifact Firefox sources
uses: actions/upload-artifact@v4
with:
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', steps.vars.outputs.sha_short) || steps.vars.outputs.sha_short) }}-sources
path: apps/browser-extension/dist/sources-unpacked
outputs:
sha_short: ${{ steps.vars.outputs.sha_short }}
browser: firefox
build-edge-extension:
needs: build-shared-libraries
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/browser-extension
steps:
- uses: actions/checkout@v4
- name: Get short SHA
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Build Edge Extension
uses: ./.github/actions/build-browser-extension
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: apps/browser-extension/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build extension
run: npm run build:edge
- name: Run tests
run: npm run test
- name: Run linting
run: npm run lint
- name: Zip Edge Extension
run: npm run zip:edge
- name: Unzip for artifact
run: |
mkdir -p dist/edge-unpacked
unzip dist/aliasvault-browser-extension-*-edge.zip -d dist/edge-unpacked
- name: Upload dist artifact Edge
uses: actions/upload-artifact@v4
with:
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', steps.vars.outputs.sha_short) || steps.vars.outputs.sha_short) }}-edge
path: apps/browser-extension/dist/edge-unpacked
outputs:
sha_short: ${{ steps.vars.outputs.sha_short }}
browser: edge

View File

@@ -6,18 +6,33 @@ on:
pull_request:
branches: [ "main" ]
workflow_dispatch:
inputs:
build_android_signed:
description: 'Build signed Android APK/AAB'
required: true
type: boolean
default: false
build_ios_signed:
description: 'Build signed iOS IPA'
required: true
type: boolean
default: false
upload_to_app_store_connect:
description: 'Upload iOS IPA to App Store Connect'
required: true
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-react-native-app:
setup:
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/mobile-app
steps:
- uses: actions/checkout@v4
@@ -38,8 +53,9 @@ jobs:
run: |
# Check if files exist and were recently modified
TARGET_DIRS=(
"utils/shared/identity-generator"
"utils/shared/password-generator"
"utils/dist/shared/identity-generator"
"utils/dist/shared/password-generator"
"utils/dist/shared/models"
)
for dir in "${TARGET_DIRS[@]}"; do
@@ -48,15 +64,6 @@ jobs:
exit 1
fi
# Check for required files
REQUIRED_FILES=("index.js" "index.mjs" "index.d.ts" "index.js.map" "index.mjs.map")
for file in "${REQUIRED_FILES[@]}"; do
if [ ! -f "$dir/$file" ]; then
echo "❌ Required file $dir/$file does not exist"
exit 1
fi
done
# Check if files were modified in the last 5 minutes
find "$dir" -type f -mmin -5 | grep -q . || {
echo "❌ Files in $dir were not recently modified"
@@ -69,6 +76,31 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test
- name: Run linting
run: npm run lint
build-ios:
needs: setup
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/mobile-app
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: apps/mobile-app/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build JS bundle (iOS - Expo)
run: |
mkdir -p build
@@ -77,8 +109,59 @@ jobs:
--output-dir ./build \
--platform ios
- name: Run tests
run: npm run test
build-android:
needs: setup
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run linting
run: npm run lint
- name: Build Android App
uses: ./.github/actions/build-android-app
with:
run_tests: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
build-android-signed:
needs: setup
if: github.event_name == 'workflow_dispatch' && github.event.inputs.build_android_signed == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build Android App
uses: ./.github/actions/build-android-app
with:
signed: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
build-ios-signed:
needs: setup
if: github.event_name == 'workflow_dispatch' && github.event.inputs.build_ios_signed == 'true'
runs-on: macos-15
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build iOS App
uses: ./.github/actions/build-ios-app
with:
signed: true
upload_to_app_store_connect: ${{ github.event.inputs.upload_to_app_store_connect }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASC_PRIVATE_KEY_BASE64: ${{ secrets.ASC_PRIVATE_KEY_BASE64 }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_TEAM_ID: ${{ secrets.ASC_TEAM_ID }}

View File

@@ -24,42 +24,67 @@ jobs:
files: install.sh
token: ${{ secrets.GITHUB_TOKEN }}
package-browser-extensions:
build-chrome-extension:
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/browser-extension
steps:
- uses: actions/checkout@v4
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Build Chrome Extension
uses: ./.github/actions/build-browser-extension
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: apps/browser-extension/package-lock.json
browser: chrome
upload_to_release: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
run: npm ci
build-firefox-extension:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Zip extensions
run: |
npm run zip:chrome
npm run zip:firefox
npm run zip:edge
- name: Upload extensions to release
uses: softprops/action-gh-release@v2
- name: Build Firefox Extension
uses: ./.github/actions/build-browser-extension
with:
files: |
apps/browser-extension/dist/aliasvault-browser-extension-*-chrome.zip
apps/browser-extension/dist/aliasvault-browser-extension-*-firefox.zip
apps/browser-extension/dist/aliasvault-browser-extension-*-edge.zip
apps/browser-extension/dist/aliasvault-browser-extension-*-sources.zip
token: ${{ secrets.GITHUB_TOKEN }}
browser: firefox
upload_to_release: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-edge-extension:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build Edge Extension
uses: ./.github/actions/build-browser-extension
with:
browser: edge
upload_to_release: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-android-release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build Android App
uses: ./.github/actions/build-android-app
with:
signed: true
upload_to_release: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
build-and-push-docker:
needs: [upload-install-script, package-browser-extensions]
runs-on: ubuntu-latest
permissions:
contents: read

View File

@@ -66,9 +66,9 @@ jobs:
run: |
$scanner = "${{ github.workspace }}\.sonar\scanner\dotnet-sonarscanner"
if ('${{ github.event_name }}' -eq 'pull_request_target') {
& $scanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.pullrequest.key=${{ github.event.pull_request.number }} /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs" /d:sonar.exclusions="**/__tests__/test-forms/*.html"
& $scanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.pullrequest.key=${{ github.event.pull_request.number }} /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs" /d:sonar.exclusions="**/__tests__/test-forms/*.html,**/dist/shared/**"
} else {
& $scanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs" /d:sonar.exclusions="**/__tests__/test-forms/*.html"
& $scanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs" /d:sonar.exclusions="**/__tests__/test-forms/*.html,**/dist/shared/**"
}
dotnet build
dotnet test -c Release /p:CollectCoverage=true /p:CoverletOutput=coverage /p:CoverletOutputFormat=opencover --filter 'FullyQualifiedName!~AliasVault.E2ETests'

6
.gitignore vendored
View File

@@ -9,7 +9,6 @@
*.user
*.userosscache
*.sln.docstates
*.code-workspace
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
@@ -419,4 +418,7 @@ temp
# libraries and copied to the application so they can be used for debugging, but we don't need
# to check them in as it's not needed for the applications to actually run.
**/*.js.map
**/*.mjs.map
**/*.mjs.map
# Android keystore file (for publishing to Google Play)
*.keystore

27
.vscode/AliasVault.code-workspace vendored Normal file
View File

@@ -0,0 +1,27 @@
{
"folders": [
{
"name": "AliasVault",
"path": "../"
},
{
"name": "server",
"path": "../apps/server"
},
{
"name": "browser-extension",
"path": "../apps/browser-extension"
},
{
"name": "mobile-app",
"path": "../apps/mobile-app"
},
{
"path": "../docs"
},
{
"path": "../shared"
}
],
"settings": {}
}

4
.vscode/tasks.json vendored
View File

@@ -155,10 +155,10 @@
}
},
{
"label": "Run Android App",
"label": "Run release Android App (device)",
"type": "shell",
"command": "npx",
"args": ["expo", "run:android"],
"args": ["expo", "run:android", "--device", "--variant", "release"],
"problemMatcher": [],
"group": {
"kind": "build",

View File

@@ -1,5 +1,5 @@
# <img src="https://github.com/user-attachments/assets/933c8b45-a190-4df6-913e-b7c64ad9938b" width="35" alt="AliasVault"> AliasVault
End-to-end encrypted password manager with built-in alias and email generation — giving you full control over your online identity and safeguarding your privacy. AliasVault: the privacy toolbox that you control.
The privacy-first password & email alias manager. Fully end-to-end encrypted, with built-in alias generation and email server — giving you full control over your online identity and safeguarding your privacy.
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github&label=Release">](https://github.com/lanedirt/AliasVault/releases)
[![.NET E2E Tests (with Sharding)](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-e2e-tests.yml/badge.svg)](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-e2e-tests.yml)
@@ -47,7 +47,7 @@ Built on 15 years of experience, AliasVault is open-source, self-hostable and co
## Cloud-hosted
Use the official cloud version of AliasVault at [app.aliasvault.net](https://app.aliasvault.net). This fully supported platform is always up to date with our latest release.
AliasVault is available on: [Web](https://app.aliasvault.net) | [iOS](https://apps.apple.com/app/id6745490915) | [Chrome](https://chromewebstore.google.com/detail/aliasvault/bmoggiinmnodjphdjnmpcnlleamkfedj) | [Firefox](https://addons.mozilla.org/en-US/firefox/addon/aliasvault/) | [Edge](https://microsoftedge.microsoft.com/addons/detail/aliasvault/kabaanafahnjkfkplbnllebdmppdemfo) | [Safari](https://apps.apple.com/app/id6743163173)
AliasVault is available on: [Web](https://app.aliasvault.net) | [iOS](https://apps.apple.com/app/id6745490915) | [Android](https://play.google.com/store/apps/details?id=net.aliasvault.app) | [Chrome](https://chromewebstore.google.com/detail/aliasvault/bmoggiinmnodjphdjnmpcnlleamkfedj) | [Firefox](https://addons.mozilla.org/en-US/firefox/addon/aliasvault/) | [Edge](https://microsoftedge.microsoft.com/addons/detail/aliasvault/kabaanafahnjkfkplbnllebdmppdemfo) | [Safari](https://apps.apple.com/app/id6743163173)
[<img width="700" alt="Screenshot of AliasVault" src="docs/assets/img/screenshot.png">](https://app.aliasvault.net)
@@ -114,8 +114,8 @@ Core features that are being worked on:
- [x] Built-in TOTP authenticator
- [x] Import passwords from traditional password managers
- [x] iOS native app
- [ ] Android native app
- [ ] Data model improvements to support reusable identities in combination with aliases
- [x] Android native app
- [ ] Data model and usability improvements (more flexible aliases and credential types, folder support, editing in browser extension, bulk selecting etc.)
- [ ] Support for FIDO2/WebAuthn hardware keys and passkeys
- [ ] Adding support for family/team sharing (organization features)

View File

@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
.output
dist
!src/utils/dist
stats.html
stats-*.json
.wxt

View File

@@ -12,7 +12,7 @@ export default [
ignores: [
"dist/**",
"node_modules/**",
"src/utils/shared/**",
"src/utils/dist/**",
]
},
js.configs.recommended,
@@ -105,8 +105,57 @@ export default [
],
"react-hooks/exhaustive-deps": "warn",
"react/jsx-no-constructed-context-values": "error",
},
"import/no-unresolved": [
"error",
{
ignore: ['^#imports$'] // Ignore virtual imports from WXT which are not resolved by the typescript compiler
}
],
"import/order": [
"error",
{
"groups": [
"builtin", // Node "fs", "path", etc.
"external", // "react", "lodash", etc.
"internal", // Aliased paths like "@/utils"
"parent", // "../"
"sibling", // "./"
"index", // "./index"
"object", // import 'foo'
"type" // import type ...
],
"pathGroups": [
{
pattern: "@/entrypoints/**",
group: "internal",
position: "before"
},
{
pattern: "@/utils/**",
group: "internal",
position: "before"
},
{
pattern: "@/hooks/**",
group: "internal",
position: "before"
}
],
"pathGroupsExcludedImportTypes": ["builtin"],
"newlines-between": "always",
"alphabetize": {
order: "asc",
caseInsensitive: true
}
}
],
},
settings: {
'import/resolver': {
typescript: {
project: './tsconfig.json',
},
},
react: {
version: "detect",
},

View File

@@ -1,25 +1,28 @@
{
"name": "aliasvault-browser-extension",
"version": "0.0.0",
"version": "0.18.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aliasvault-browser-extension",
"version": "0.0.0",
"version": "0.18.1",
"hasInstallScript": true,
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"argon2-browser": "^1.18.0",
"buffer": "^6.0.3",
"globals": "^16.0.0",
"otpauth": "^9.3.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.57.0",
"react-router-dom": "^7.5.2",
"secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0",
"sql.js": "^1.12.0",
"vitest": "^3.0.8",
"webext-bridge": "^6.0.1"
"webext-bridge": "^6.0.1",
"yup": "^1.6.1"
},
"devDependencies": {
"@types/chrome": "^0.0.280",
@@ -28,12 +31,14 @@
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@types/sql.js": "^1.4.9",
"@types/yup": "^0.29.14",
"@typescript-eslint/eslint-plugin": "^8.21.0",
"@typescript-eslint/parser": "^8.21.0",
"@vitest/coverage-v8": "^3.0.8",
"@wxt-dev/module-react": "^1.1.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.19.0",
"eslint-import-resolver-typescript": "^4.4.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsdoc": "^50.6.3",
"eslint-plugin-react": "^7.37.4",
@@ -699,6 +704,40 @@
}
}
},
"node_modules/@emnapi/core": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
"integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz",
"integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz",
"integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@es-joy/jsdoccomment": {
"version": "0.49.0",
"resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.49.0.tgz",
@@ -1310,6 +1349,18 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@hookform/resolvers": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.1.1.tgz",
"integrity": "sha512-J/NVING3LMAEvexJkyTLjruSm7aOFx7QX21pzkiJfMoNG0wl5aFEjLTl7ay7IQb9EWY6AkrBy7tHL2Alijpdcg==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1478,6 +1529,19 @@
"node": ">=18"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz",
"integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.9.0"
}
},
"node_modules/@noble/hashes": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz",
@@ -1894,6 +1958,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
"integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2085,6 +2166,13 @@
"@types/node": "*"
}
},
"node_modules/@types/yup": {
"version": "0.29.14",
"resolved": "https://registry.npmjs.org/@types/yup/-/yup-0.29.14.tgz",
"integrity": "sha512-Ynb/CjHhE/Xp/4bhHmQC4U1Ox+I2OpfRYF3dnNgQqn1cHa6LK3H1wJMNPT02tSVZA6FYuXE2ITORfbnb6zBCSA==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.32.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz",
@@ -2278,6 +2366,247 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@unrs/resolver-binding-darwin-arm64": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.7.11.tgz",
"integrity": "sha512-i3/wlWjQJXMh1uiGtiv7k1EYvrrS3L1hdwmWJJiz1D8jWy726YFYPIxQWbEIVPVAgrfRR0XNlLrTQwq17cuCGw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@unrs/resolver-binding-darwin-x64": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.7.11.tgz",
"integrity": "sha512-8XXyFvc6w6kmMmi6VYchZhjd5CDcp+Lv6Cn1YmUme0ypsZ/0Kzd+9ESrWtDrWibKPTgSteDTxp75cvBOY64FQQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@unrs/resolver-binding-freebsd-x64": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.7.11.tgz",
"integrity": "sha512-0qJBYzP8Qk24CZ05RSWDQUjdiQUeIJGfqMMzbtXgCKl/a5xa6thfC0MQkGIr55LCLd6YmMyO640ifYUa53lybQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.7.11.tgz",
"integrity": "sha512-1sGwpgvx+WZf0GFT6vkkOm6UJ+mlsVnjw+Yv9esK71idWeRAG3bbpkf3AoY8KIqKqmnzJExi0uKxXpakQ5Pcbg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.7.11.tgz",
"integrity": "sha512-D/1F/2lTe+XAl3ohkYj51NjniVly8sIqkA/n1aOND3ZMO418nl2JNU95iVa1/RtpzaKcWEsNTtHRogykrUflJg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.7.11.tgz",
"integrity": "sha512-7vFWHLCCNFLEQlmwKQfVy066ohLLArZl+AV/AdmrD1/pD1FlmqM+FKbtnONnIwbHtgetFUCV/SRi1q4D49aTlw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-arm64-musl": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.7.11.tgz",
"integrity": "sha512-tYkGIx8hjWPh4zcn17jLEHU8YMmdP2obRTGkdaB3BguGHh31VCS3ywqC4QjTODjmhhNyZYkj/1Dz/+0kKvg9YA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.7.11.tgz",
"integrity": "sha512-6F328QIUev29vcZeRX6v6oqKxfUoGwIIAhWGD8wSysnBYFY0nivp25jdWmAb1GildbCCaQvOKEhCok7YfWkj4Q==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.7.11.tgz",
"integrity": "sha512-NqhWmiGJGdzbZbeucPZIG9Iav4lyYLCarEnxAceguMx9qlpeEF7ENqYKOwB8Zqk7/CeuYMEcLYMaW2li6HyDzQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.7.11.tgz",
"integrity": "sha512-J2RPIFKMdTrLtBdfR1cUMKl8Gcy05nlQ+bEs/6al7EdWLk9cs3tnDREHZ7mV9uGbeghpjo4i8neNZNx3PYUY9w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.7.11.tgz",
"integrity": "sha512-bDpGRerHvvHdhun7MmFUNDpMiYcJSqWckwAVVRTJf8F+RyqYJOp/mx04PDc7DhpNPeWdnTMu91oZRMV+gGaVcQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-x64-gnu": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.7.11.tgz",
"integrity": "sha512-G9U7bVmylzRLma3cK39RBm3guoD1HOvY4o0NS4JNm37AD0lS7/xyMt7kn0JejYyc0Im8J+rH69/dXGM9DAJcSQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-x64-musl": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.7.11.tgz",
"integrity": "sha512-7qL20SBKomekSunm7M9Fe5L93bFbn+FbHiGJbfTlp0RKhPVoJDP73vOxf1QrmJHyDPECsGWPFnKa/f8fO2FsHw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-wasm32-wasi": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.7.11.tgz",
"integrity": "sha512-jisvIva8MidjI+B1lFRZZMfCPaCISePgTyR60wNT1MeQvIh5Ksa0G3gvI+Iqyj3jqYbvOHByenpa5eDGcSdoSg==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^0.2.10"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.7.11.tgz",
"integrity": "sha512-G+H5nQZ8sRZ8ebMY6mRGBBvTEzMYEcgVauLsNHpvTUavZoCCRVP1zWkCZgOju2dW3O22+8seTHniTdl1/uLz3g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.7.11.tgz",
"integrity": "sha512-Hfy46DBfFzyv0wgR0MMOwFFib2W2+Btc8oE5h4XlPhpelnSyA6nFxkVIyTgIXYGTdFaLoZFNn62fmqx3rjEg3A==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@unrs/resolver-binding-win32-x64-msvc": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.7.11.tgz",
"integrity": "sha512-7L8NdsQlCJ8T106Gbz/AjzM4QKWVsoQbKpB9bMBGcIZswUuAnJMHpvbqGW3RBqLHCIwX4XZ5fxSBHEFcK2h9wA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@vitejs/plugin-react": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz",
@@ -4421,9 +4750,9 @@
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -5191,6 +5520,31 @@
}
}
},
"node_modules/eslint-import-context": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.8.tgz",
"integrity": "sha512-bq+F7nyc65sKpZGT09dY0S0QrOnQtuDVIfyTGQ8uuvtMIF7oHp6CEP3mouN0rrnYF3Jqo6Ke0BfU/5wASZue1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-tsconfig": "^4.10.1",
"stable-hash-x": "^0.1.1"
},
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint-import-context"
},
"peerDependencies": {
"unrs-resolver": "^1.0.0"
},
"peerDependenciesMeta": {
"unrs-resolver": {
"optional": true
}
}
},
"node_modules/eslint-import-resolver-node": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
@@ -5213,6 +5567,41 @@
"ms": "^2.1.1"
}
},
"node_modules/eslint-import-resolver-typescript": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.3.tgz",
"integrity": "sha512-elVDn1eWKFrWlzxlWl9xMt8LltjKl161Ix50JFC50tHXI5/TRP32SNEqlJ/bo/HV+g7Rou/tlPQU2AcRtIhrOg==",
"dev": true,
"license": "ISC",
"dependencies": {
"debug": "^4.4.1",
"eslint-import-context": "^0.1.8",
"get-tsconfig": "^4.10.1",
"is-bun-module": "^2.0.0",
"stable-hash-x": "^0.1.1",
"tinyglobby": "^0.2.14",
"unrs-resolver": "^1.7.11"
},
"engines": {
"node": "^16.17.0 || >=18.6.0"
},
"funding": {
"url": "https://opencollective.com/eslint-import-resolver-typescript"
},
"peerDependencies": {
"eslint": "*",
"eslint-plugin-import": "*",
"eslint-plugin-import-x": "*"
},
"peerDependenciesMeta": {
"eslint-plugin-import": {
"optional": true
},
"eslint-plugin-import-x": {
"optional": true
}
}
},
"node_modules/eslint-module-utils": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz",
@@ -6279,6 +6668,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-tsconfig": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
"integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/giget": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/giget/-/giget-1.2.5.tgz",
@@ -6911,6 +7313,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-bun-module": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz",
"integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.7.1"
}
},
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@@ -8569,6 +8981,22 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-postinstall": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.4.tgz",
"integrity": "sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg==",
"dev": true,
"license": "MIT",
"bin": {
"napi-postinstall": "lib/cli.js"
},
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/napi-postinstall"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -9702,6 +10130,12 @@
"react-is": "^16.13.1"
}
},
"node_modules/property-expr": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
"license": "MIT"
},
"node_modules/proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
@@ -10340,6 +10774,22 @@
"react": "^19.1.0"
}
},
"node_modules/react-hook-form": {
"version": "7.57.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.57.0.tgz",
"integrity": "sha512-RbEks3+cbvTP84l/VXGUZ+JMrKOS8ykQCRYdm5aYsxnDquL0vspsyNhGRO7pcH6hsZqWlPOjLye7rJqdtdAmlg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -10588,6 +11038,16 @@
"node": ">=4"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
@@ -11333,6 +11793,16 @@
"integrity": "sha512-RJbVP1HRDlUUXahJ7VMTcu9Rm1Nzw+EBpoPr94vnbD4LwR715F3CcxE2G2k45PewcaZ57pjetYa+LoSJLAASgA==",
"license": "MIT"
},
"node_modules/stable-hash-x": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.1.1.tgz",
"integrity": "sha512-l0x1D6vhnsNUGPFVDx45eif0y6eedVC8nm5uACTrVFJFtl2mLRW17aWtVyxFCpn5t94VUPkjU8vSLwIuwwqtJQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -11833,6 +12303,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/tiny-case": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
"license": "MIT"
},
"node_modules/tiny-uid": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tiny-uid/-/tiny-uid-1.1.2.tgz",
@@ -11852,9 +12328,9 @@
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
@@ -11986,6 +12462,12 @@
"node": ">=0.6"
}
},
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
@@ -12346,6 +12828,39 @@
"node": ">=14.0.0"
}
},
"node_modules/unrs-resolver": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.7.11.tgz",
"integrity": "sha512-OhuAzBImFPjKNgZ2JwHMfGFUA6NSbRegd1+BPjC1Y0E6X9Y/vJ4zKeGmIMqmlYboj6cMNEwKI+xQisrg4J0HaQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"napi-postinstall": "^0.2.2"
},
"funding": {
"url": "https://opencollective.com/unrs-resolver"
},
"optionalDependencies": {
"@unrs/resolver-binding-darwin-arm64": "1.7.11",
"@unrs/resolver-binding-darwin-x64": "1.7.11",
"@unrs/resolver-binding-freebsd-x64": "1.7.11",
"@unrs/resolver-binding-linux-arm-gnueabihf": "1.7.11",
"@unrs/resolver-binding-linux-arm-musleabihf": "1.7.11",
"@unrs/resolver-binding-linux-arm64-gnu": "1.7.11",
"@unrs/resolver-binding-linux-arm64-musl": "1.7.11",
"@unrs/resolver-binding-linux-ppc64-gnu": "1.7.11",
"@unrs/resolver-binding-linux-riscv64-gnu": "1.7.11",
"@unrs/resolver-binding-linux-riscv64-musl": "1.7.11",
"@unrs/resolver-binding-linux-s390x-gnu": "1.7.11",
"@unrs/resolver-binding-linux-x64-gnu": "1.7.11",
"@unrs/resolver-binding-linux-x64-musl": "1.7.11",
"@unrs/resolver-binding-wasm32-wasi": "1.7.11",
"@unrs/resolver-binding-win32-arm64-msvc": "1.7.11",
"@unrs/resolver-binding-win32-ia32-msvc": "1.7.11",
"@unrs/resolver-binding-win32-x64-msvc": "1.7.11"
}
},
"node_modules/untildify": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz",
@@ -13472,6 +13987,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yup": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz",
"integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==",
"license": "MIT",
"dependencies": {
"property-expr": "^2.0.5",
"tiny-case": "^1.0.3",
"toposort": "^2.0.2",
"type-fest": "^2.19.0"
}
},
"node_modules/zip-dir": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/zip-dir/-/zip-dir-2.0.0.tgz",

View File

@@ -2,12 +2,13 @@
"name": "aliasvault-browser-extension",
"description": "AliasVault Browser Extension",
"private": true,
"version": "0.0.0",
"version": "0.19.2",
"type": "module",
"scripts": {
"dev:chrome": "wxt -b chrome",
"dev:firefox": "wxt -b firefox",
"dev:edge": "wxt -b edge",
"dev:safari": "wxt -b safari",
"build:chrome": "wxt build -b chrome",
"build:firefox": "wxt build -b firefox",
"build:edge": "wxt build -b edge",
@@ -25,17 +26,20 @@
"postinstall": "wxt prepare"
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"argon2-browser": "^1.18.0",
"buffer": "^6.0.3",
"globals": "^16.0.0",
"otpauth": "^9.3.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.57.0",
"react-router-dom": "^7.5.2",
"secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0",
"sql.js": "^1.12.0",
"vitest": "^3.0.8",
"webext-bridge": "^6.0.1"
"webext-bridge": "^6.0.1",
"yup": "^1.6.1"
},
"devDependencies": {
"@types/chrome": "^0.0.280",
@@ -44,12 +48,14 @@
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@types/sql.js": "^1.4.9",
"@types/yup": "^0.29.14",
"@typescript-eslint/eslint-plugin": "^8.21.0",
"@typescript-eslint/parser": "^8.21.0",
"@vitest/coverage-v8": "^3.0.8",
"@wxt-dev/module-react": "^1.1.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.19.0",
"eslint-import-resolver-typescript": "^4.4.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsdoc": "^50.6.3",
"eslint-plugin-react": "^7.37.4",

View File

@@ -447,7 +447,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -460,7 +460,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 0.19.2;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -479,7 +479,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "AliasVault Extension/AliasVault_Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -492,7 +492,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 0.19.2;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -515,7 +515,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 17;
CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -530,7 +530,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 0.17.2;
MARKETING_VERSION = 0.19.2;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -554,7 +554,7 @@
CODE_SIGN_ENTITLEMENTS = AliasVault/AliasVault.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 17;
CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 0.17.2;
MARKETING_VERSION = 0.19.2;
OTHER_LDFLAGS = (
"-framework",
SafariServices,

View File

@@ -1,3 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind utilities;
@media (max-width: 400px) {
html, body {
width: 350px;
max-width: 350px;
height: 600px;
max-height: 600px;
overflow: hidden;
}
}

View File

@@ -1,11 +1,13 @@
import { defineBackground } from '#imports';
import { onMessage, sendMessage } from "webext-bridge/background";
import { setupContextMenus } from '@/entrypoints/background/ContextMenu';
import { handleCheckAuthStatus, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDefaultIdentityLanguage, handleGetDerivedKey, handleGetPasswordSettings, handleGetVault, handleStoreVault, handleSyncVault } from '@/entrypoints/background/VaultMessageHandler';
import { handleOpenPopup, handlePopupWithCredential, handleToggleContextMenu } from '@/entrypoints/background/PopupMessageHandler';
import { storage, browser } from '#imports';
import { handleCheckAuthStatus, handleClearPersistedFormValues, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDefaultIdentityLanguage, handleGetDerivedKey, handleGetPasswordSettings, handleGetPersistedFormValues, handleGetVault, handlePersistFormValues, handleStoreVault, handleSyncVault, handleUploadVault } from '@/entrypoints/background/VaultMessageHandler';
import { GLOBAL_CONTEXT_MENU_ENABLED_KEY } from '@/utils/Constants';
import { defineBackground, storage, browser } from '#imports';
export default defineBackground({
/**
* This is the main entry point for the background script.
@@ -14,6 +16,7 @@ export default defineBackground({
// Listen for messages using webext-bridge
onMessage('CHECK_AUTH_STATUS', () => handleCheckAuthStatus());
onMessage('STORE_VAULT', ({ data }) => handleStoreVault(data));
onMessage('UPLOAD_VAULT', ({ data }) => handleUploadVault(data));
onMessage('SYNC_VAULT', () => handleSyncVault());
onMessage('GET_VAULT', () => handleGetVault());
onMessage('CLEAR_VAULT', () => handleClearVault());
@@ -27,12 +30,16 @@ export default defineBackground({
onMessage('OPEN_POPUP_WITH_CREDENTIAL', ({ data }) => handlePopupWithCredential(data));
onMessage('TOGGLE_CONTEXT_MENU', ({ data }) => handleToggleContextMenu(data));
onMessage('PERSIST_FORM_VALUES', ({ data }) => handlePersistFormValues(data));
onMessage('GET_PERSISTED_FORM_VALUES', () => handleGetPersistedFormValues());
onMessage('CLEAR_PERSISTED_FORM_VALUES', () => handleClearPersistedFormValues());
// Setup context menus
const isContextMenuEnabled = await storage.getItem(GLOBAL_CONTEXT_MENU_ENABLED_KEY) ?? true;
if (isContextMenuEnabled) {
setupContextMenus();
}
// Listen for custom commands
try {
browser.commands.onCommand.addListener(async (command) => {

View File

@@ -1,7 +1,9 @@
import { sendMessage } from 'webext-bridge/background';
import { browser } from "#imports";
import { type Browser } from '@wxt-dev/browser';
import { PasswordGenerator } from '@/utils/shared/password-generator';
import { sendMessage } from 'webext-bridge/background';
import { PasswordGenerator } from '@/utils/dist/shared/password-generator';
import { browser } from "#imports";
/**
* Setup the context menus.

View File

@@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { browser } from '#imports';
import { setupContextMenus } from '@/entrypoints/background/ContextMenu';
import { BoolResponse } from '@/utils/types/messaging/BoolResponse';
import { setupContextMenus } from './ContextMenu';
import { browser } from '#imports';
/**
* Handle opening the popup.

View File

@@ -1,16 +1,17 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { storage } from 'wxt/utils/storage';
import type { Vault, VaultResponse, VaultPostResponse } from '@/utils/dist/shared/models/webapi';
import { EncryptionUtility } from '@/utils/EncryptionUtility';
import { SqliteClient } from '@/utils/SqliteClient';
import { WebApiService } from '@/utils/WebApiService';
import { Vault } from '@/utils/types/webapi/Vault';
import { VaultResponse } from '@/utils/types/webapi/VaultResponse';
import { VaultPostResponse } from '@/utils/types/webapi/VaultPostResponse';
import { BoolResponse as messageBoolResponse } from '@/utils/types/messaging/BoolResponse';
import { VaultResponse as messageVaultResponse } from '@/utils/types/messaging/VaultResponse';
import { CredentialsResponse as messageCredentialsResponse } from '@/utils/types/messaging/CredentialsResponse';
import { StringResponse as stringResponse } from '@/utils/types/messaging/StringResponse';
import { PasswordSettingsResponse as messagePasswordSettingsResponse } from '@/utils/types/messaging/PasswordSettingsResponse';
import { StoreVaultRequest } from '@/utils/types/messaging/StoreVaultRequest';
import { StringResponse as stringResponse } from '@/utils/types/messaging/StringResponse';
import { VaultResponse as messageVaultResponse } from '@/utils/types/messaging/VaultResponse';
import { VaultUploadResponse as messageVaultUploadResponse } from '@/utils/types/messaging/VaultUploadResponse';
import { WebApiService } from '@/utils/WebApiService';
/**
* Check if the user is logged in and if the vault is locked.
@@ -36,17 +37,32 @@ export async function handleStoreVault(
message: any,
) : Promise<messageBoolResponse> {
try {
const vaultResponse = message.vaultResponse as VaultResponse;
const encryptedVaultBlob = vaultResponse.vault.blob;
const vaultRequest = message as StoreVaultRequest;
// Store encrypted vault and derived key in session storage.
await storage.setItems([
{ key: 'session:encryptedVault', value: encryptedVaultBlob },
{ key: 'session:derivedKey', value: message.derivedKey },
{ key: 'session:publicEmailDomains', value: vaultResponse.vault.publicEmailDomainList },
{ key: 'session:privateEmailDomains', value: vaultResponse.vault.privateEmailDomainList },
{ key: 'session:vaultRevisionNumber', value: vaultResponse.vault.currentRevisionNumber }
]);
// Store new encrypted vault in session storage.
await storage.setItem('session:encryptedVault', vaultRequest.vaultBlob);
/*
* For all other values, check if they have a value and store them in session storage if they do.
* Some updates, e.g. when mutating local database, these values will not be set.
*/
// Store derived key in session storage (if it has a value)
if (vaultRequest.derivedKey) {
await storage.setItem('session:derivedKey', vaultRequest.derivedKey);
}
if (vaultRequest.publicEmailDomainList) {
await storage.setItem('session:publicEmailDomains', vaultRequest.publicEmailDomainList);
}
if (vaultRequest.privateEmailDomainList) {
await storage.setItem('session:privateEmailDomains', vaultRequest.privateEmailDomainList);
}
if (vaultRequest.vaultRevisionNumber) {
await storage.setItem('session:vaultRevisionNumber', vaultRequest.vaultRevisionNumber);
}
return { success: true };
} catch (error) {
@@ -210,48 +226,16 @@ export async function getEmailAddressesForVault(
/**
* Get default email domain for a vault.
*/
export function handleGetDefaultEmailDomain(
) : Promise<stringResponse> {
return (async () : Promise<stringResponse> => {
export function handleGetDefaultEmailDomain(): Promise<stringResponse> {
return (async (): Promise<stringResponse> => {
try {
const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[];
const publicEmailDomains = await storage.getItem('session:publicEmailDomains') as string[];
const sqliteClient = await createVaultSqliteClient();
const defaultEmailDomain = sqliteClient.getDefaultEmailDomain();
const defaultEmailDomain = sqliteClient.getDefaultEmailDomain(privateEmailDomains, publicEmailDomains);
/**
* Check if a domain is valid.
*/
const isValidDomain = (domain: string) : boolean => {
const isValid = (domain &&
domain !== 'DISABLED.TLD' &&
(privateEmailDomains.includes(domain) || publicEmailDomains.includes(domain))) as boolean;
return isValid;
};
// First check if the default domain that is configured in the vault is still valid.
if (defaultEmailDomain && isValidDomain(defaultEmailDomain)) {
return { success: true, value: defaultEmailDomain };
}
// If default domain is not valid, fall back to first available private domain.
const firstPrivate = privateEmailDomains.find(isValidDomain);
if (firstPrivate) {
return { success: true, value: firstPrivate };
}
// Return first valid public domain if no private domains are available.
const firstPublic = publicEmailDomains.find(isValidDomain);
if (firstPublic) {
return { success: true, value: firstPublic };
}
// Return null if no valid domains are found
return { success: true };
return { success: true, value: defaultEmailDomain ?? undefined };
} catch (error) {
console.error('Error getting default email domain:', error);
return { success: false, error: 'Failed to get default email domain' };
@@ -300,10 +284,82 @@ export async function handleGetDerivedKey(
return derivedKey;
}
/**
* Upload the vault to the server.
*/
export async function handleUploadVault(
message: any
) : Promise<messageVaultUploadResponse> {
try {
// Store the new vault blob in session storage.
await storage.setItem('session:encryptedVault', message.vaultBlob);
// Create new sqlite client which will use the new vault blob.
const sqliteClient = await createVaultSqliteClient();
// Upload the new vault to the server.
const response = await uploadNewVaultToServer(sqliteClient);
return { success: true, status: response.status, newRevisionNumber: response.newRevisionNumber };
} catch (error) {
console.error('Failed to upload vault:', error);
return { success: false, error: 'Failed to upload vault' };
}
}
/**
* Handle persisting form values to storage.
* Data is encrypted using the derived key for additional security.
*/
export async function handlePersistFormValues(data: any): Promise<void> {
const derivedKey = await storage.getItem('session:derivedKey') as string;
if (!derivedKey) {
throw new Error('No derived key available for encryption');
}
// Always stringify the data properly
const serializedData = JSON.stringify(data);
const encryptedData = await EncryptionUtility.symmetricEncrypt(
serializedData,
derivedKey
);
await storage.setItem('session:persistedFormValues', encryptedData);
}
/**
* Handle retrieving persisted form values from storage.
* Data is decrypted using the derived key.
*/
export async function handleGetPersistedFormValues(): Promise<any | null> {
const derivedKey = await storage.getItem('session:derivedKey') as string;
const encryptedData = await storage.getItem('session:persistedFormValues') as string | null;
if (!encryptedData || !derivedKey) {
return null;
}
try {
const decryptedData = await EncryptionUtility.symmetricDecrypt(
encryptedData,
derivedKey
);
return JSON.parse(decryptedData);
} catch (error) {
console.error('Failed to decrypt or parse persisted form values:', error);
return null;
}
}
/**
* Handle clearing persisted form values from storage.
*/
export async function handleClearPersistedFormValues(): Promise<void> {
await storage.removeItem('session:persistedFormValues');
}
/**
* Upload a new version of the vault to the server using the provided sqlite client.
*/
async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<void> {
async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<VaultPostResponse> {
const updatedVaultData = sqliteClient.exportToBase64();
const derivedKey = await storage.getItem('session:derivedKey') as string;
@@ -347,6 +403,8 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<void
} else {
throw new Error('Failed to upload new vault to server');
}
return response;
}
/**
@@ -355,7 +413,6 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<void
async function createVaultSqliteClient() : Promise<SqliteClient> {
const encryptedVault = await storage.getItem('session:encryptedVault') as string;
const derivedKey = await storage.getItem('session:derivedKey') as string;
if (!encryptedVault || !derivedKey) {
throw new Error('No vault or derived key found');
}

View File

@@ -1,9 +1,12 @@
import '@/entrypoints/contentScript/style.css';
import { FormDetector } from '@/utils/formDetector/FormDetector';
import { isAutoShowPopupEnabled, openAutofillPopup, removeExistingPopup } from '@/entrypoints/contentScript/Popup';
import { injectIcon, popupDebounceTimeHasPassed, validateInputField } from '@/entrypoints/contentScript/Form';
import { onMessage } from "webext-bridge/content-script";
import { injectIcon, popupDebounceTimeHasPassed, validateInputField } from '@/entrypoints/contentScript/Form';
import { isAutoShowPopupEnabled, openAutofillPopup, removeExistingPopup } from '@/entrypoints/contentScript/Popup';
import { FormDetector } from '@/utils/formDetector/FormDetector';
import { BoolResponse as messageBoolResponse } from '@/utils/types/messaging/BoolResponse';
import { defineContentScript } from '#imports';
import { createShadowRootUi } from '#imports';
@@ -55,6 +58,11 @@ export default defineContentScript({
return;
}
// Only show popup for autofill-triggerable fields
if (!formDetector.isAutofillTriggerableField()) {
return;
}
// Only inject icon and show popup if autofill popup is enabled
if (await isAutoShowPopupEnabled()) {
injectIcon(inputElement, container);
@@ -117,10 +125,10 @@ export default defineContentScript({
}
/**
* By default we check if the popup is not disabled (for current site)
* By default we check if the popup is not disabled (for current site) and if the field is autofill-triggerable
* but if forceShow is true, we show the popup regardless.
*/
const canShowPopup = forceShow || (await isAutoShowPopupEnabled());
const canShowPopup = forceShow || (await isAutoShowPopupEnabled() && formDetector.isAutofillTriggerableField());
if (canShowPopup) {
injectIcon(inputElement, container);

View File

@@ -1,5 +1,5 @@
import type { Credential } from '@/utils/dist/shared/models/vault';
import { CombinedStopWords } from '@/utils/formDetector/FieldPatterns';
import { Credential } from '@/utils/types/Credential';
type CredentialWithPriority = Credential & {
priority: number;

View File

@@ -1,7 +1,8 @@
import { openAutofillPopup } from '@/entrypoints/contentScript/Popup';
import type { Credential } from '@/utils/dist/shared/models/vault';
import { FormDetector } from '@/utils/formDetector/FormDetector';
import { FormFiller } from '@/utils/formDetector/FormFiller';
import { Credential } from '@/utils/types/Credential';
import { openAutofillPopup } from '@/entrypoints/contentScript/Popup';
/**
* Global timestamp to track popup debounce time.

View File

@@ -1,17 +1,19 @@
import { storage } from '#imports';
import { sendMessage } from 'webext-bridge/content-script';
import { fillCredential } from '@/entrypoints/contentScript/Form';
import { filterCredentials } from '@/entrypoints/contentScript/Filter';
import { IdentityGeneratorEn, IdentityGeneratorNl } from '@/utils/shared/identity-generator';
import { PasswordGenerator } from '@/utils/shared/password-generator';
import { fillCredential } from '@/entrypoints/contentScript/Form';
import { DISABLED_SITES_KEY, TEMPORARY_DISABLED_SITES_KEY, GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, VAULT_LOCKED_DISMISS_UNTIL_KEY, LAST_CUSTOM_EMAIL_KEY, LAST_CUSTOM_USERNAME_KEY } from '@/utils/Constants';
import { CreateIdentityGenerator } from '@/utils/dist/shared/identity-generator';
import type { Credential } from '@/utils/dist/shared/models/vault';
import { CreatePasswordGenerator, PasswordGenerator } from '@/utils/dist/shared/password-generator';
import { FormDetector } from '@/utils/formDetector/FormDetector';
import { SqliteClient } from '@/utils/SqliteClient';
import { CredentialsResponse } from '@/utils/types/messaging/CredentialsResponse';
import { PasswordSettingsResponse } from '@/utils/types/messaging/PasswordSettingsResponse';
import { SqliteClient } from '@/utils/SqliteClient';
import { BaseIdentityGenerator } from '@/utils/shared/identity-generator';
import { StringResponse } from '@/utils/types/messaging/StringResponse';
import { FormDetector } from '@/utils/formDetector/FormDetector';
import { Credential } from '@/utils/types/Credential';
import { DISABLED_SITES_KEY, TEMPORARY_DISABLED_SITES_KEY, GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, VAULT_LOCKED_DISMISS_UNTIL_KEY, LAST_CUSTOM_EMAIL_KEY, LAST_CUSTOM_USERNAME_KEY } from '@/utils/Constants';
import { storage } from '#imports';
/**
* WeakMap to store event listeners for popup containers
@@ -243,23 +245,21 @@ export function createAutofillPopup(input: HTMLInputElement, credentials: Creden
} else {
// Generate new random identity using identity generator.
const identityLanguage = await sendMessage('GET_DEFAULT_IDENTITY_LANGUAGE', {}, 'background') as StringResponse;
let identityGenerator: BaseIdentityGenerator;
switch (identityLanguage.value) {
case 'nl':
identityGenerator = new IdentityGeneratorNl();
break;
case 'en':
default:
identityGenerator = new IdentityGeneratorEn();
break;
}
const identity = await identityGenerator.generateRandomIdentity();
const identityGenerator = CreateIdentityGenerator(identityLanguage.value ?? 'en');
const identity = identityGenerator.generateRandomIdentity();
// Get password settings from background
const passwordSettingsResponse = await sendMessage('GET_PASSWORD_SETTINGS', {}, 'background') as PasswordSettingsResponse;
// Initialize password generator with the retrieved settings
const passwordGenerator = new PasswordGenerator(passwordSettingsResponse.settings);
const passwordGenerator = CreatePasswordGenerator(passwordSettingsResponse.settings ?? {
Length: 12,
UseLowercase: true,
UseUppercase: true,
UseNumbers: true,
UseSpecialChars: true,
UseNonAmbiguousChars: true
});
const password = passwordGenerator.generateRandomPassword();
// Extract favicon from page and get the bytes
@@ -946,6 +946,22 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
}
});
// Get password settings from background
let passwordGenerator: PasswordGenerator;
sendMessage('GET_PASSWORD_SETTINGS', {}, 'background').then((response) => {
const passwordSettingsResponse = response as PasswordSettingsResponse;
passwordGenerator = CreatePasswordGenerator(passwordSettingsResponse.settings ?? {
Length: 12,
UseLowercase: true,
UseUppercase: true,
UseNumbers: true,
UseSpecialChars: true,
UseNonAmbiguousChars: true
});
// Generate initial password after settings are loaded
passwordGenerator.generateRandomPassword();
});
/**
* Generate and set password.
*/
@@ -960,15 +976,6 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
updateVisibilityIcon(true);
};
// Get password settings from background
let passwordGenerator: PasswordGenerator;
sendMessage('GET_PASSWORD_SETTINGS', {}, 'background').then((response) => {
const passwordSettingsResponse = response as PasswordSettingsResponse;
passwordGenerator = new PasswordGenerator(passwordSettingsResponse.settings);
// Generate initial password after settings are loaded
generatePassword();
});
// Handle regenerate button click
regenerateBtn.addEventListener('click', generatePassword);

View File

@@ -1,20 +1,29 @@
import React, { useState, useEffect } from 'react';
import { HashRouter as Router, Routes, Route } from 'react-router-dom';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import Header from '@/entrypoints/popup/components/Layout/Header';
import BottomNav from '@/entrypoints/popup/components/Layout/BottomNav';
import AuthSettings from '@/entrypoints/popup/pages/AuthSettings';
import CredentialsList from '@/entrypoints/popup/pages/CredentialsList';
import EmailsList from '@/entrypoints/popup/pages/EmailsList';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
import Home from '@/entrypoints/popup/pages/Home';
import CredentialDetails from '@/entrypoints/popup/pages/CredentialDetails';
import EmailDetails from '@/entrypoints/popup/pages/EmailDetails';
import Settings from '@/entrypoints/popup/pages/Settings';
import GlobalStateChangeHandler from '@/entrypoints/popup/components/GlobalStateChangeHandler';
import BottomNav from '@/entrypoints/popup/components/Layout/BottomNav';
import Header from '@/entrypoints/popup/components/Layout/Header';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { NavigationProvider } from '@/entrypoints/popup/context/NavigationContext';
import AuthSettings from '@/entrypoints/popup/pages/AuthSettings';
import CredentialAddEdit from '@/entrypoints/popup/pages/CredentialAddEdit';
import CredentialDetails from '@/entrypoints/popup/pages/CredentialDetails';
import CredentialsList from '@/entrypoints/popup/pages/CredentialsList';
import EmailDetails from '@/entrypoints/popup/pages/EmailDetails';
import EmailsList from '@/entrypoints/popup/pages/EmailsList';
import Home from '@/entrypoints/popup/pages/Home';
import Login from '@/entrypoints/popup/pages/Login';
import Logout from '@/entrypoints/popup/pages/Logout';
import Settings from '@/entrypoints/popup/pages/Settings';
import Unlock from '@/entrypoints/popup/pages/Unlock';
import UnlockSuccess from '@/entrypoints/popup/pages/UnlockSuccess';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import '@/entrypoints/popup/style.css';
/**
@@ -35,13 +44,19 @@ const App: React.FC = () => {
const { isInitialLoading } = useLoading();
const [isLoading, setIsLoading] = useMinDurationLoading(true, 150);
const [message, setMessage] = useState<string | null>(null);
const { headerButtons } = useHeaderButtons();
// Add these route configurations
const routes: RouteConfig[] = [
{ path: '/', element: <Home />, showBackButton: false },
{ path: '/login', element: <Login />, showBackButton: false },
{ path: '/unlock', element: <Unlock />, showBackButton: false },
{ path: '/unlock-success', element: <UnlockSuccess onClose={() => window.location.search = ''} />, showBackButton: false },
{ path: '/auth-settings', element: <AuthSettings />, showBackButton: true, title: 'Settings' },
{ path: '/credentials', element: <CredentialsList />, showBackButton: false },
{ path: '/credentials/add', element: <CredentialAddEdit />, showBackButton: true, title: 'Add credential' },
{ path: '/credentials/:id', element: <CredentialDetails />, showBackButton: true, title: 'Credential details' },
{ path: '/credentials/:id/edit', element: <CredentialAddEdit />, showBackButton: true, title: 'Edit credential' },
{ path: '/emails', element: <EmailsList />, showBackButton: false },
{ path: '/emails/:id', element: <EmailDetails />, showBackButton: true, title: 'Email details' },
{ path: '/settings', element: <Settings />, showBackButton: false },
@@ -67,44 +82,45 @@ const App: React.FC = () => {
return (
<Router>
<div className="min-h-screen min-w-[350px] bg-white dark:bg-gray-900 flex flex-col">
{isLoading && (
<div className="fixed inset-0 bg-white dark:bg-gray-900 z-50 flex items-center justify-center">
<LoadingSpinner />
</div>
)}
<NavigationProvider>
<div className="min-h-screen min-w-[350px] bg-white dark:bg-gray-900 flex flex-col max-h-[600px]">
{isLoading && (
<div className="fixed inset-0 bg-white dark:bg-gray-900 z-50 flex items-center justify-center">
<LoadingSpinner />
</div>
)}
<GlobalStateChangeHandler />
<Header
routes={routes}
/>
<GlobalStateChangeHandler />
<Header
routes={routes}
rightButtons={headerButtons}
/>
<main
className="flex-1 overflow-y-auto bg-gray-100 dark:bg-gray-900"
style={{
paddingTop: '64px',
height: 'calc(100% - 120px)',
maxHeight: '600px',
}}
>
<div className="p-4 mb-16">
{message && (
<p className="text-red-500 mb-4">{message}</p>
)}
<Routes>
{routes.map((route) => (
<Route
key={route.path}
path={route.path}
element={route.element}
/>
))}
</Routes>
</div>
</main>
<BottomNav />
</div>
<main
className="flex-1 overflow-y-auto bg-gray-100 dark:bg-gray-900"
style={{
paddingTop: '64px',
height: 'calc(100% - 120px)',
}}
>
<div className="p-4 mb-16">
{message && (
<p className="text-red-500 mb-4">{message}</p>
)}
<Routes>
{routes.map((route) => (
<Route
key={route.path}
path={route.path}
element={route.element}
/>
))}
</Routes>
</div>
</main>
<BottomNav />
</div>
</NavigationProvider>
</Router>
);
};

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Credential } from '@/utils/types/Credential';
import type { Credential } from '@/utils/dist/shared/models/vault';
import SqliteClient from '@/utils/SqliteClient';
type CredentialCardProps = {
@@ -23,18 +24,34 @@ const CredentialCard: React.FC<CredentialCardProps> = ({ credential }) => {
* @returns The display text for the credential
*/
const getDisplayText = (cred: Credential): string => {
let returnValue = '';
// Show username if available
if (cred.Username) {
return cred.Username;
returnValue = cred.Username;
}
// Show email if username is not available
if (cred.Alias?.Email) {
return cred.Alias.Email;
returnValue = cred.Alias.Email;
}
// Show empty string if neither username nor email is available
return '';
// Trim the return value to max. 33 characters.
return returnValue.length > 33 ? returnValue.slice(0, 30) + '...' : returnValue;
};
/**
* Get the service name for a credential, trimming it to maximum length so it doesn't overflow the UI.
*/
const getCredentialServiceName = (cred: Credential): string => {
let returnValue = 'Untitled';
if (cred.ServiceName) {
returnValue = cred.ServiceName;
}
// Trim the return value to max. 33 characters.
return returnValue.length > 33 ? returnValue.slice(0, 30) + '...' : returnValue;
};
return (
@@ -53,7 +70,7 @@ const CredentialCard: React.FC<CredentialCardProps> = ({ credential }) => {
}}
/>
<div className="text-left">
<p className="font-medium text-gray-900 dark:text-white">{credential.ServiceName}</p>
<p className="font-medium text-gray-900 dark:text-white">{getCredentialServiceName(credential)}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">{getDisplayText(credential)}</p>
</div>
</button>

View File

@@ -1,7 +1,9 @@
import React from 'react';
import { Credential } from '@/utils/types/Credential';
import { FormInputCopyToClipboard } from '@/entrypoints/popup/components/FormInputCopyToClipboard';
import { IdentityHelperUtils } from '@/utils/shared/identity-generator';
import { IdentityHelperUtils } from '@/utils/dist/shared/identity-generator';
import type { Credential } from '@/utils/dist/shared/models/vault';
type AliasBlockProps = {
credential: Credential;

View File

@@ -1,18 +1,45 @@
import React from 'react';
import { EmailPreview } from '@/entrypoints/popup/components/EmailPreview';
import { useDb } from '@/entrypoints/popup/context/DbContext';
type EmailBlockProps = {
email: string;
isSupported: boolean;
}
/**
* Render the email block.
*/
const EmailBlock: React.FC<EmailBlockProps> = ({ email, isSupported }) => (
<>
{isSupported && <EmailPreview email={email} />}
</>
);
const EmailBlock: React.FC<EmailBlockProps> = ({ email }) => {
const dbContext = useDb();
/**
* Check if the email domain is supported.
*/
const isEmailDomainSupported = async (email: string): Promise<boolean> => {
const domain = email.split('@')[1]?.toLowerCase();
if (!domain) {
return false;
}
const vaultMetadata = await dbContext.getVaultMetadata();
const publicDomains = vaultMetadata?.publicEmailDomains ?? [];
const privateDomains = vaultMetadata?.privateEmailDomains ?? [];
return [...publicDomains, ...privateDomains].some(supportedDomain =>
domain === supportedDomain || domain.endsWith(`.${supportedDomain}`)
);
};
if (!isEmailDomainSupported(email)) {
return null;
}
return (
<>
{<EmailPreview email={email} />}
</>
);
};
export default EmailBlock;

View File

@@ -1,58 +1,36 @@
import React from 'react';
import { Credential } from '@/utils/types/Credential';
import type { Credential } from '@/utils/dist/shared/models/vault';
import SqliteClient from '@/utils/SqliteClient';
type HeaderBlockProps = {
credential: Credential;
onOpenNewPopup: () => void;
}
/**
* Render the header block.
*/
const HeaderBlock: React.FC<HeaderBlockProps> = ({ credential, onOpenNewPopup }) => (
<div className="mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center">
<img
src={SqliteClient.imgSrcFromBytes(credential.Logo)}
alt={credential.ServiceName}
className="w-12 h-12 rounded-lg mr-4"
/>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{credential.ServiceName}</h1>
{credential.ServiceUrl && (
<a
href={credential.ServiceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 break-all"
>
{credential.ServiceUrl}
</a>
)}
</div>
const HeaderBlock: React.FC<HeaderBlockProps> = ({ credential }) => (
<div className="flex items-center justify-between">
<div className="flex items-center">
<img
src={SqliteClient.imgSrcFromBytes(credential.Logo)}
alt={credential.ServiceName}
className="w-12 h-12 rounded-lg mr-4"
/>
<div>
<h1 className="text-lg font-bold text-gray-900 dark:text-white">{credential.ServiceName}</h1>
{credential.ServiceUrl && (
<a
href={credential.ServiceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 break-all"
>
{credential.ServiceUrl}
</a>
)}
</div>
<button
onClick={onOpenNewPopup}
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
title="Open in new window"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
</button>
</div>
</div>
);

View File

@@ -1,7 +1,9 @@
import React from 'react';
import { Credential } from '@/utils/types/Credential';
import { FormInputCopyToClipboard } from '@/entrypoints/popup/components/FormInputCopyToClipboard';
import type { Credential } from '@/utils/dist/shared/models/vault';
type LoginCredentialsBlockProps = {
credential: Credential;
}

View File

@@ -1,7 +1,9 @@
import React, { useState, useEffect } from 'react';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { TotpCode } from '@/utils/types/TotpCode';
import * as OTPAuth from 'otpauth';
import React, { useState, useEffect } from 'react';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import type { TotpCode } from '@/utils/dist/shared/models/vault';
type TotpBlockProps = {
credentialId: string;

View File

@@ -1,9 +1,9 @@
import HeaderBlock from './HeaderBlock';
import EmailBlock from './EmailBlock';
import TotpBlock from './TotpBlock';
import LoginCredentialsBlock from './LoginCredentialsBlock';
import AliasBlock from './AliasBlock';
import EmailBlock from './EmailBlock';
import HeaderBlock from './HeaderBlock';
import LoginCredentialsBlock from './LoginCredentialsBlock';
import NotesBlock from './NotesBlock';
import TotpBlock from './TotpBlock';
export {
HeaderBlock,

View File

@@ -1,11 +1,14 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { storage } from '#imports';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { EncryptionUtility } from '@/utils/EncryptionUtility';
import { MailboxEmail } from '@/utils/types/webapi/MailboxEmail';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import { AppInfo } from '@/utils/AppInfo';
import type { ApiErrorResponse, MailboxEmail } from '@/utils/dist/shared/models/webapi';
import { EncryptionUtility } from '@/utils/EncryptionUtility';
import { storage } from '#imports';
type EmailPreviewProps = {
email: string;
@@ -19,6 +22,8 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
const [loading, setLoading] = useState(true);
const [lastEmailId, setLastEmailId] = useState<number>(0);
const [isSpamOk, setIsSpamOk] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSupportedDomain, setIsSupportedDomain] = useState(false);
const webApi = useWebApi();
const dbContext = useDb();
@@ -31,14 +36,32 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
return publicEmailDomains.some(domain => emailAddress.toLowerCase().endsWith(domain));
};
/**
* Checks if the email is a private domain.
*/
const isPrivateDomain = async (emailAddress: string): Promise<boolean> => {
// Get metadata from storage
const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[] ?? [];
return privateEmailDomains.some(domain => emailAddress.toLowerCase().endsWith(domain));
};
useEffect(() => {
/**
* Loads the latest emails from the server and decrypts them locally if needed.
*/
const loadEmails = async (): Promise<void> => {
try {
setError(null);
const isPublic = await isPublicDomain(email);
const isPrivate = await isPrivateDomain(email);
const isSupported = isPublic || isPrivate;
setIsSpamOk(isPublic);
setIsSupportedDomain(isSupported);
if (!isSupported) {
return;
}
if (isPublic) {
// For public domains (SpamOK), use the SpamOK API directly
@@ -49,6 +72,12 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
'X-Asdasd-Platform-Version': AppInfo.VERSION,
}
});
if (!response.ok) {
setError('An error occurred while loading emails. Please try again later.');
return;
}
const data = await response.json();
// Only show the latest 2 emails to save space in UI
@@ -62,32 +91,57 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
}
setEmails(latestMails);
} else {
} else if (isPrivate) {
// For private domains, use existing encrypted email logic
const response = await webApi.get(`EmailBox/${email}`);
const data = response as { mails: MailboxEmail[] };
try {
/**
* We use authFetch here because we don't want to the inner method to throw an error if HTTP status is not 200.
* Instead we want to catch the error ourselves.
*/
const response = await webApi.authFetch(`EmailBox/${email}`, { method: 'GET' }, true, false);
try {
const data = response as { mails: MailboxEmail[] };
// Only show the latest 2 emails to save space in UI
const latestMails = data.mails
.toSorted((a, b) => new Date(b.dateSystem).getTime() - new Date(a.dateSystem).getTime())
.slice(0, 2);
// Only show the latest 2 emails to save space in UI
const latestMails = data.mails
.toSorted((a, b) => new Date(b.dateSystem).getTime() - new Date(a.dateSystem).getTime())
.slice(0, 2);
if (latestMails) {
// Loop through all emails and decrypt them locally
const decryptedEmails: MailboxEmail[] = await EncryptionUtility.decryptEmailList(
latestMails,
dbContext.sqliteClient!.getAllEncryptionKeys()
);
if (latestMails) {
// Loop through all emails and decrypt them locally
const decryptedEmails: MailboxEmail[] = await EncryptionUtility.decryptEmailList(
latestMails,
dbContext.sqliteClient!.getAllEncryptionKeys()
);
if (loading && decryptedEmails.length > 0) {
setLastEmailId(decryptedEmails[0].id);
if (loading && decryptedEmails.length > 0) {
setLastEmailId(decryptedEmails[0].id);
}
setEmails(decryptedEmails);
}
} catch {
// Try to parse as error response instead
const apiErrorResponse = response as ApiErrorResponse;
if (apiErrorResponse?.code === 'CLAIM_DOES_NOT_MATCH_USER') {
setError('The current chosen email address is already in use. Please change the email address by editing this credential.');
} else if (apiErrorResponse?.code === 'CLAIM_DOES_NOT_EXIST') {
setError('An error occurred while trying to load the emails. Please try to edit and save the credential entry to synchronize the database, then try again.');
} else {
setError('An error occurred while loading emails. Please try again later.');
}
return;
}
setEmails(decryptedEmails);
} catch {
setError('An error occurred while loading emails. Please try again later.');
return;
}
}
} catch (err) {
console.error('Error loading emails:', err);
setError('An unexpected error occurred while loading emails. Please try again later.');
}
setLoading(false);
};
@@ -98,6 +152,24 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
return () : void => clearInterval(interval);
}, [email, loading, webApi, dbContext]);
// Don't render anything if the domain is not supported
if (!isSupportedDomain) {
return null;
}
if (error) {
return (
<div className="text-gray-500 dark:text-gray-400 mb-4">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Recent emails</h2>
</div>
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
</div>
);
}
if (loading) {
return (
<div className="text-gray-500 dark:text-gray-400 mb-4">

View File

@@ -0,0 +1,171 @@
import React, { forwardRef } from 'react';
/**
* Button configuration for form input.
*/
type FormInputButton = {
icon: string;
onClick: () => void;
title?: string;
}
/**
* Icon component for form input buttons.
*/
const Icon: React.FC<{ name: string }> = ({ name }) => {
switch (name) {
case 'visibility':
return (
<>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</>
);
case 'visibility-off':
return (
<>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</>
);
case 'refresh':
return (
<>
<path d="M23 4v6h-6" />
<path d="M1 20v-6h6" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</>
);
default:
return null;
}
};
/**
* Form input props.
*/
type FormInputProps = {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
type?: 'text' | 'password';
placeholder?: string;
required?: boolean;
multiline?: boolean;
rows?: number;
error?: string;
buttons?: FormInputButton[];
showPassword?: boolean;
onShowPasswordChange?: (show: boolean) => void;
}
/**
* Form input component.
*/
export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(({
id,
label,
value,
onChange,
type = 'text',
placeholder,
required = false,
multiline = false,
rows = 1,
error,
buttons = [],
showPassword: controlledShowPassword,
onShowPasswordChange
}, ref) => {
const [internalShowPassword, setInternalShowPassword] = React.useState(false);
/**
* Use controlled or uncontrolled showPassword state.
* If controlledShowPassword is provided, use that value and call onShowPasswordChange.
* Otherwise, use internal state.
*/
const showPassword = controlledShowPassword !== undefined ? controlledShowPassword : internalShowPassword;
/**
* Set the showPassword state.
* If controlledShowPassword is provided, use that value and call onShowPasswordChange.
* Otherwise, use internal state.
*/
const setShowPassword = (value: boolean): void => {
if (controlledShowPassword !== undefined) {
onShowPasswordChange?.(value);
} else {
setInternalShowPassword(value);
}
};
const inputClasses = `mt-1 block w-full rounded-md ${
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-700'
} text-gray-900 sm:text-sm rounded-lg shadow-sm border focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400 py-2 px-3`;
// Add password visibility button if type is password
const allButtons = type === 'password'
? [...buttons, {
icon: showPassword ? 'visibility-off' : 'visibility',
/**
* Toggle password visibility.
*/
onClick: (): void => setShowPassword(!showPassword),
title: showPassword ? 'Hide password' : 'Show password'
}]
: buttons;
return (
<div>
<label htmlFor={id} className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="relative">
{multiline ? (
<textarea
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
rows={rows}
placeholder={placeholder}
className={inputClasses}
/>
) : (
<input
ref={ref}
type={type === 'password' && !showPassword ? 'password' : 'text'}
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={inputClasses}
/>
)}
{allButtons.length > 0 && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-2">
{allButtons.map((button, index) => (
<button
type="button"
key={index}
onClick={button.onClick}
title={button.title}
className="p-1 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<Icon name={button.icon} />
</svg>
</button>
))}
</div>
)}
</div>
{error && (
<p className="mt-1 text-sm text-red-500">{error}</p>
)}
</div>
);
});
FormInput.displayName = 'FormInput';

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { ClipboardCopyService } from '@/entrypoints/popup/utils/ClipboardCopyService';
/**
@@ -13,6 +14,43 @@ type FormInputCopyToClipboardProps = {
const clipboardService = new ClipboardCopyService();
/**
* Icon component for form input buttons.
*/
const Icon: React.FC<{ name: string }> = ({ name }) => {
switch (name) {
case 'visibility':
return (
<>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</>
);
case 'visibility-off':
return (
<>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</>
);
case 'copy':
return (
<>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</>
);
case 'check':
return (
<>
<polyline points="20 6 9 17 4 12" />
</>
);
default:
return null;
}
};
/**
* Form input copy to clipboard component.
*/
@@ -70,17 +108,38 @@ export const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> =
} text-gray-900 sm:text-sm rounded-lg shadow-sm focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400`}
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-2">
{copied && (
<span className="text-green-500 dark:text-green-400">
Copied!
</span>
{copied ? (
<button
type="button"
className="p-1 text-green-500 dark:text-green-400 transition-colors duration-200"
title="Copied!"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<Icon name="check" />
</svg>
</button>
) : (
<button
type="button"
onClick={copyToClipboard}
className="p-1 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
title="Copy to clipboard"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<Icon name="copy" />
</svg>
</button>
)}
{type === 'password' && (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
className="p-1 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
title={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? 'Hide' : 'Show'}
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<Icon name={showPassword ? 'visibility-off' : 'visibility'} />
</svg>
</button>
)}
</div>

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
/**

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { HeaderIcon, HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
type HeaderButtonProps = {
onClick: () => void;
title: string;
iconType: HeaderIconType;
variant?: 'default' | 'primary' | 'danger';
};
/**
* Header button component for consistent header button styling
*/
const HeaderButton: React.FC<HeaderButtonProps> = ({
onClick,
title,
iconType,
variant = 'default'
}) => {
const colorClasses = {
default: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700',
primary: 'text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/20',
danger: 'text-red-500 hover:text-red-600 hover:bg-red-100 dark:hover:bg-red-900/20'
};
return (
<button
onClick={onClick}
className={`p-2 rounded-lg ${colorClasses[variant]}`}
title={title}
>
<HeaderIcon type={iconType} />
</button>
);
};
export default HeaderButton;

View File

@@ -0,0 +1,163 @@
import React from 'react';
export enum HeaderIconType {
EXPAND = 'expand',
EDIT = 'edit',
DELETE = 'delete',
SETTINGS = 'settings',
RELOAD = 'reload',
EXTERNAL_LINK = 'external_link',
SAVE = 'save',
PLUS = 'plus'
}
type HeaderIconProps = {
type: HeaderIconType;
className?: string;
};
/**
* Component to render header icons
*/
export const HeaderIcon: React.FC<HeaderIconProps> = ({ type, className = 'w-5 h-5' }) => {
const icons = {
[HeaderIconType.EXPAND]: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
),
[HeaderIconType.EDIT]: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
),
[HeaderIconType.DELETE]: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
),
[HeaderIconType.SETTINGS]: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
),
[HeaderIconType.RELOAD]: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
),
[HeaderIconType.EXTERNAL_LINK]: (
<svg
className={className}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
),
[HeaderIconType.SAVE]: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V7l-4-4z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 3v5h10"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 12a2 2 0 100 4 2 2 0 000-4z"
/>
</svg>
),
[HeaderIconType.PLUS]: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
)
};
return icons[type] || null;
};

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useDb } from '@/entrypoints/popup/context/DbContext';
@@ -17,9 +18,13 @@ const BottomNav: React.FC = () => {
// Add effect to update currentTab based on route
useEffect(() => {
const path = location.pathname.substring(1) as TabName;
if (['credentials', 'emails', 'settings'].includes(path)) {
setCurrentTab(path);
const path = location.pathname.substring(1); // Remove leading slash
const tabNames: TabName[] = ['credentials', 'emails', 'settings'];
// Find the first tab name that matches the start of the path
const matchingTab = tabNames.find(tab => path === tab || path.startsWith(`${tab}/`));
if (matchingTab) {
setCurrentTab(matchingTab);
}
}, [location]);

View File

@@ -1,9 +1,7 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { storage } from '#imports';
import { UserMenu } from '@/entrypoints/popup/components/Layout/UserMenu';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { AppInfo } from '@/utils/AppInfo';
/**
* Header props.
@@ -14,31 +12,20 @@ type HeaderProps = {
showBackButton?: boolean;
title?: string;
}[];
rightButtons?: React.ReactNode;
}
/**
* Header component.
*/
const Header: React.FC<HeaderProps> = ({
routes = []
routes = [],
rightButtons
}) => {
const authContext = useAuth();
const navigate = useNavigate();
const location = useLocation();
/**
* Open the client tab.
*/
const openClientTab = async () : Promise<void> => {
const settingClientUrl = await storage.getItem('local:clientUrl') as string;
let clientUrl = AppInfo.DEFAULT_CLIENT_URL;
if (settingClientUrl && settingClientUrl.length > 0) {
clientUrl = settingClientUrl;
}
window.open(clientUrl, '_blank');
};
// Updated route matching logic to handle URL parameters
const currentRoute = routes?.find(route => {
// Convert route pattern to regex
@@ -105,33 +92,22 @@ const Header: React.FC<HeaderProps> = ({
<div className="flex-grow" />
<div className="flex items-center">
{!currentRoute?.showBackButton ? (
<div className="flex items-center gap-2">
{!authContext.isLoggedIn ? (
<button
onClick={openClientTab}
className="p-2"
id="settings"
onClick={(handleSettings)}
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
<span className="sr-only">Settings</span>
<svg className="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
</svg>
</button>
) : (<></>)}
) : (
rightButtons
)}
</div>
{!authContext.isLoggedIn ? (
<button
id="settings"
onClick={(handleSettings)}
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span className="sr-only">Settings</span>
<svg className="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
</svg>
</button>
) : (
<UserMenu />
)}
</div>
</header>
);

View File

@@ -1,88 +1,49 @@
import React, { useState, useRef, useEffect } from 'react';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
/**
* User menu component.
*/
export const UserMenu: React.FC = () => {
const UserMenu: React.FC = () => {
const authContext = useAuth();
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const navigate = useNavigate();
const { showLoading, hideLoading } = useLoading();
useEffect(() => {
/**
* Handle clicking outside the user menu.
*/
const handleClickOutside = (event: MouseEvent) : void => {
if (
menuRef.current &&
buttonRef.current &&
!menuRef.current.contains(event.target as Node) &&
!buttonRef.current.contains(event.target as Node)
) {
setIsUserMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () : void => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
/**
* Toggle the user menu.
* Handle logout.
*/
const toggleUserMenu = () : void => {
setIsUserMenuOpen(!isUserMenuOpen);
};
/**
* Handle logging out.
*/
const onLogout = async () : Promise<void> => {
showLoading();
navigate('/logout', { replace: true });
hideLoading();
const handleLogout = async () : Promise<void> => {
await authContext.logout();
navigate('/');
};
return (
<div className="relative flex items-center">
<div className="relative">
<button
ref={buttonRef}
onClick={toggleUserMenu}
className="flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<span className="sr-only">Open menu</span>
<svg className="w-6 h-6" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
</button>
{isUserMenuOpen && (
<div
ref={menuRef}
className="absolute right-0 z-50 mt-2 w-48 py-1 bg-white rounded-lg shadow-lg dark:bg-gray-700 border border-gray-200 dark:border-gray-600"
>
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-600">
<span className="block text-sm font-semibold text-gray-900 dark:text-white">
{authContext.username}
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
<span className="text-primary-600 dark:text-primary-400 text-lg font-medium">
{authContext.username?.[0]?.toUpperCase() || '?'}
</span>
</div>
<button
onClick={onLogout}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:text-red-400 dark:hover:bg-gray-600"
>
Logout
</button>
</div>
)}
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{authContext.username}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Logged in
</p>
</div>
</div>
<button
onClick={handleLogout}
className="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
Logout
</button>
</div>
</div>
);

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
/**

View File

@@ -1,8 +1,10 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { storage } from '#imports';
import { AppInfo } from '@/utils/AppInfo';
import { storage } from '#imports';
/**
* Component for displaying the login server information.
*/

View File

@@ -0,0 +1,99 @@
import React from 'react';
interface IModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'default';
}
/**
* A reusable modal component for confirmations and alerts.
*/
const Modal: React.FC<IModalProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'default'
}) => {
if (!isOpen) {
return null;
}
const confirmButtonClass = variant === 'danger'
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500';
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div className="fixed inset-0 bg-black bg-opacity-60 transition-opacity" onClick={onClose} />
{/* Modal */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all w-full max-w-lg">
{/* Close button */}
<button
type="button"
className="absolute right-4 top-4 text-gray-400 hover:text-gray-500 focus:outline-none"
onClick={onClose}
>
<span className="sr-only">Close</span>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Content */}
<div className="sm:flex sm:items-start">
{variant === 'danger' && (
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900 sm:mx-0 sm:h-10 sm:w-10">
<svg className="h-6 w-6 text-red-600 dark:text-red-200" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
)}
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<h3 className="text-lg font-semibold leading-6 text-gray-900 dark:text-white">
{title}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500 dark:text-gray-400">
{message}
</p>
</div>
</div>
</div>
{/* Actions */}
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button
type="button"
className={`inline-flex w-full justify-center rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto ${confirmButtonClass}`}
onClick={onConfirm}
>
{confirmText}
</button>
<button
type="button"
className="mt-3 inline-flex w-full justify-center rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:mt-0 sm:w-auto"
onClick={onClose}
>
{cancelText}
</button>
</div>
</div>
</div>
</div>
);
};
export default Modal;

View File

@@ -1,13 +1,17 @@
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
import { storage } from '#imports';
import { sendMessage } from 'webext-bridge/popup';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { VAULT_LOCKED_DISMISS_UNTIL_KEY } from '@/utils/Constants';
import { storage } from '#imports';
type AuthContextType = {
isLoggedIn: boolean;
isInitialized: boolean;
username: string | null;
initializeAuth: () => Promise<{ isLoggedIn: boolean }>;
setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise<void>;
login: () => Promise<void>;
logout: (errorMessage?: string) => Promise<void>;
@@ -31,25 +35,29 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const dbContext = useDb();
/**
* Check for tokens in browser local storage on initial load.
* Initialize the authentication state.
*
* @returns object containing whether the user is logged in.
*/
const initializeAuth = useCallback(async () : Promise<{ isLoggedIn: boolean }> => {
const accessToken = await storage.getItem('local:accessToken') as string;
const refreshToken = await storage.getItem('local:refreshToken') as string;
const username = await storage.getItem('local:username') as string;
if (accessToken && refreshToken && username) {
setUsername(username);
setIsLoggedIn(true);
}
setIsInitialized(true);
return { isLoggedIn };
}, [setUsername, setIsLoggedIn, isLoggedIn]);
/**
* Check for tokens in browser local storage on initial load when this context is mounted.
*/
useEffect(() => {
/**
* Initialize the authentication state.
*/
const initializeAuth = async () : Promise<void> => {
const accessToken = await storage.getItem('local:accessToken') as string;
const refreshToken = await storage.getItem('local:refreshToken') as string;
const username = await storage.getItem('local:username') as string;
if (accessToken && refreshToken && username) {
setUsername(username);
setIsLoggedIn(true);
}
setIsInitialized(true);
};
initializeAuth();
}, []);
}, [initializeAuth]);
/**
* Set auth tokens in browser local storage as part of the login process. After db is initialized, the login method should be called as well.
@@ -100,12 +108,13 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
isLoggedIn,
isInitialized,
username,
initializeAuth,
setAuthTokens,
login,
logout,
globalMessage,
clearGlobalMessage,
}), [isLoggedIn, isInitialized, username, globalMessage, setAuthTokens, login, logout, clearGlobalMessage]);
}), [isLoggedIn, isInitialized, username, initializeAuth, globalMessage, setAuthTokens, login, logout, clearGlobalMessage]);
return (
<AuthContext.Provider value={contextValue}>

View File

@@ -1,9 +1,12 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import { sendMessage } from 'webext-bridge/popup';
import SqliteClient from '@/utils/SqliteClient';
import { VaultResponse } from '@/utils/types/webapi/VaultResponse';
import type { VaultMetadata } from '@/utils/dist/shared/models/metadata';
import type { VaultResponse } from '@/utils/dist/shared/models/webapi';
import EncryptionUtility from '@/utils/EncryptionUtility';
import { VaultResponse as messageVaultResponse } from '@/utils/types/messaging/VaultResponse';
import SqliteClient from '@/utils/SqliteClient';
import { StoreVaultRequest } from '@/utils/types/messaging/StoreVaultRequest';
import type { VaultResponse as messageVaultResponse } from '@/utils/types/messaging/VaultResponse';
type DbContextType = {
sqliteClient: SqliteClient | null;
@@ -11,9 +14,8 @@ type DbContextType = {
dbAvailable: boolean;
initializeDatabase: (vaultResponse: VaultResponse, derivedKey: string) => Promise<void>;
clearDatabase: () => void;
vaultRevision: number;
publicEmailDomains: string[];
privateEmailDomains: string[];
getVaultMetadata: () => Promise<VaultMetadata | null>;
setCurrentVaultRevisionNumber: (revisionNumber: number) => Promise<void>;
}
const DbContext = createContext<DbContextType | undefined>(undefined);
@@ -37,20 +39,10 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
*/
const [dbAvailable, setDbAvailable] = useState(false);
/**
* Public email domains.
*/
const [publicEmailDomains, setPublicEmailDomains] = useState<string[]>([]);
/**
* Vault revision.
*/
const [vaultRevision, setVaultRevision] = useState(0);
/**
* Private email domains.
*/
const [privateEmailDomains, setPrivateEmailDomains] = useState<string[]>([]);
const [vaultMetadata, setVaultMetadata] = useState<VaultMetadata | null>(null);
const initializeDatabase = useCallback(async (vaultResponse: VaultResponse, derivedKey: string) => {
// Attempt to decrypt the blob.
@@ -66,17 +58,24 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
setSqliteClient(client);
setDbInitialized(true);
setDbAvailable(true);
setPublicEmailDomains(vaultResponse.vault.publicEmailDomainList);
setPrivateEmailDomains(vaultResponse.vault.privateEmailDomainList);
setVaultRevision(vaultResponse.vault.currentRevisionNumber);
setVaultMetadata({
publicEmailDomains: vaultResponse.vault.publicEmailDomainList,
privateEmailDomains: vaultResponse.vault.privateEmailDomainList,
vaultRevisionNumber: vaultResponse.vault.currentRevisionNumber,
});
/*
/**
* Store encrypted vault in background worker.
*/
sendMessage('STORE_VAULT', {
const request: StoreVaultRequest = {
vaultBlob: vaultResponse.vault.blob,
derivedKey: derivedKey,
vaultResponse: vaultResponse,
}, 'background');
publicEmailDomainList: vaultResponse.vault.publicEmailDomainList,
privateEmailDomainList: vaultResponse.vault.privateEmailDomainList,
vaultRevisionNumber: vaultResponse.vault.currentRevisionNumber,
};
await sendMessage('STORE_VAULT', request, 'background');
}, []);
const checkStoredVault = useCallback(async () => {
@@ -89,9 +88,11 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
setSqliteClient(client);
setDbInitialized(true);
setDbAvailable(true);
setPublicEmailDomains(response.publicEmailDomains ?? []);
setPrivateEmailDomains(response.privateEmailDomains ?? []);
setVaultRevision(response.vaultRevisionNumber ?? 0);
setVaultMetadata({
publicEmailDomains: response.publicEmailDomains ?? [],
privateEmailDomains: response.privateEmailDomains ?? [],
vaultRevisionNumber: response.vaultRevisionNumber ?? 0,
});
} else {
setDbInitialized(true);
setDbAvailable(false);
@@ -103,6 +104,24 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
}
}, []);
/**
* Get the vault metadata.
*/
const getVaultMetadata = useCallback(async () : Promise<VaultMetadata | null> => {
return vaultMetadata;
}, [vaultMetadata]);
/**
* Set the current vault revision number.
*/
const setCurrentVaultRevisionNumber = useCallback(async (revisionNumber: number) => {
setVaultMetadata({
publicEmailDomains: vaultMetadata?.publicEmailDomains ?? [],
privateEmailDomains: vaultMetadata?.privateEmailDomains ?? [],
vaultRevisionNumber: revisionNumber,
});
}, [vaultMetadata]);
/**
* Check if database is initialized and try to retrieve vault from background
*/
@@ -127,10 +146,9 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
dbAvailable,
initializeDatabase,
clearDatabase,
vaultRevision,
publicEmailDomains,
privateEmailDomains
}), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, clearDatabase, vaultRevision, publicEmailDomains, privateEmailDomains]);
getVaultMetadata,
setCurrentVaultRevisionNumber,
}), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, clearDatabase, getVaultMetadata, setCurrentVaultRevisionNumber]);
return (
<DbContext.Provider value={contextValue}>

View File

@@ -0,0 +1,48 @@
import React, { createContext, useContext, useState, useCallback, useMemo } from "react";
type HeaderButtonsContextType = {
setHeaderButtons: (buttons: React.ReactNode) => void;
headerButtons: React.ReactNode;
}
/**
* Context for managing header buttons in the popup
*/
export const HeaderButtonsContext = createContext<HeaderButtonsContextType | undefined>(undefined);
/**
* Provider component for HeaderButtonsContext
*/
export const HeaderButtonsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [headerButtons, setHeaderButtons] = useState<React.ReactNode>(null);
const handleSetHeaderButtons = useCallback((buttons: React.ReactNode) => {
setHeaderButtons(buttons);
}, []);
const value = useMemo(() => ({
setHeaderButtons: handleSetHeaderButtons,
headerButtons
}), [handleSetHeaderButtons, headerButtons]);
return (
<HeaderButtonsContext.Provider value={value}>
{children}
</HeaderButtonsContext.Provider>
);
};
/**
* Hook to use the HeaderButtonsContext
* @returns The HeaderButtonsContext value
*/
export const useHeaderButtons = (): {
setHeaderButtons: (buttons: React.ReactNode) => void;
headerButtons: React.ReactNode;
} => {
const context = useContext(HeaderButtonsContext);
if (context === undefined) {
throw new Error("useHeaderButtons must be used within a HeaderButtonsProvider");
}
return context;
};

View File

@@ -1,9 +1,11 @@
import React, { createContext, useContext, useState, useMemo } from 'react';
import LoadingSpinnerFullScreen from '@/entrypoints/popup/components/LoadingSpinnerFullScreen';
type LoadingContextType = {
isLoading: boolean;
showLoading: () => void;
loadingMessage?: string;
showLoading: (message?: string) => void;
hideLoading: () => void;
isInitialLoading: boolean;
setIsInitialLoading: (isInitialLoading: boolean) => void;
@@ -29,31 +31,39 @@ export const LoadingProvider: React.FC<{ children: React.ReactNode }> = ({ child
* Loading state that can be used by other components during normal operation.
*/
const [isLoading, setIsLoading] = useState(false);
const [loadingMessage, setLoadingMessage] = useState<string | undefined>(undefined);
/**
* Show loading spinner
* Show loading spinner with optional message
*/
const showLoading = (): void => setIsLoading(true);
const showLoading = (message?: string): void => {
setIsLoading(true);
setLoadingMessage(message);
};
/**
* Hide loading spinner
* Hide loading spinner and clear message
*/
const hideLoading = (): void => setIsLoading(false);
const hideLoading = (): void => {
setIsLoading(false);
setLoadingMessage(undefined);
};
const value = useMemo(
() => ({
isLoading,
loadingMessage,
showLoading,
hideLoading,
isInitialLoading,
setIsInitialLoading,
}),
[isLoading, isInitialLoading]
[isLoading, loadingMessage, isInitialLoading]
);
return (
<LoadingContext.Provider value={value}>
<LoadingSpinnerFullScreen />
<LoadingSpinnerFullScreen message={loadingMessage} />
{children}
</LoadingContext.Provider>
);

View File

@@ -0,0 +1,195 @@
import React, { createContext, useContext, useEffect, useState, useMemo, useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { sendMessage } from 'webext-bridge/popup';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { storage } from '#imports';
const LAST_VISITED_PAGE_KEY = 'session:lastVisitedPage';
const LAST_VISITED_TIME_KEY = 'session:lastVisitedTime';
const NAVIGATION_HISTORY_KEY = 'session:navigationHistory';
const PAGE_MEMORY_DURATION = 120 * 1000; // 2 minutes in milliseconds
type NavigationHistoryEntry = {
pathname: string;
search: string;
hash: string;
};
type NavigationContextType = {
storeCurrentPage: () => Promise<void>;
restoreLastPage: () => Promise<void>;
isFullyInitialized: boolean;
requiresAuth: boolean;
};
const NavigationContext = createContext<NavigationContextType | undefined>(undefined);
/**
* Navigation provider component that handles storing and restoring the last visited page,
* as well as managing initialization and auth state redirects.
*/
export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const location = useLocation();
const navigate = useNavigate();
const [isInitialized, setIsInitialized] = useState(false);
const [isInlineUnlockMode, setIsInlineUnlockMode] = useState(false);
const { setIsInitialLoading } = useLoading();
// Auth and DB state
const { isInitialized: authInitialized, isLoggedIn } = useAuth();
const { dbInitialized, dbAvailable } = useDb();
// Derived state
const isFullyInitialized = authInitialized && dbInitialized;
const requiresAuth = isFullyInitialized && (!isLoggedIn || !dbAvailable || isInlineUnlockMode);
/**
* Store the current page path, timestamp, and navigation history in storage.
*/
const storeCurrentPage = useCallback(async (): Promise<void> => {
// Pages that are not allowed to be stored as these are auth conditional pages.
const notAllowedPaths = ['/', '/login', '/unlock', '/unlock-success', '/auth-settings'];
// Only store the page if we're fully initialized and don't need auth
if (isFullyInitialized && !requiresAuth && !notAllowedPaths.includes(location.pathname)) {
// Split the path into segments and build up the history
const segments = location.pathname.split('/').filter(Boolean);
const historyEntries: NavigationHistoryEntry[] = [];
let currentPath = '';
for (const segment of segments) {
currentPath += '/' + segment;
historyEntries.push({
pathname: currentPath,
search: location.search,
hash: location.hash,
});
}
await Promise.all([
storage.setItem(LAST_VISITED_PAGE_KEY, location.pathname),
storage.setItem(LAST_VISITED_TIME_KEY, Date.now()),
storage.setItem(NAVIGATION_HISTORY_KEY, historyEntries),
]);
}
}, [location, isFullyInitialized, requiresAuth]);
/**
* Restore the last visited page and navigation history if it was visited within the memory duration.
*/
const restoreLastPage = useCallback(async (): Promise<void> => {
// Only restore if we're fully initialized and don't need auth
if (!isFullyInitialized || requiresAuth) {
return;
}
const [lastPage, lastVisitTime, savedHistory] = await Promise.all([
storage.getItem(LAST_VISITED_PAGE_KEY) as Promise<string>,
storage.getItem(LAST_VISITED_TIME_KEY) as Promise<number>,
storage.getItem(NAVIGATION_HISTORY_KEY) as Promise<NavigationHistoryEntry[]>,
]);
if (lastPage && lastVisitTime) {
const timeSinceLastVisit = Date.now() - lastVisitTime;
if (timeSinceLastVisit <= PAGE_MEMORY_DURATION) {
// Restore the navigation history
if (savedHistory?.length) {
// First navigate to credentials page as the base
navigate('/credentials', { replace: true });
// Then restore the history stack
for (const entry of savedHistory) {
navigate(entry.pathname + entry.search + entry.hash);
}
return;
}
// Fallback to simple navigation if no history
navigate('/credentials', { replace: true });
navigate(lastPage, { replace: true });
return;
}
}
// Duration has expired, clear all stored navigation data
await Promise.all([
storage.removeItem(LAST_VISITED_PAGE_KEY),
storage.removeItem(LAST_VISITED_TIME_KEY),
storage.removeItem(NAVIGATION_HISTORY_KEY),
sendMessage('CLEAR_PERSISTED_FORM_VALUES', null, 'background'),
]);
// Navigate to the credentials page as default entry page.
navigate('/credentials', { replace: true });
}, [navigate, isFullyInitialized, requiresAuth]);
// Handle initialization and auth state changes
useEffect(() => {
// Check for inline unlock mode
const urlParams = new URLSearchParams(window.location.search);
const inlineUnlock = urlParams.get('mode') === 'inline_unlock';
setIsInlineUnlockMode(inlineUnlock);
if (isFullyInitialized) {
setIsInitialLoading(false);
if (requiresAuth) {
const allowedPaths = ['/login', '/unlock', '/unlock-success', '/auth-settings'];
if (allowedPaths.includes(location.pathname)) {
// Do not override the navigation if the current path is in the allowed paths.
return;
}
// Determine which auth page to show
if (!isLoggedIn) {
navigate('/login', { replace: true });
} else if (!dbAvailable) {
navigate('/unlock', { replace: true });
} else if (inlineUnlock) {
navigate('/unlock-success', { replace: true });
}
} else if (!isInitialized) {
// First initialization, try to restore last page or go to credentials
restoreLastPage().then(() => {
setIsInitialized(true);
});
}
}
}, [isFullyInitialized, requiresAuth, isLoggedIn, dbAvailable, isInitialized, navigate, restoreLastPage, setIsInitialLoading, location.pathname]);
// Store the current page whenever it changes
useEffect(() => {
if (isInitialized) {
storeCurrentPage();
}
}, [location.pathname, location.search, location.hash, isInitialized, storeCurrentPage]);
const contextValue = useMemo(() => ({
storeCurrentPage,
restoreLastPage,
isFullyInitialized,
requiresAuth
}), [storeCurrentPage, restoreLastPage, isFullyInitialized, requiresAuth]);
return (
<NavigationContext.Provider value={contextValue}>
{children}
</NavigationContext.Provider>
);
};
/**
* Hook to access the navigation context.
* @returns The navigation context
*/
export const useNavigation = (): NavigationContextType => {
const context = useContext(NavigationContext);
if (context === undefined) {
throw new Error('useNavigation must be used within a NavigationProvider');
}
return context;
};

View File

@@ -1,4 +1,5 @@
import React, { createContext, useContext, useState, useMemo, useEffect, useCallback } from 'react';
import { storage } from '#imports';
/**

View File

@@ -1,7 +1,9 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { WebApiService } from '@/utils/WebApiService';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { WebApiService } from '@/utils/WebApiService';
const WebApiContext = createContext<WebApiService | null>(null);
/**

View File

@@ -0,0 +1,153 @@
import { useCallback, useState } from 'react';
import { sendMessage } from 'webext-bridge/popup';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync';
import { EncryptionUtility } from '@/utils/EncryptionUtility';
import { UploadVaultRequest } from '@/utils/types/messaging/UploadVaultRequest';
import { VaultUploadResponse as messageVaultUploadResponse } from '@/utils/types/messaging/VaultUploadResponse';
type VaultMutationOptions = {
onSuccess?: () => void;
onError?: (error: Error) => void;
}
/**
* Hook to execute a vault mutation.
*/
export function useVaultMutate() : {
executeVaultMutation: (operation: () => Promise<void>, options?: VaultMutationOptions) => Promise<void>;
isLoading: boolean;
syncStatus: string;
} {
const [isLoading, setIsLoading] = useState(false);
const [syncStatus, setSyncStatus] = useState('Syncing vault');
const dbContext = useDb();
const { syncVault } = useVaultSync();
/**
* Execute the provided operation (e.g. create/update/delete credential)
*/
const executeMutateOperation = useCallback(async (
operation: () => Promise<void>,
options: VaultMutationOptions
) : Promise<void> => {
setSyncStatus('Saving changes to vault');
// Execute the provided operation (e.g. create/update/delete credential)
await operation();
setSyncStatus('Uploading vault to server');
try {
// Upload the updated vault to the server.
const base64Vault = dbContext.sqliteClient!.exportToBase64();
// Get derived key from background worker
const derivedKey = await sendMessage('GET_DERIVED_KEY', {}, 'background') as string;
// Encrypt the vault.
const encryptedVaultBlob = await EncryptionUtility.symmetricEncrypt(
base64Vault,
derivedKey
);
const request: UploadVaultRequest = {
vaultBlob: encryptedVaultBlob,
};
const response = await sendMessage('UPLOAD_VAULT', request, 'background') as messageVaultUploadResponse;
/*
* If we get here, it means we have a valid connection to the server.
* TODO: offline mode is not implemented for browser extension yet.
* authContext.setOfflineMode(false);
*/
if (response.status === 0 && response.newRevisionNumber) {
await dbContext.setCurrentVaultRevisionNumber(response.newRevisionNumber);
options.onSuccess?.();
} else if (response.status === 1) {
throw new Error('Vault merge required. Please login via the web app to merge the multiple pending updates to your vault.');
} else {
throw new Error('Failed to upload vault to server');
}
} catch (error) {
// Check if it's a network error
if (error instanceof Error && (error.message.includes('network') || error.message.includes('timeout'))) {
/*
* Network error, mark as offline and track pending changes
* TODO: offline mode is not implemented for browser extension yet.
* authContext.setOfflineMode(true);
*/
options.onError?.(new Error('Network error'));
return;
}
throw error;
}
}, [dbContext]);
/**
* Hook to execute a vault mutation which uploads a new encrypted vault to the server
*/
const executeVaultMutation = useCallback(async (
operation: () => Promise<void>,
options: VaultMutationOptions = {}
) => {
try {
setIsLoading(true);
setSyncStatus('Checking for vault updates');
await syncVault({
/**
* Handle the status update.
*/
onStatus: (message) => setSyncStatus(message),
/**
* Handle successful vault sync and continue with vault mutation.
*/
onSuccess: async (hasNewVault) => {
if (hasNewVault) {
// Vault was changed, but has now been reloaded so we can continue with the operation.
}
await executeMutateOperation(operation, options);
},
/**
* Handle error during vault sync.
*/
onError: (error) => {
/**
*Toast.show({
*type: 'error',
*text1: 'Failed to sync vault',
*text2: error,
*position: 'bottom'
*});
*/
options.onError?.(new Error(error));
}
});
} catch (error) {
console.error('Error during vault mutation:', error);
/*
* Toast.show({
*type: 'error',
*text1: 'Operation failed',
*text2: error instanceof Error ? error.message : 'Unknown error',
*position: 'bottom'
*});
*/
options.onError?.(error instanceof Error ? error : new Error('Unknown error'));
} finally {
setIsLoading(false);
setSyncStatus('');
}
}, [syncVault, executeMutateOperation]);
return {
executeVaultMutation,
isLoading,
syncStatus,
};
}

View File

@@ -0,0 +1,148 @@
import { useCallback } from 'react';
import { sendMessage } from 'webext-bridge/popup';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import type { VaultResponse } from '@/utils/dist/shared/models/webapi';
/**
* Utility function to ensure a minimum time has elapsed for an operation
*/
const withMinimumDelay = async <T>(
operation: () => Promise<T>,
minDelayMs: number,
enableDelay: boolean = true
): Promise<T> => {
if (!enableDelay) {
// If delay is disabled, return the result immediately.
return operation();
}
const startTime = Date.now();
const result = await operation();
const elapsedTime = Date.now() - startTime;
if (elapsedTime < minDelayMs) {
await new Promise(resolve => setTimeout(resolve, minDelayMs - elapsedTime));
}
return result;
};
type VaultSyncOptions = {
initialSync?: boolean;
onSuccess?: (hasNewVault: boolean) => void;
onError?: (error: string) => void;
onStatus?: (message: string) => void;
_onOffline?: () => void;
}
/**
* Hook to sync the vault with the server.
*/
export const useVaultSync = () : {
syncVault: (options?: VaultSyncOptions) => Promise<boolean>;
} => {
const authContext = useAuth();
const dbContext = useDb();
const webApi = useWebApi();
const syncVault = useCallback(async (options: VaultSyncOptions = {}) => {
const { initialSync = false, onSuccess, onError, onStatus, _onOffline } = options;
// For the initial sync, we add an artifical delay to various steps which makes it feel more fluid.
const enableDelay = initialSync;
try {
const { isLoggedIn } = await authContext.initializeAuth();
if (!isLoggedIn) {
// Not authenticated, return false immediately
return false;
}
// Check app status and vault revision
onStatus?.('Checking vault updates');
const statusResponse = await withMinimumDelay(() => webApi.getStatus(), 300, enableDelay);
// Check if server is actually available, 0.0.0 indicates connection error which triggers offline mode.
if (statusResponse.serverVersion === '0.0.0') {
// Offline mode is not implemented for browser extension yet, let it fail below due to the validateStatusResponse check.
}
const statusError = webApi.validateStatusResponse(statusResponse);
if (statusError) {
onError?.(statusError);
return false;
}
/*
* If we get here, it means we have a valid connection to the server.
* TODO: browser extension does not support offline mode yet.
* authContext.setOfflineMode(false);
*/
// Compare vault revisions
const vaultMetadata = await dbContext.getVaultMetadata();
const vaultRevisionNumber = vaultMetadata?.vaultRevisionNumber ?? 0;
if (statusResponse.vaultRevision > vaultRevisionNumber) {
onStatus?.('Syncing updated vault');
const vaultResponseJson = await withMinimumDelay(() => webApi.get<VaultResponse>('Vault'), 1000, enableDelay);
const vaultError = webApi.validateVaultResponse(vaultResponseJson as VaultResponse);
if (vaultError) {
// Only logout if it's an authentication error, not a network error
if (vaultError.includes('authentication') || vaultError.includes('unauthorized')) {
await webApi.logout(vaultError);
onError?.(vaultError);
return false;
}
/*
* TODO: browser extension does not support offline mode yet.
* For other errors, go into offline mode
* authContext.setOfflineMode(true);
*/
return false;
}
try {
// Get derived key from background worker
const passwordHashBase64 = await sendMessage('GET_DERIVED_KEY', {}, 'background') as string;
await dbContext.initializeDatabase(vaultResponseJson as VaultResponse, passwordHashBase64);
onSuccess?.(true);
return true;
} catch {
// Vault could not be decrypted, throw an error
throw new Error('Vault could not be decrypted, if problem persists please logout and login again.');
}
}
await withMinimumDelay(() => Promise.resolve(onSuccess?.(false)), 300, enableDelay);
return false;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error during vault sync';
console.error('Vault sync error:', err);
/*
* Check if it's a network error
* TODO: browser extension does not support offline mode yet.
*/
/*
* if (errorMessage.includes('network') || errorMessage.includes('timeout')) {
*authContext.setOfflineMode(true);
*return true;
*}
*/
onError?.(errorMessage);
return false;
}
}, [authContext, dbContext, webApi]);
return { syncVault };
};

View File

@@ -1,14 +1,12 @@
import ReactDOM from 'react-dom/client';
import App from '@/entrypoints/popup/App';
import { AuthProvider } from '@/entrypoints/popup/context/AuthContext';
import { WebApiProvider } from '@/entrypoints/popup/context/WebApiContext';
import { DbProvider } from '@/entrypoints/popup/context/DbContext';
import { HeaderButtonsProvider } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { LoadingProvider } from '@/entrypoints/popup/context/LoadingContext';
import { ThemeProvider } from '@/entrypoints/popup/context/ThemeContext';
import { setupExpandedMode } from '@/utils/ExpandedMode';
// Run before React initializes to ensure the popup is always a fixed width except for when explicitly expanded.
setupExpandedMode();
import { WebApiProvider } from '@/entrypoints/popup/context/WebApiContext';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
@@ -16,9 +14,11 @@ root.render(
<AuthProvider>
<WebApiProvider>
<LoadingProvider>
<ThemeProvider>
<App />
</ThemeProvider>
<HeaderButtonsProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</HeaderButtonsProvider>
</LoadingProvider>
</WebApiProvider>
</AuthProvider>

View File

@@ -1,8 +1,13 @@
import React, { useState, useEffect } from 'react';
import * as Yup from 'yup';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { AppInfo } from '@/utils/AppInfo';
import { storage } from '#imports';
import { GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, DISABLED_SITES_KEY, VAULT_LOCKED_DISMISS_UNTIL_KEY } from '@/utils/Constants';
import { storage } from '#imports';
type ApiOption = {
label: string;
value: string;
@@ -13,6 +18,36 @@ const DEFAULT_OPTIONS: ApiOption[] = [
{ label: 'Self-hosted', value: 'custom' }
];
// Validation schema for URLs
const urlSchema = Yup.object().shape({
apiUrl: Yup.string()
.required('API URL is required')
.test('is-valid-api-url', 'Please enter a valid API URL', (value: string | undefined) => {
if (!value) {
return true; // Allow empty for non-custom option
}
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}),
clientUrl: Yup.string()
.required('Client URL is required')
.test('is-valid-client-url', 'Please enter a valid client URL', (value: string | undefined) => {
if (!value) {
return true; // Allow empty for non-custom option
}
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
})
});
/**
* Auth settings page only shown when user is not logged in.
*/
@@ -21,6 +56,8 @@ const AuthSettings: React.FC = () => {
const [customUrl, setCustomUrl] = useState<string>('');
const [customClientUrl, setCustomClientUrl] = useState<string>('');
const [isGloballyEnabled, setIsGloballyEnabled] = useState<boolean>(true);
const [errors, setErrors] = useState<{ apiUrl?: string; clientUrl?: string }>({});
const { setIsInitialLoading } = useLoading();
useEffect(() => {
/**
@@ -49,10 +86,11 @@ const AuthSettings: React.FC = () => {
} else {
setSelectedOption(DEFAULT_OPTIONS[0].value);
}
setIsInitialLoading(false);
};
loadStoredSettings();
}, []);
}, [setIsInitialLoading]);
/**
* Handle option change
@@ -63,6 +101,9 @@ const AuthSettings: React.FC = () => {
if (value !== 'custom') {
await storage.setItem('local:apiUrl', '');
await storage.setItem('local:clientUrl', '');
setCustomUrl('');
setCustomClientUrl('');
setErrors({});
}
};
@@ -72,17 +113,37 @@ const AuthSettings: React.FC = () => {
const handleCustomUrlChange = async (e: React.ChangeEvent<HTMLInputElement>) : Promise<void> => {
const value = e.target.value;
setCustomUrl(value);
await storage.setItem('local:apiUrl', value);
try {
await urlSchema.validateAt('apiUrl', { apiUrl: value });
setErrors(prev => ({ ...prev, apiUrl: undefined }));
await storage.setItem('local:apiUrl', value);
} catch (error: unknown) {
if (error instanceof Yup.ValidationError) {
setErrors(prev => ({ ...prev, apiUrl: error.message }));
// On error we revert back to the aliasvault.net official hosted instance.
await storage.setItem('local:apiUrl', AppInfo.DEFAULT_API_URL);
await storage.setItem('local:clientUrl', AppInfo.DEFAULT_CLIENT_URL);
}
}
};
/**
* Handle custom client URL change
* @param e
*/
const handleCustomClientUrlChange = async (e: React.ChangeEvent<HTMLInputElement>) : Promise<void> => {
const value = e.target.value;
setCustomClientUrl(value);
await storage.setItem('local:clientUrl', value);
try {
await urlSchema.validateAt('clientUrl', { clientUrl: value });
setErrors(prev => ({ ...prev, clientUrl: undefined }));
await storage.setItem('local:clientUrl', value);
} catch (error: unknown) {
if (error instanceof Yup.ValidationError) {
setErrors(prev => ({ ...prev, clientUrl: error.message }));
}
}
};
/**
@@ -133,8 +194,11 @@ const AuthSettings: React.FC = () => {
value={customClientUrl}
onChange={handleCustomClientUrlChange}
placeholder="https://my-aliasvault-instance.com"
className="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
className={`w-full bg-gray-50 border ${errors.clientUrl ? 'border-red-500' : 'border-gray-300'} text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white`}
/>
{errors.clientUrl && (
<p className="mt-1 text-sm text-red-500">{errors.clientUrl}</p>
)}
</div>
<div className="mb-6">
<label htmlFor="custom-api-url" className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
@@ -146,8 +210,11 @@ const AuthSettings: React.FC = () => {
value={customUrl}
onChange={handleCustomUrlChange}
placeholder="https://my-aliasvault-instance.com/api"
className="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
className={`w-full bg-gray-50 border ${errors.apiUrl ? 'border-red-500' : 'border-gray-300'} text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white`}
/>
{errors.apiUrl && (
<p className="mt-1 text-sm text-red-500">{errors.apiUrl}</p>
)}
</div>
</>
)}

View File

@@ -0,0 +1,682 @@
import { Buffer } from 'buffer';
import { yupResolver } from '@hookform/resolvers/yup';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import { sendMessage } from 'webext-bridge/popup';
import * as Yup from 'yup';
import { FormInput } from '@/entrypoints/popup/components/FormInput';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
import Modal from '@/entrypoints/popup/components/Modal';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
import { IdentityHelperUtils, CreateIdentityGenerator, CreateUsernameEmailGenerator, Identity, Gender } from '@/utils/dist/shared/identity-generator';
import type { Credential } from '@/utils/dist/shared/models/vault';
import { CreatePasswordGenerator } from '@/utils/dist/shared/password-generator';
import LoadingSpinner from '../components/LoadingSpinner';
import { useLoading } from '../context/LoadingContext';
type CredentialMode = 'random' | 'manual';
// Persisted form data type used for JSON serialization.
type PersistedFormData = {
credentialId: string | null;
mode: CredentialMode;
formValues: Omit<Credential, 'Logo'> & { Logo?: string | null };
}
/**
* Validation schema for the credential form.
*/
const credentialSchema = Yup.object().shape({
Id: Yup.string(),
ServiceName: Yup.string().required('Service name is required'),
ServiceUrl: Yup.string().url('Invalid URL format').nullable().optional(),
Alias: Yup.object().shape({
FirstName: Yup.string().nullable().optional(),
LastName: Yup.string().nullable().optional(),
NickName: Yup.string().nullable().optional(),
BirthDate: Yup.string()
.nullable()
.optional()
.test(
'is-valid-date-format',
'Date must be in YYYY-MM-DD format',
value => {
if (!value) {
return true;
}
return /^\d{4}-\d{2}-\d{2}$/.test(value);
},
),
Gender: Yup.string().nullable().optional(),
Email: Yup.string().email('Invalid email format').nullable().optional()
}),
Username: Yup.string().nullable().optional(),
Password: Yup.string().nullable().optional(),
Notes: Yup.string().nullable().optional()
});
/**
* Add or edit credential page.
*/
const CredentialAddEdit: React.FC = () => {
const { id } = useParams();
const navigate = useNavigate();
const dbContext = useDb();
const { executeVaultMutation, isLoading, syncStatus } = useVaultMutate();
const [mode, setMode] = useState<CredentialMode>('random');
const { setHeaderButtons } = useHeaderButtons();
const { setIsInitialLoading } = useLoading();
const [localLoading, setLocalLoading] = useState(true);
const [showPassword, setShowPassword] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const webApi = useWebApi();
const serviceNameRef = useRef<HTMLInputElement>(null);
const { handleSubmit, setValue, watch, formState: { errors } } = useForm<Credential>({
resolver: yupResolver(credentialSchema as Yup.ObjectSchema<Credential>),
defaultValues: {
Id: "",
Username: "",
Password: "",
ServiceName: "",
ServiceUrl: "",
Notes: "",
Alias: {
FirstName: "",
LastName: "",
NickName: "",
BirthDate: "",
Gender: undefined,
Email: ""
}
}
});
/**
* Persists the current form values to storage
* @returns Promise that resolves when the form values are persisted
*/
const persistFormValues = useCallback(async (): Promise<void> => {
if (localLoading) {
// Do not persist values if the page is still loading.
return;
}
const formValues = watch();
const persistedData: PersistedFormData = {
credentialId: id || null,
mode,
formValues: {
...formValues,
Logo: null // Don't persist the Logo field as it can't be user modified in the UI.
}
};
await sendMessage('PERSIST_FORM_VALUES', JSON.stringify(persistedData), 'background');
}, [watch, id, mode, localLoading]);
/**
* Watch for mode changes and persist form values
*/
useEffect(() => {
if (!localLoading) {
void persistFormValues();
}
}, [mode, persistFormValues, localLoading]);
// Watch for form changes and persist them
useEffect(() => {
const subscription = watch(() => {
void persistFormValues();
});
return (): void => subscription.unsubscribe();
}, [watch, persistFormValues]);
// If we received an ID, we're in edit mode
const isEditMode = id !== undefined && id.length > 0;
/**
* Loads persisted form values from storage. This is used to keep track of form changes
* and restore them when the page is reloaded. The browser extension popup will close
* automatically by clicking outside of the popup, but with this logic we can restore
* the form values when the page is reloaded so the user can continue their mutation operation.
*
* @returns Promise that resolves when the form values are loaded
*/
const loadPersistedValues = useCallback(async (): Promise<void> => {
const persistedData = await sendMessage('GET_PERSISTED_FORM_VALUES', null, 'background') as string | null;
// Try to parse the persisted data as a JSON object.
try {
let persistedDataObject: PersistedFormData | null = null;
try {
if (persistedData) {
persistedDataObject = JSON.parse(persistedData) as PersistedFormData;
}
} catch (error) {
console.error('Error parsing persisted data:', error);
}
// Check if the object has a value and is not null
const objectEmpty = persistedDataObject === null || persistedDataObject === undefined;
if (objectEmpty) {
// If the persisted data object is empty, we don't have any values to restore and can exit early.
setLocalLoading(false);
return;
}
const isCurrentPage = persistedDataObject?.credentialId == id;
if (persistedDataObject && isCurrentPage) {
// Only restore if the persisted credential ID matches current page
setMode(persistedDataObject.mode);
Object.entries(persistedDataObject.formValues).forEach(([key, value]) => {
setValue(key as keyof Credential, value as Credential[keyof Credential]);
});
} else {
console.error('Persisted values do not match current page');
}
} catch (error) {
console.error('Error loading persisted data:', error);
}
// Set local loading state to false which also activates the persisting of form value changes from this point on.
setLocalLoading(false);
}, [setValue, id, setMode, setLocalLoading]);
/**
* Clears persisted form values from storage
* @returns Promise that resolves when the form values are cleared
*/
const clearPersistedValues = useCallback(async (): Promise<void> => {
await sendMessage('CLEAR_PERSISTED_FORM_VALUES', null, 'background');
}, []);
// Clear persisted values when the page is unmounted.
useEffect(() => {
return (): void => {
void clearPersistedValues();
};
}, [clearPersistedValues]);
/**
* Load an existing credential from the database in edit mode.
*/
useEffect(() => {
if (!dbContext?.sqliteClient) {
return;
}
if (!id) {
// On create mode, focus the service name field after a short delay to ensure the component is mounted.
setTimeout(() => {
serviceNameRef.current?.focus();
}, 100);
setIsInitialLoading(false);
// Load persisted form values if they exist.
loadPersistedValues();
return;
}
try {
const result = dbContext.sqliteClient.getCredentialById(id);
if (result) {
result.Alias.BirthDate = IdentityHelperUtils.normalizeBirthDateForDisplay(result.Alias.BirthDate);
// Set form values
Object.entries(result).forEach(([key, value]) => {
setValue(key as keyof Credential, value);
});
setMode('manual');
setIsInitialLoading(false);
// Check for persisted values that might override the loaded values if they exist.
loadPersistedValues();
} else {
console.error('Credential not found');
navigate('/credentials');
}
} catch (err) {
console.error('Error loading credential:', err);
setIsInitialLoading(false);
}
}, [dbContext.sqliteClient, id, navigate, setIsInitialLoading, setValue, loadPersistedValues]);
/**
* Handle the delete button click.
*/
const handleDelete = useCallback(async (): Promise<void> => {
if (!id) {
return;
}
executeVaultMutation(async () => {
dbContext.sqliteClient!.deleteCredentialById(id);
}, {
/**
* Navigate to the credentials list page on success.
*/
onSuccess: () => {
void clearPersistedValues();
navigate('/credentials');
}
});
}, [id, executeVaultMutation, dbContext.sqliteClient, navigate, clearPersistedValues]);
/**
* Initialize the identity and password generators with settings from user's vault.
*/
const initializeGenerators = useCallback(async () => {
// Get default identity language from database
const identityLanguage = dbContext.sqliteClient!.getDefaultIdentityLanguage();
// Initialize identity generator based on language
const identityGenerator = CreateIdentityGenerator(identityLanguage);
// Initialize password generator with settings from vault
const passwordSettings = dbContext.sqliteClient!.getPasswordSettings();
const passwordGenerator = CreatePasswordGenerator(passwordSettings);
return { identityGenerator, passwordGenerator };
}, [dbContext.sqliteClient]);
/**
* Generate a random alias and password.
*/
const generateRandomAlias = useCallback(async () => {
const { identityGenerator, passwordGenerator } = await initializeGenerators();
const identity = identityGenerator.generateRandomIdentity();
const password = passwordGenerator.generateRandomPassword();
const metadata = await dbContext.getVaultMetadata();
const privateEmailDomains = metadata?.privateEmailDomains ?? [];
const publicEmailDomains = metadata?.publicEmailDomains ?? [];
const defaultEmailDomain = dbContext.sqliteClient!.getDefaultEmailDomain(privateEmailDomains, publicEmailDomains);
const email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix;
setValue('Alias.Email', email);
setValue('Alias.FirstName', identity.firstName);
setValue('Alias.LastName', identity.lastName);
setValue('Alias.NickName', identity.nickName);
setValue('Alias.Gender', identity.gender);
setValue('Alias.BirthDate', IdentityHelperUtils.normalizeBirthDateForDisplay(identity.birthDate.toISOString()));
// In edit mode, preserve existing username and password if they exist
if (isEditMode && watch('Username')) {
// Keep the existing username in edit mode, so don't do anything here.
} else {
// Use the newly generated username
setValue('Username', identity.nickName);
}
if (isEditMode && watch('Password')) {
// Keep the existing password in edit mode, so don't do anything here.
} else {
// Use the newly generated password
setValue('Password', password);
}
}, [isEditMode, watch, setValue, initializeGenerators, dbContext]);
/**
* Handle the generate random alias button press.
*/
const handleGenerateRandomAlias = useCallback(() => {
void generateRandomAlias();
}, [generateRandomAlias]);
const generateRandomUsername = useCallback(async () => {
try {
const usernameEmailGenerator = CreateUsernameEmailGenerator();
let gender = Gender.Other;
try {
gender = watch('Alias.Gender') as Gender;
} catch {
// Gender parsing failed, default to other.
}
const identity: Identity = {
firstName: watch('Alias.FirstName') ?? '',
lastName: watch('Alias.LastName') ?? '',
nickName: watch('Alias.NickName') ?? '',
gender: gender,
birthDate: new Date(watch('Alias.BirthDate') ?? ''),
emailPrefix: watch('Alias.Email') ?? '',
};
const username = usernameEmailGenerator.generateUsername(identity);
setValue('Username', username);
} catch (error) {
console.error('Error generating random username:', error);
}
}, [setValue, watch]);
const generateRandomPassword = useCallback(async () => {
try {
const { passwordGenerator } = await initializeGenerators();
const password = passwordGenerator.generateRandomPassword();
setValue('Password', password);
setShowPassword(true);
} catch (error) {
console.error('Error generating random password:', error);
}
}, [initializeGenerators, setValue]);
/**
* Handle form submission.
*/
const onSubmit = useCallback(async (data: Credential): Promise<void> => {
// Normalize the birth date for database entry.
let birthdate = data.Alias.BirthDate;
if (birthdate) {
birthdate = IdentityHelperUtils.normalizeBirthDateForDb(birthdate);
}
// If we're creating a new credential and mode is random, generate random values here
if (!isEditMode && mode === 'random') {
// Generate random values now and then read them from the form fields to manually assign to the credentialToSave object
await generateRandomAlias();
data.Username = watch('Username');
data.Password = watch('Password');
data.Alias.FirstName = watch('Alias.FirstName');
data.Alias.LastName = watch('Alias.LastName');
data.Alias.NickName = watch('Alias.NickName');
data.Alias.BirthDate = birthdate;
data.Alias.Gender = watch('Alias.Gender');
data.Alias.Email = watch('Alias.Email');
}
// Extract favicon from service URL if the credential has one
if (data.ServiceUrl) {
setLocalLoading(true);
try {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Favicon extraction timed out')), 5000)
);
const faviconPromise = webApi.get<{ image: string }>('Favicon/Extract?url=' + data.ServiceUrl);
const faviconResponse = await Promise.race([faviconPromise, timeoutPromise]) as { image: string };
if (faviconResponse?.image) {
const decodedImage = Uint8Array.from(Buffer.from(faviconResponse.image, 'base64'));
data.Logo = decodedImage;
}
} catch {
// Favicon extraction failed or timed out, this is not a critical error so we can ignore it.
}
}
executeVaultMutation(async () => {
setLocalLoading(false);
if (isEditMode) {
await dbContext.sqliteClient!.updateCredentialById(data);
} else {
const credentialId = await dbContext.sqliteClient!.createCredential(data);
data.Id = credentialId.toString();
}
}, {
/**
* Navigate to the credential details page on success.
*/
onSuccess: () => {
void clearPersistedValues();
// If in add mode, navigate to the credential details page.
if (!isEditMode) {
// Navigate to the credential details page.
navigate(`/credentials/${data.Id}`, { replace: true });
} else {
// If in edit mode, pop the current page from the history stack to end up on details page as well.
navigate(-1);
}
},
});
}, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate, mode, watch, generateRandomAlias, webApi, clearPersistedValues]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
// Only set the header buttons once on mount.
const headerButtonsJSX = (
<div className="flex items-center gap-2">
{isEditMode && (
<HeaderButton
onClick={() => setShowDeleteModal(true)}
title="Delete credential"
iconType={HeaderIconType.DELETE}
variant="danger"
/>
)}
<HeaderButton
onClick={handleSubmit(onSubmit)}
title="Save credential"
iconType={HeaderIconType.SAVE}
/>
</div>
);
setHeaderButtons(headerButtonsJSX);
return () => {};
}, [setHeaderButtons, handleSubmit, onSubmit, isEditMode]);
// Clear header buttons on unmount
useEffect((): (() => void) => {
return () => setHeaderButtons(null);
}, [setHeaderButtons]);
if (isEditMode && !watch('ServiceName')) {
return <div>Loading...</div>;
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<button type="submit" style={{ display: 'none' }} />
{(localLoading || isLoading) && (
<div className="fixed inset-0 flex flex-col justify-center items-center bg-white dark:bg-gray-900 bg-opacity-90 dark:bg-opacity-90 z-50">
<LoadingSpinner />
<div className="text-sm text-gray-500 mt-2">
{syncStatus}
</div>
</div>
)}
<Modal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={() => {
setShowDeleteModal(false);
void handleDelete();
}}
title="Delete Credential"
message="Are you sure you want to delete this credential? This action cannot be undone."
confirmText="Delete"
variant="danger"
/>
{!isEditMode && (
<div className="flex space-x-2 mb-4">
<button
type="button"
onClick={() => setMode('random')}
className={`flex-1 py-2 px-4 rounded flex items-center justify-center gap-2 ${
mode === 'random' ? 'bg-primary-500 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
<svg className='w-5 h-5' viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8" cy="8" r="1"/>
<circle cx="16" cy="8" r="1"/>
<circle cx="12" cy="12" r="1"/>
<circle cx="8" cy="16" r="1"/>
<circle cx="16" cy="16" r="1"/>
</svg>
Random Alias
</button>
<button
type="button"
onClick={() => setMode('manual')}
className={`flex-1 py-2 px-4 rounded flex items-center justify-center gap-2 ${
mode === 'manual' ? 'bg-primary-500 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="7" r="4"/>
<path d="M5.5 20a6.5 6.5 0 0 1 13 0"/>
</svg>
Manual
</button>
</div>
)}
<div className="space-y-4">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Service</h2>
<div className="space-y-4">
<FormInput
id="serviceName"
label="Service Name"
ref={serviceNameRef}
value={watch('ServiceName') ?? ''}
onChange={(value) => setValue('ServiceName', value)}
required
error={errors.ServiceName?.message}
/>
<FormInput
id="serviceUrl"
label="Service URL"
value={watch('ServiceUrl') ?? ''}
onChange={(value) => setValue('ServiceUrl', value)}
error={errors.ServiceUrl?.message}
/>
</div>
</div>
{(mode === 'manual' || isEditMode) && (
<>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Login Credentials</h2>
<div className="space-y-4">
<FormInput
id="username"
label="Username"
value={watch('Username') ?? ''}
onChange={(value) => setValue('Username', value)}
error={errors.Username?.message}
buttons={[
{
icon: 'refresh',
onClick: generateRandomUsername,
title: 'Generate random username'
}
]}
/>
<FormInput
id="password"
label="Password"
type="password"
value={watch('Password') ?? ''}
onChange={(value) => setValue('Password', value)}
error={errors.Password?.message}
showPassword={showPassword}
onShowPasswordChange={setShowPassword}
buttons={[
{
icon: 'refresh',
onClick: generateRandomPassword,
title: 'Generate random password'
}
]}
/>
<button
type="button"
onClick={handleGenerateRandomAlias}
className="w-full bg-primary-500 text-white py-2 px-4 rounded hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
>
Generate Random Alias
</button>
<FormInput
id="email"
label="Email"
value={watch('Alias.Email') ?? ''}
onChange={(value) => setValue('Alias.Email', value)}
error={errors.Alias?.Email?.message}
/>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Alias</h2>
<div className="space-y-4">
<FormInput
id="firstName"
label="First Name"
value={watch('Alias.FirstName') ?? ''}
onChange={(value) => setValue('Alias.FirstName', value)}
error={errors.Alias?.FirstName?.message}
/>
<FormInput
id="lastName"
label="Last Name"
value={watch('Alias.LastName') ?? ''}
onChange={(value) => setValue('Alias.LastName', value)}
error={errors.Alias?.LastName?.message}
/>
<FormInput
id="nickName"
label="Nick Name"
value={watch('Alias.NickName') ?? ''}
onChange={(value) => setValue('Alias.NickName', value)}
error={errors.Alias?.NickName?.message}
/>
<FormInput
id="gender"
label="Gender"
value={watch('Alias.Gender') ?? ''}
onChange={(value) => setValue('Alias.Gender', value)}
error={errors.Alias?.Gender?.message}
/>
<FormInput
id="birthDate"
label="Birth Date"
placeholder="YYYY-MM-DD"
value={watch('Alias.BirthDate') ?? ''}
onChange={(value) => setValue('Alias.BirthDate', value)}
error={errors.Alias?.BirthDate?.message}
/>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Metadata</h2>
<div className="space-y-4">
<FormInput
id="notes"
label="Notes"
value={watch('Notes') ?? ''}
onChange={(value) => setValue('Notes', value)}
multiline
rows={4}
error={errors.Notes?.message}
/>
</div>
</div>
</>
)}
</div>
</form>
);
};
export default CredentialAddEdit;

View File

@@ -1,8 +1,6 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { Credential } from '@/utils/types/Credential';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import {
HeaderBlock,
EmailBlock,
@@ -11,16 +9,24 @@ import {
AliasBlock,
NotesBlock
} from '@/entrypoints/popup/components/CredentialDetails';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import type { Credential } from '@/utils/dist/shared/models/vault';
/**
* Credential details page.
*/
const CredentialDetails: React.FC = () => {
const CredentialDetails: React.FC = (): React.ReactElement => {
const { id } = useParams();
const navigate = useNavigate();
const dbContext = useDb();
const [credential, setCredential] = useState<Credential | null>(null);
const { setIsInitialLoading } = useLoading();
const { setHeaderButtons } = useHeaderButtons();
/**
* Check if the current page is an expanded popup.
@@ -33,9 +39,9 @@ const CredentialDetails: React.FC = () => {
/**
* Open the credential details in a new expanded popup.
*/
const openInNewPopup = (): void => {
const width = 380;
const height = 600;
const openInNewPopup = useCallback((): void => {
const width = 800;
const height = 1000;
const left = window.screen.width / 2 - width / 2;
const top = window.screen.height / 2 - height / 2;
@@ -46,24 +52,14 @@ const CredentialDetails: React.FC = () => {
);
window.close();
};
}, [id]);
/**
* Check if the email domain is supported.
* Navigate to the edit page for this credential.
*/
const isEmailDomainSupported = (email: string): boolean => {
const domain = email.split('@')[1]?.toLowerCase();
if (!domain) {
return false;
}
const publicDomains = dbContext.publicEmailDomains ?? [];
const privateDomains = dbContext.privateEmailDomains ?? [];
return [...publicDomains, ...privateDomains].some(supportedDomain =>
domain === supportedDomain || domain.endsWith(`.${supportedDomain}`)
);
};
const handleEdit = useCallback((): void => {
navigate(`/credentials/${id}/edit`);
}, [id, navigate]);
useEffect(() => {
if (isPopup()) {
@@ -89,23 +85,49 @@ const CredentialDetails: React.FC = () => {
}
}, [dbContext.sqliteClient, id, navigate, setIsInitialLoading]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
const headerButtonsJSX = (
<div className="flex items-center gap-2">
<HeaderButton
onClick={openInNewPopup}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
<HeaderButton
onClick={handleEdit}
title="Edit credential"
iconType={HeaderIconType.EDIT}
/>
</div>
);
setHeaderButtons(headerButtonsJSX);
return () => {};
}, [setHeaderButtons, handleEdit, openInNewPopup]);
// Clear header buttons on unmount
useEffect((): (() => void) => {
return () => setHeaderButtons(null);
}, [setHeaderButtons]);
if (!credential) {
return <div>Loading...</div>;
}
return (
<div className="space-y-4">
<HeaderBlock credential={credential} onOpenNewPopup={openInNewPopup} />
<div className="flex justify-between items-center">
<HeaderBlock credential={credential} />
</div>
{credential.Alias?.Email && (
<EmailBlock
email={credential.Alias.Email}
isSupported={isEmailDomainSupported(credential.Alias.Email)}
/>
)}
<NotesBlock notes={credential.Notes} />
<TotpBlock credentialId={credential.Id} />
<LoginCredentialsBlock credential={credential} />
<AliasBlock credential={credential} />
<NotesBlock notes={credential.Notes} />
</div>
);
};

View File

@@ -1,14 +1,20 @@
import React, { useState, useEffect, useCallback } from 'react';
import { sendMessage } from 'webext-bridge/popup';
import { useNavigate } from 'react-router-dom';
import CredentialCard from '@/entrypoints/popup/components/CredentialCard';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
import ReloadButton from '@/entrypoints/popup/components/ReloadButton';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { Credential } from '@/utils/types/Credential';
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import { VaultResponse } from '@/utils/types/webapi/VaultResponse';
import ReloadButton from '@/entrypoints/popup/components/ReloadButton';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
import { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync';
import type { Credential } from '@/utils/dist/shared/models/vault';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import CredentialCard from '@/entrypoints/popup/components/CredentialCard';
/**
* Credentials list page.
@@ -16,15 +22,25 @@ import CredentialCard from '@/entrypoints/popup/components/CredentialCard';
const CredentialsList: React.FC = () => {
const dbContext = useDb();
const webApi = useWebApi();
const navigate = useNavigate();
const { syncVault } = useVaultSync();
const { setHeaderButtons } = useHeaderButtons();
const [credentials, setCredentials] = useState<Credential[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const { showLoading, hideLoading, setIsInitialLoading } = useLoading();
const { setIsInitialLoading } = useLoading();
/**
* Loading state with minimum duration for more fluid UX.
*/
const [isLoading, setIsLoading] = useMinDurationLoading(true, 100);
/**
* Handle add new credential.
*/
const handleAddCredential = useCallback(() : void => {
navigate('/credentials/add');
}, [navigate]);
/**
* Retrieve latest vault and refresh the credentials list.
*/
@@ -33,83 +49,84 @@ const CredentialsList: React.FC = () => {
return;
}
// Do status check first to ensure the extension is (still) supported.
const statusResponse = await webApi.getStatus();
const statusError = webApi.validateStatusResponse(statusResponse);
if (statusError !== null) {
await webApi.logout(statusError);
return;
}
try {
// If the vault revision is the same or lower, (re)load existing credentials.
if (statusResponse.vaultRevision <= dbContext.vaultRevision) {
const results = dbContext.sqliteClient.getAllCredentials();
setCredentials(results);
return;
}
/**
* If the vault revision is higher, fetch the latest vault and initialize the SQLite context again.
* This will trigger a new credentials list refresh.
*/
const vaultResponseJson = await webApi.get<VaultResponse>('Vault');
const vaultError = webApi.validateVaultResponse(vaultResponseJson);
if (vaultError) {
await webApi.logout(vaultError);
hideLoading();
return;
}
// Get derived key from background worker
const passwordHashBase64 = await sendMessage('GET_DERIVED_KEY', {}, 'background') as string;
// Initialize the SQLite context again with the newly retrieved decrypted blob)
try {
await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
} catch {
// Sync vault and load credentials
await syncVault({
/**
* If error occurs during database initialization, it most likely has to do with decryption that
* failed. This is most likely due to the user changing their password.
* So we logout the user here to force them to re-authenticate.
* On success.
*/
await webApi.logout('Vault could not be decrypted, please re-authenticate.');
}
onSuccess: async (_hasNewVault) => {
// Credentials list is refreshed automatically when the (new) sqlite client is available via useEffect hook below.
},
/**
* On offline.
*/
_onOffline: () => {
// Not implemented for browser extension yet.
},
/**
* On error.
*/
onError: async (error) => {
console.error('Error syncing vault:', error);
await webApi.logout('Error while syncing vault, please re-authenticate.');
},
});
} catch (err) {
console.error('Refresh error:', err);
console.error('Error refreshing credentials:', err);
await webApi.logout('Error while syncing vault, please re-authenticate.');
}
}, [dbContext, webApi, hideLoading]);
}, [dbContext, webApi, syncVault]);
/**
* Manually refresh the credentials list.
* Get latest vault from server and refresh the credentials list.
*/
const onManualRefresh = async (): Promise<void> => {
showLoading();
const syncVaultAndRefresh = useCallback(async () : Promise<void> => {
setIsLoading(true);
await onRefresh();
hideLoading();
};
setIsLoading(false);
setIsInitialLoading(false);
}, [onRefresh, setIsLoading, setIsInitialLoading]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
const headerButtonsJSX = (
<div className="flex items-center gap-2">
<HeaderButton
onClick={handleAddCredential}
title="Add new credential"
iconType={HeaderIconType.PLUS}
/>
</div>
);
setHeaderButtons(headerButtonsJSX);
return () => setHeaderButtons(null);
}, [setHeaderButtons, handleAddCredential]);
/**
* Load credentials list on mount and on sqlite client change.
*/
useEffect(() => {
/**
* Refresh credentials list when sqlite client is available.
* Refresh credentials list when a (new) sqlite client is available.
*/
const refreshCredentials = async () : Promise<void> => {
if (dbContext?.sqliteClient) {
setIsLoading(true);
await onRefresh();
const results = dbContext.sqliteClient?.getAllCredentials() ?? [];
setCredentials(results);
setIsLoading(false);
// Hide the global app initial loading state after the credentials list is loaded.
setIsInitialLoading(false);
}
};
refreshCredentials();
}, [dbContext?.sqliteClient, onRefresh, setIsLoading, setIsInitialLoading]);
}, [dbContext?.sqliteClient, setIsLoading]);
// Call syncVaultAndRefresh when the page first mounts
useEffect(() => {
syncVaultAndRefresh();
}, [syncVaultAndRefresh]);
// Add this function to filter credentials
const filteredCredentials = credentials.filter(cred => {
@@ -135,7 +152,7 @@ const CredentialsList: React.FC = () => {
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-gray-900 dark:text-white text-xl">Credentials</h2>
<ReloadButton onClick={onManualRefresh} />
<ReloadButton onClick={syncVaultAndRefresh} />
</div>
{credentials.length > 0 ? (

View File

@@ -1,19 +1,26 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Email } from '@/utils/types/webapi/Email';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import EncryptionUtility from '@/utils/EncryptionUtility';
import { Attachment } from '@/utils/types/webapi/Attachment';
import Modal from '@/entrypoints/popup/components/Modal';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import ConversionUtility from '@/entrypoints/popup/utils/ConversionUtility';
import type { EmailAttachment, Email } from '@/utils/dist/shared/models/webapi';
import EncryptionUtility from '@/utils/EncryptionUtility';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import HeaderButton from '../components/HeaderButton';
import { HeaderIconType } from '../components/Icons/HeaderIcons';
/**
* Email details page.
*/
const EmailDetails: React.FC = () => {
const EmailDetails: React.FC = (): React.ReactElement => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const dbContext = useDb();
@@ -21,16 +28,10 @@ const EmailDetails: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState<Email | null>(null);
const [isLoading, setIsLoading] = useMinDurationLoading(true, 150);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const { setIsInitialLoading } = useLoading();
/**
* Make sure the initial loading state is set to false when this component is loaded itself.
*/
useEffect(() => {
if (!isLoading) {
setIsInitialLoading(false);
}
}, [setIsInitialLoading, isLoading]);
const { setHeaderButtons } = useHeaderButtons();
const [headerButtonsConfigured, setHeaderButtonsConfigured] = useState(false);
useEffect(() => {
// For popup windows, ensure we have proper history state for navigation
@@ -62,23 +63,28 @@ const EmailDetails: React.FC = () => {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsLoading(false);
setIsInitialLoading(false);
}
};
loadEmail();
}, [id, dbContext?.sqliteClient, webApi, setIsLoading]);
}, [id, dbContext?.sqliteClient, webApi, setIsLoading, setIsInitialLoading]);
/**
* Handle deleting an email.
*/
const handleDelete = async () : Promise<void> => {
const handleDelete = useCallback(async () : Promise<void> => {
try {
await webApi.delete(`Email/${id}`);
navigate('/emails');
if (isPopup()) {
window.close();
} else {
navigate('/emails');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete email');
}
};
}, [id, webApi, navigate]);
/**
* Check if the current page is an expanded popup.
@@ -91,7 +97,7 @@ const EmailDetails: React.FC = () => {
/**
* Open the credential details in a new expanded popup.
*/
const openInNewPopup = () : void => {
const openInNewPopup = useCallback((): void => {
const width = 800;
const height = 1000;
const left = window.screen.width / 2 - width / 2;
@@ -105,15 +111,15 @@ const EmailDetails: React.FC = () => {
// Close the current tab
window.close();
};
}, [id]);
/**
* Handle downloading an attachment.
*/
const handleDownloadAttachment = async (attachment: Attachment): Promise<void> => {
const handleDownloadAttachment = async (attachment: EmailAttachment): Promise<void> => {
try {
// Get the encrypted attachment bytes from the API
const base64EncryptedAttachment = await webApi.downloadBlobAndConvertToBase64(`Email/${id}/attachments/${attachment.id}`);
const encryptedBytes = await webApi.downloadBlob(`Email/${id}/attachments/${attachment.id}`);
if (!dbContext?.sqliteClient || !email) {
setError('Database context or email not available');
@@ -123,16 +129,18 @@ const EmailDetails: React.FC = () => {
// Get encryption keys for decryption
const encryptionKeys = dbContext.sqliteClient.getAllEncryptionKeys();
// Decrypt the attachment using ArrayBuffer
const decryptedBytes = await EncryptionUtility.decryptAttachment(base64EncryptedAttachment, email, encryptionKeys);
// Decrypt the attachment using raw bytes
const decryptedBytes = await EncryptionUtility.decryptAttachment(encryptedBytes, email, encryptionKeys);
if (!decryptedBytes) {
setError('Failed to decrypt attachment');
return;
}
// Create blob from decrypted bytes with proper MIME type
const blob = new Blob([decryptedBytes], { type: attachment.mimeType ?? 'application/octet-stream' });
// Create Blob directly from Uint8Array
const blob = new Blob([new Uint8Array(decryptedBytes)], {
type: attachment.mimeType ?? 'application/octet-stream'
});
// Create download link and trigger download
const url = window.URL.createObjectURL(blob);
@@ -151,6 +159,37 @@ const EmailDetails: React.FC = () => {
}
};
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
// Only set the header buttons once on mount.
if (!headerButtonsConfigured) {
const headerButtonsJSX = (
<div className="flex items-center gap-2">
<HeaderButton
onClick={openInNewPopup}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
<HeaderButton
onClick={() => setShowDeleteModal(true)}
title="Delete email"
iconType={HeaderIconType.DELETE}
variant="danger"
/>
</div>
);
setHeaderButtons(headerButtonsJSX);
setHeaderButtonsConfigured(true);
}
return () => {};
}, [setHeaderButtons, headerButtonsConfigured, openInNewPopup]);
// Clear header buttons on unmount
useEffect((): (() => void) => {
return () => setHeaderButtons(null);
}, [setHeaderButtons]);
if (isLoading) {
return (
<div className="flex justify-center items-center p-8">
@@ -169,53 +208,24 @@ const EmailDetails: React.FC = () => {
return (
<div className="max-w-4xl mx-auto">
<Modal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={() => {
setShowDeleteModal(false);
void handleDelete();
}}
title="Delete Email"
message="Are you sure you want to delete this email? This action cannot be undone."
confirmText="Delete"
variant="danger"
/>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md">
{/* Header */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-start mb-4">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{email.subject}</h1>
<div className="flex space-x-2">
<button
onClick={openInNewPopup}
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
title="Open in new window"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
</button>
<button
onClick={handleDelete}
className="p-2 text-red-500 hover:text-red-600 rounded-md hover:bg-red-100 dark:hover:bg-red-900/20"
title="Delete email"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<p>From: {email.fromDisplay} ({email.fromLocal}@{email.fromDomain})</p>

View File

@@ -1,13 +1,16 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { MailboxBulkRequest, MailboxBulkResponse } from '@/utils/types/webapi/MailboxBulk';
import { MailboxEmail } from '@/utils/types/webapi/MailboxEmail';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import EncryptionUtility from '@/utils/EncryptionUtility';
import ReloadButton from '@/entrypoints/popup/components/ReloadButton';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import type { MailboxBulkRequest, MailboxBulkResponse, MailboxEmail } from '@/utils/dist/shared/models/webapi';
import EncryptionUtility from '@/utils/EncryptionUtility';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
/**
* Emails list page.
@@ -17,6 +20,7 @@ const EmailsList: React.FC = () => {
const webApi = useWebApi();
const [error, setError] = useState<string | null>(null);
const [emails, setEmails] = useState<MailboxEmail[]>([]);
const { setIsInitialLoading } = useLoading();
/**
* Loading state with minimum duration for more fluid UX.
@@ -61,8 +65,9 @@ const EmailsList: React.FC = () => {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsLoading(false);
setIsInitialLoading(false);
}
}, [dbContext?.sqliteClient, webApi, setIsLoading]);
}, [dbContext?.sqliteClient, webApi, setIsLoading, setIsInitialLoading]);
useEffect(() => {
loadEmails();

View File

@@ -1,61 +1,20 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import Unlock from '@/entrypoints/popup/pages/Unlock';
import Login from '@/entrypoints/popup/pages/Login';
import UnlockSuccess from '@/entrypoints/popup/pages/UnlockSuccess';
import { useNavigate } from 'react-router-dom';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useNavigation } from '@/entrypoints/popup/context/NavigationContext';
/**
* Home page that shows the correct page based on the user's authentication state.
* Most of the navigation logic is now handled by NavigationContext.
*/
const Home: React.FC = () => {
const authContext = useAuth();
const dbContext = useDb();
const navigate = useNavigate();
const { setIsInitialLoading } = useLoading();
const [isInlineUnlockMode, setIsInlineUnlockMode] = useState(false);
const { isFullyInitialized } = useNavigation();
// Initialization state.
const isFullyInitialized = authContext.isInitialized && dbContext.dbInitialized;
const isAuthenticated = authContext.isLoggedIn;
const isDatabaseAvailable = dbContext.dbAvailable;
const requireLoginOrUnlock = isFullyInitialized && (!isAuthenticated || !isDatabaseAvailable || isInlineUnlockMode);
useEffect(() => {
// Detect if the user is coming from the unlock page with mode=inline_unlock.
const urlParams = new URLSearchParams(window.location.search);
const isInlineUnlockMode = urlParams.get('mode') === 'inline_unlock';
setIsInlineUnlockMode(isInlineUnlockMode);
// Redirect to credentials if fully initialized and doesn't need unlock.
if (isFullyInitialized && !requireLoginOrUnlock) {
navigate('/credentials', { replace: true });
}
}, [isFullyInitialized, requireLoginOrUnlock, isInlineUnlockMode, navigate]);
// Show loading state if not fully initialized or when about to redirect to credentials.
if (!isFullyInitialized || (isFullyInitialized && !requireLoginOrUnlock)) {
// Global loading spinner will be shown by the parent component.
if (!isFullyInitialized) {
return null;
}
setIsInitialLoading(false);
if (!isAuthenticated) {
return <Login />;
}
if (!isDatabaseAvailable) {
return <Unlock />;
}
if (isInlineUnlockMode) {
return <UnlockSuccess onClose={() => setIsInlineUnlockMode(false)} />;
}
return null;
return <Navigate to="/credentials" replace />;
};
export default Home;

View File

@@ -1,19 +1,24 @@
import React, { useEffect, useState } from 'react';
import { Buffer } from 'buffer';
import { storage } from '#imports';
import React, { useEffect, useState } from 'react';
import Button from '@/entrypoints/popup/components/Button';
import LoginServerInfo from '@/entrypoints/popup/components/LoginServerInfo';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import { AppInfo } from '@/utils/AppInfo';
import Button from '@/entrypoints/popup/components/Button';
import EncryptionUtility from '@/utils/EncryptionUtility';
import SrpUtility from '@/entrypoints/popup/utils/SrpUtility';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { VaultResponse } from '@/utils/types/webapi/VaultResponse';
import { LoginResponse } from '@/utils/types/webapi/Login';
import LoginServerInfo from '@/entrypoints/popup/components/LoginServerInfo';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import SrpUtility from '@/entrypoints/popup/utils/SrpUtility';
import { AppInfo } from '@/utils/AppInfo';
import type { VaultResponse, LoginResponse } from '@/utils/dist/shared/models/webapi';
import EncryptionUtility from '@/utils/EncryptionUtility';
import { ApiAuthError } from '@/utils/types/errors/ApiAuthError';
import ConversionUtility from '../utils/ConversionUtility';
import { storage } from '#imports';
/**
* Login page
*/
@@ -24,7 +29,7 @@ const Login: React.FC = () => {
username: '',
password: '',
});
const { showLoading, hideLoading } = useLoading();
const { showLoading, hideLoading, setIsInitialLoading } = useLoading();
const [rememberMe, setRememberMe] = useState(true);
const [loginResponse, setLoginResponse] = useState<LoginResponse | null>(null);
const [passwordHashString, setPasswordHashString] = useState<string | null>(null);
@@ -48,9 +53,10 @@ const Login: React.FC = () => {
}
setClientUrl(clientUrl);
setIsInitialLoading(false);
};
loadClientUrl();
}, []);
}, [setIsInitialLoading]);
/**
* Handle submit
@@ -66,7 +72,7 @@ const Login: React.FC = () => {
authContext.clearGlobalMessage();
// Use the srpUtil instance instead of the imported singleton
const loginResponse = await srpUtil.initiateLogin(credentials.username);
const loginResponse = await srpUtil.initiateLogin(ConversionUtility.normalizeUsername(credentials.username));
// 1. Derive key from password using Argon2id
const passwordHash = await EncryptionUtility.deriveKeyFromPassword(
@@ -84,7 +90,7 @@ const Login: React.FC = () => {
// 2. Validate login with SRP protocol
const validationResponse = await srpUtil.validateLogin(
credentials.username,
ConversionUtility.normalizeUsername(credentials.username),
passwordHashString,
rememberMe,
loginResponse
@@ -122,7 +128,7 @@ const Login: React.FC = () => {
}
// All is good. Store auth info which is required to make requests to the web API.
await authContext.setAuthTokens(credentials.username, validationResponse.token.token, validationResponse.token.refreshToken);
await authContext.setAuthTokens(ConversionUtility.normalizeUsername(credentials.username), validationResponse.token.token, validationResponse.token.refreshToken);
// Initialize the SQLite context with the new vault data.
await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
@@ -164,7 +170,7 @@ const Login: React.FC = () => {
}
const validationResponse = await srpUtil.validateLogin2Fa(
credentials.username,
ConversionUtility.normalizeUsername(credentials.username),
passwordHashString,
rememberMe,
loginResponse,
@@ -189,7 +195,7 @@ const Login: React.FC = () => {
}
// All is good. Store auth info which is required to make requests to the web API.
await authContext.setAuthTokens(credentials.username, validationResponse.token.token, validationResponse.token.refreshToken);
await authContext.setAuthTokens(ConversionUtility.normalizeUsername(credentials.username), validationResponse.token.token, validationResponse.token.refreshToken);
// Initialize the SQLite context with the new vault data.
await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);

View File

@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';

View File

@@ -1,10 +1,18 @@
import React, { useEffect, useState, useCallback } from 'react';
import { storage } from "#imports";
import { sendMessage } from 'webext-bridge/popup';
import { DISABLED_SITES_KEY, GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, GLOBAL_CONTEXT_MENU_ENABLED_KEY, TEMPORARY_DISABLED_SITES_KEY } from '@/utils/Constants';
import { AppInfo } from '@/utils/AppInfo';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { useTheme } from '@/entrypoints/popup/context/ThemeContext';
import { browser } from "#imports";
import { AppInfo } from '@/utils/AppInfo';
import { DISABLED_SITES_KEY, GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, GLOBAL_CONTEXT_MENU_ENABLED_KEY, TEMPORARY_DISABLED_SITES_KEY } from '@/utils/Constants';
import { useLoading } from '../context/LoadingContext';
import { storage, browser } from "#imports";
/**
* Popup settings type.
@@ -23,6 +31,9 @@ type PopupSettings = {
*/
const Settings: React.FC = () => {
const { theme, setTheme } = useTheme();
const authContext = useAuth();
const { setHeaderButtons } = useHeaderButtons();
const { setIsInitialLoading } = useLoading();
const [settings, setSettings] = useState<PopupSettings>({
disabledUrls: [],
temporaryDisabledUrls: {},
@@ -41,6 +52,35 @@ const Settings: React.FC = () => {
return tab;
};
/**
* Open the client tab.
*/
const openClientTab = async () : Promise<void> => {
const settingClientUrl = await storage.getItem('local:clientUrl') as string;
let clientUrl = AppInfo.DEFAULT_CLIENT_URL;
if (settingClientUrl && settingClientUrl.length > 0) {
clientUrl = settingClientUrl;
}
window.open(clientUrl, '_blank');
};
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
const headerButtonsJSX = (
<div className="flex items-center gap-2">
<HeaderButton
onClick={openClientTab}
title="Open web app"
iconType={HeaderIconType.EXTERNAL_LINK}
/>
</div>
);
setHeaderButtons(headerButtonsJSX);
return () => setHeaderButtons(null);
}, [setHeaderButtons]);
/**
* Load settings.
*/
@@ -72,7 +112,8 @@ const Settings: React.FC = () => {
isGloballyEnabled,
isContextMenuEnabled
});
}, []);
setIsInitialLoading(false);
}, [setIsInitialLoading]);
useEffect(() => {
loadSettings();
@@ -188,12 +229,52 @@ const Settings: React.FC = () => {
}
};
/**
* Handle logout.
*/
const handleLogout = async () : Promise<void> => {
await authContext.logout();
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-gray-900 dark:text-white text-xl">Settings</h2>
</div>
{/* User Menu Section */}
<section>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
<span className="text-primary-600 dark:text-primary-400 text-lg font-medium">
{authContext.username?.[0]?.toUpperCase() || '?'}
</span>
</div>
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{authContext.username}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Logged in
</p>
</div>
</div>
<button
onClick={handleLogout}
className="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
Logout
</button>
</div>
</div>
</div>
</section>
{/* Global Settings Section */}
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">Global Settings</h3>

View File

@@ -1,16 +1,20 @@
import { Buffer } from 'buffer';
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Buffer } from 'buffer';
import { storage } from '#imports';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import Button from '@/entrypoints/popup/components/Button';
import EncryptionUtility from '@/utils/EncryptionUtility';
import SrpUtility from '@/entrypoints/popup/utils/SrpUtility';
import { VaultResponse } from '@/utils/types/webapi/VaultResponse';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import SrpUtility from '@/entrypoints/popup/utils/SrpUtility';
import { VAULT_LOCKED_DISMISS_UNTIL_KEY } from '@/utils/Constants';
import type { VaultResponse } from '@/utils/dist/shared/models/webapi';
import EncryptionUtility from '@/utils/EncryptionUtility';
import { storage } from '#imports';
/**
* Unlock page
@@ -25,7 +29,7 @@ const Unlock: React.FC = () => {
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const { showLoading, hideLoading } = useLoading();
const { showLoading, hideLoading, setIsInitialLoading } = useLoading();
useEffect(() => {
/**
@@ -37,10 +41,11 @@ const Unlock: React.FC = () => {
if (statusError !== null) {
await webApi.logout(statusError);
}
setIsInitialLoading(false);
};
checkStatus();
}, [webApi, authContext]);
}, [webApi, authContext, setIsInitialLoading]);
/**
* Handle submit

View File

@@ -1,5 +1,7 @@
/**
* Utility class for conversion operations.
* TODO: make this a shared utility class in root /shared/ folder so we can reuse it between browser extension/mobile app
* and possibly WASM client.
*/
class ConversionUtility {
/**
@@ -49,6 +51,15 @@ class ConversionUtility {
return html;
}
}
/**
* Normalize a username by converting it to lowercase and trimming whitespace.
* @param username The username to normalize.
* @returns The normalized username.
*/
public normalizeUsername(username: string): string {
return username.toLowerCase().trim();
}
}
export default new ConversionUtility();

View File

@@ -1,9 +1,8 @@
import srp from 'secure-remote-password/client'
import { WebApiService } from '@/utils/WebApiService';
import { LoginRequest, LoginResponse } from '@/utils/types/webapi/Login';
import { ValidateLoginRequest, ValidateLoginRequest2Fa, ValidateLoginResponse } from '@/utils/types/webapi/ValidateLogin';
import BadRequestResponse from '@/utils/types/webapi/BadRequestResponse';
import type { LoginRequest, LoginResponse, ValidateLoginRequest, ValidateLoginRequest2Fa, ValidateLoginResponse, BadRequestResponse } from '@/utils/dist/shared/models/webapi';
import { ApiAuthError } from '@/utils/types/errors/ApiAuthError';
import { WebApiService } from '@/utils/WebApiService';
/**
* Utility class for SRP authentication operations.

View File

@@ -6,7 +6,7 @@ export class AppInfo {
/**
* The current extension version. This should be updated with each release of the extension.
*/
public static readonly VERSION = '0.17.2';
public static readonly VERSION = '0.19.2';
/**
* The minimum supported AliasVault server (API) version. If the server version is below this, the

View File

@@ -1,9 +1,10 @@
import argon2 from 'argon2-browser/dist/argon2-bundled.min.js';
import { Email } from './types/webapi/Email';
import { EncryptionKey } from './types/EncryptionKey';
import { MailboxEmail } from './types/webapi/MailboxEmail';
import { Buffer } from 'buffer';
import argon2 from 'argon2-browser/dist/argon2-bundled.min.js';
import type { EncryptionKey } from '@/utils/dist/shared/models/vault';
import type { Email, MailboxEmail } from '@/utils/dist/shared/models/webapi';
/**
* Utility class for encryption operations including:
* - Argon2Id key derivation
@@ -118,6 +119,37 @@ export class EncryptionUtility {
return decoder.decode(decrypted);
}
/**
* Decrypts data using AES-GCM symmetric encryption with raw bytes input/output
*/
public static async symmetricDecryptBytes(encryptedBytes: Uint8Array, base64Key: string): Promise<Uint8Array> {
if (!encryptedBytes || encryptedBytes.length === 0) {
return encryptedBytes;
}
const key = await crypto.subtle.importKey(
"raw",
Uint8Array.from(atob(base64Key), c => c.charCodeAt(0)),
{
name: "AES-GCM",
length: 256,
},
false,
["decrypt"]
);
const iv = encryptedBytes.slice(0, 12);
const ciphertext = encryptedBytes.slice(12);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
ciphertext
);
return new Uint8Array(decrypted);
}
/**
* Generates a new RSA key pair for asymmetric encryption
*/
@@ -292,9 +324,13 @@ export class EncryptionUtility {
}
/**
* Decrypts an attachment based on the provided public/private key pairs and returns the decrypted bytes as a base64 string.
* Decrypts an attachment and returns the decrypted content as Uint8Array (raw bytes).
*/
public static async decryptAttachment(base64EncryptedAttachment: string, email: Email, encryptionKeys: EncryptionKey[]): Promise<string> {
public static async decryptAttachment(
encryptedBytes: Uint8Array,
email: Email,
encryptionKeys: EncryptionKey[]
): Promise<Uint8Array> {
try {
const encryptionKey = encryptionKeys.find(key => key.PublicKey === email.encryptionKey);
@@ -302,15 +338,17 @@ export class EncryptionUtility {
throw new Error('Encryption key not found');
}
// Decrypt symmetric key with asymmetric private key
// Decrypt the symmetric key using private key (returns raw bytes)
const symmetricKey = await EncryptionUtility.decryptWithPrivateKey(
email.encryptedSymmetricKey,
encryptionKey.PrivateKey
);
// Convert symmetric key to base64 string if symmetricDecrypt expects it
const symmetricKeyBase64 = Buffer.from(symmetricKey).toString('base64');
const encryptedBytesString = await EncryptionUtility.symmetricDecrypt(base64EncryptedAttachment, symmetricKeyBase64);
return encryptedBytesString;
// Decrypt the attachment using raw bytes
return await EncryptionUtility.symmetricDecryptBytes(encryptedBytes, symmetricKeyBase64);
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Failed to decrypt attachment');
}

View File

@@ -1,20 +0,0 @@
/**
* Setup the expanded mode.
*/
export function setupExpandedMode() : void {
/**
* This runs once when imported and checks if the popup was opened in expanded mode with unlimited width.
* If not, it sets the width to 350px to force the default popup to a fixed width.
* This is used to ensure the popup is always a fixed width, even if some content like email preview
* is too wide to fit in the default width. Some browsers like Firefox and Safari will then try to
* expand the popup to the width of the content, which can cause the popup to become too wide and bad UX.
*
* You can test this by opening the popup and then clicking on the email preview. If the popup width does
* not change, it works. Then if you expand/popout the extension, the content of the page should adjust
* to the new width of the resizable popup.
*/
const urlParams = new URLSearchParams(window.location.search);
if (!urlParams.get('expanded')) {
document.documentElement.classList.add('max-w-[350px]');
}
}

View File

@@ -1,8 +1,6 @@
import initSqlJs, { Database } from 'sql.js';
import { Credential } from './types/Credential';
import { EncryptionKey } from './types/EncryptionKey';
import { TotpCode } from './types/TotpCode';
import { PasswordSettings } from './types/PasswordSettings';
import type { Credential, EncryptionKey, PasswordSettings, TotpCode } from '@/utils/dist/shared/models/vault';
/**
* Placeholder base64 image for credentials without a logo.
@@ -14,6 +12,7 @@ const placeholderBase64 = 'UklGRjoEAABXRUJQVlA4IC4EAAAwFwCdASqAAIAAPpFCm0olo6Ihp
*/
export class SqliteClient {
private db: Database | null = null;
private isInTransaction: boolean = false;
/**
* Initialize the SQLite database from a base64 string
@@ -45,6 +44,69 @@ export class SqliteClient {
}
}
/**
* Begin a new transaction
*/
public beginTransaction(): void {
if (!this.db) {
throw new Error('Database not initialized');
}
if (this.isInTransaction) {
throw new Error('Transaction already in progress');
}
try {
this.db.run('BEGIN TRANSACTION');
this.isInTransaction = true;
} catch (error) {
console.error('Error beginning transaction:', error);
throw error;
}
}
/**
* Commit the current transaction and persist changes to the vault
*/
public async commitTransaction(): Promise<void> {
if (!this.db) {
throw new Error('Database not initialized');
}
if (!this.isInTransaction) {
throw new Error('No transaction in progress');
}
try {
this.db.run('COMMIT');
this.isInTransaction = false;
} catch (error) {
console.error('Error committing transaction:', error);
throw error;
}
}
/**
* Rollback the current transaction
*/
public rollbackTransaction(): void {
if (!this.db) {
throw new Error('Database not initialized');
}
if (!this.isInTransaction) {
throw new Error('No transaction in progress');
}
try {
this.db.run('ROLLBACK');
this.isInTransaction = false;
} catch (error) {
console.error('Error rolling back transaction:', error);
throw error;
}
}
/**
* Export the SQLite database to a base64 string
* @returns Base64 encoded string of the database
@@ -279,9 +341,41 @@ export class SqliteClient {
/**
* Get the default email domain from the database.
* @param privateEmailDomains - Array of private email domains
* @param publicEmailDomains - Array of public email domains
* @returns The default email domain or null if no valid domain is found
*/
public getDefaultEmailDomain(): string {
return this.getSetting('DefaultEmailDomain');
public getDefaultEmailDomain(privateEmailDomains: string[], publicEmailDomains: string[]): string | null {
const defaultEmailDomain = this.getSetting('DefaultEmailDomain');
/**
* Check if a domain is valid.
*/
const isValidDomain = (domain: string): boolean => {
return Boolean(domain &&
domain !== 'DISABLED.TLD' &&
(privateEmailDomains.includes(domain) || publicEmailDomains.includes(domain)));
};
// First check if the default domain that is configured in the vault is still valid.
if (defaultEmailDomain && isValidDomain(defaultEmailDomain)) {
return defaultEmailDomain;
}
// If default domain is not valid, fall back to first available private domain.
const firstPrivate = privateEmailDomains.find(isValidDomain);
if (firstPrivate) {
return firstPrivate;
}
// Return first valid public domain if no private domains are available.
const firstPublic = publicEmailDomains.find(isValidDomain);
if (firstPublic) {
return firstPublic;
}
// Return null if no valid domains are found
return null;
}
/**
@@ -321,15 +415,15 @@ export class SqliteClient {
/**
* Create a new credential with associated entities
* @param credential The credential object to insert
* @returns The number of rows modified
* @returns The ID of the created credential
*/
public createCredential(credential: Credential): number {
public async createCredential(credential: Credential): Promise<string> {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
this.db.run('BEGIN TRANSACTION');
this.beginTransaction();
// 1. Insert Service
let logoData = null;
@@ -417,11 +511,11 @@ export class SqliteClient {
]);
}
this.db.run('COMMIT');
return 1;
await this.commitTransaction();
return credentialId;
} catch (error) {
this.db.run('ROLLBACK');
this.rollbackTransaction();
console.error('Error creating credential:', error);
throw error;
}
@@ -502,6 +596,225 @@ export class SqliteClient {
}
}
/**
* Delete a credential by ID
* @param credentialId - The ID of the credential to delete
* @returns The number of rows deleted
*/
public async deleteCredentialById(credentialId: string): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
this.beginTransaction();
const currentDateTime = new Date().toISOString()
.replace('T', ' ')
.replace('Z', '')
.substring(0, 23);
// Update the credential, alias, and service to be deleted
const query = `
UPDATE Credentials
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = ?`;
const aliasQuery = `
UPDATE Aliases
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = (
SELECT AliasId
FROM Credentials
WHERE Id = ?
)`;
const serviceQuery = `
UPDATE Services
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = (
SELECT ServiceId
FROM Credentials
WHERE Id = ?
)`;
const results = this.executeUpdate(query, [currentDateTime, credentialId]);
this.executeUpdate(aliasQuery, [currentDateTime, credentialId]);
this.executeUpdate(serviceQuery, [currentDateTime, credentialId]);
await this.commitTransaction();
return results;
} catch (error) {
this.rollbackTransaction();
console.error('Error deleting credential:', error);
throw error;
}
}
/**
* Update an existing credential with associated entities
* @param credential The credential object to update
* @returns The number of rows modified
*/
public async updateCredentialById(credential: Credential): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
this.beginTransaction();
const currentDateTime = new Date().toISOString()
.replace('T', ' ')
.replace('Z', '')
.substring(0, 23);
// Get existing credential to compare changes
const existingCredential = this.getCredentialById(credential.Id);
if (!existingCredential) {
throw new Error('Credential not found');
}
// 1. Update Service
const serviceQuery = `
UPDATE Services
SET Name = ?,
Url = ?,
Logo = COALESCE(?, Logo),
UpdatedAt = ?
WHERE Id = (
SELECT ServiceId
FROM Credentials
WHERE Id = ?
)`;
let logoData = null;
try {
if (credential.Logo) {
// Handle object-like array conversion
if (typeof credential.Logo === 'object' && !ArrayBuffer.isView(credential.Logo)) {
const values = Object.values(credential.Logo);
logoData = new Uint8Array(values);
// Handle existing array types
} else if (Array.isArray(credential.Logo) || credential.Logo instanceof ArrayBuffer || credential.Logo instanceof Uint8Array) {
logoData = new Uint8Array(credential.Logo);
}
}
} catch (error) {
console.warn('Failed to convert logo to Uint8Array:', error);
logoData = null;
}
this.executeUpdate(serviceQuery, [
credential.ServiceName,
credential.ServiceUrl ?? null,
logoData,
currentDateTime,
credential.Id
]);
// 2. Update Alias
const aliasQuery = `
UPDATE Aliases
SET FirstName = ?,
LastName = ?,
NickName = ?,
BirthDate = ?,
Gender = ?,
Email = ?,
UpdatedAt = ?
WHERE Id = (
SELECT AliasId
FROM Credentials
WHERE Id = ?
)`;
// Only update BirthDate if it's actually different (accounting for format differences)
let birthDate = credential.Alias.BirthDate;
if (birthDate && existingCredential.Alias.BirthDate) {
const newDate = new Date(birthDate);
const existingDate = new Date(existingCredential.Alias.BirthDate);
if (newDate.getTime() === existingDate.getTime()) {
birthDate = existingCredential.Alias.BirthDate;
}
}
this.executeUpdate(aliasQuery, [
credential.Alias.FirstName ?? null,
credential.Alias.LastName ?? null,
credential.Alias.NickName ?? null,
birthDate ?? null,
credential.Alias.Gender ?? null,
credential.Alias.Email ?? null,
currentDateTime,
credential.Id
]);
// 3. Update Credential
const credentialQuery = `
UPDATE Credentials
SET Username = ?,
Notes = ?,
UpdatedAt = ?
WHERE Id = ?`;
this.executeUpdate(credentialQuery, [
credential.Username ?? null,
credential.Notes ?? null,
currentDateTime,
credential.Id
]);
// 4. Update Password if changed
if (credential.Password !== existingCredential.Password) {
// Check if a password record already exists for this credential, if not, then create one.
const passwordRecordExistsQuery = `
SELECT Id
FROM Passwords
WHERE CredentialId = ?`;
const passwordResults = this.executeQuery(passwordRecordExistsQuery, [credential.Id]);
if (passwordResults.length === 0) {
// Create a new password record
const passwordQuery = `
INSERT INTO Passwords (Id, Value, CredentialId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?)`;
this.executeUpdate(passwordQuery, [
crypto.randomUUID().toUpperCase(),
credential.Password,
credential.Id,
currentDateTime,
currentDateTime,
0
]);
} else {
// Update the existing password record
const passwordQuery = `
UPDATE Passwords
SET Value = ?, UpdatedAt = ?
WHERE CredentialId = ?`;
this.executeUpdate(passwordQuery, [
credential.Password,
currentDateTime,
credential.Id
]);
}
}
await this.commitTransaction();
return 1;
} catch (error) {
this.rollbackTransaction();
console.error('Error updating credential:', error);
throw error;
}
}
/**
* Convert binary data to a base64 encoded image source.
*/

View File

@@ -1,6 +1,7 @@
import type { StatusResponse, VaultResponse } from '@/utils/dist/shared/models/webapi';
import { AppInfo } from "./AppInfo";
import { StatusResponse } from "./types/webapi/StatusResponse";
import { VaultResponse } from "./types/webapi/VaultResponse";
import { storage } from '#imports';
type RequestInit = globalThis.RequestInit;
@@ -42,7 +43,8 @@ export class WebApiService {
public async authFetch<T>(
endpoint: string,
options: RequestInit = {},
parseJson: boolean = true
parseJson: boolean = true,
throwOnError: boolean = true
): Promise<T> {
const headers = new Headers(options.headers ?? {});
@@ -80,7 +82,7 @@ export class WebApiService {
}
}
if (!response.ok) {
if (!response.ok && throwOnError) {
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -162,9 +164,9 @@ export class WebApiService {
}
/**
* Issue GET request to the API expecting a file download and return it as a Base64 string.
* Issue GET request to the API expecting a file download and return it as raw bytes.
*/
public async downloadBlobAndConvertToBase64(endpoint: string): Promise<string> {
public async downloadBlob(endpoint: string): Promise<Uint8Array> {
try {
const response = await this.authFetch<Response>(endpoint, {
method: 'GET',
@@ -173,11 +175,11 @@ export class WebApiService {
}
}, false);
// Ensure we get the response as a blob
const blob = await response.blob();
return await this.blobToBase64(blob);
// Get the response as an ArrayBuffer
const arrayBuffer = await response.arrayBuffer();
return new Uint8Array(arrayBuffer);
} catch (error) {
console.error('Error fetching and converting to Base64:', error);
console.error('Error downloading blob:', error);
throw error;
}
}

View File

@@ -1,6 +1,7 @@
import { AppInfo } from '../AppInfo';
import { describe, it, expect } from 'vitest';
import { AppInfo } from '../AppInfo';
describe('AppInfo', () => {
describe('isVersionSupported', () => {
it('should support exact version match', () => {

View File

@@ -5,5 +5,5 @@ This folder contains the output of the shared `identity-generator` module from t
**Do not edit any of these files manually.**
To make changes:
1. Update the source files in `packages/identity-generator/src`
2. Run the `build-and-distribute.sh` script at the root of the project to regenerate the outputs and copy them here.
1. Update the source files in the `/shared/identity-generator/src` directory
2. Run the `build.sh` script in the module directory to regenerate the outputs and copy them here.

View File

@@ -16,6 +16,89 @@ type Identity = {
nickName: string;
};
interface IIdentityGenerator {
generateRandomIdentity(): Identity;
}
/**
* Base identity generator.
*/
declare abstract class IdentityGenerator implements IIdentityGenerator {
protected firstNamesMale: string[];
protected firstNamesFemale: string[];
protected lastNames: string[];
private readonly random;
/**
* Constructor.
*/
constructor();
protected abstract getFirstNamesMaleJson(): string[];
protected abstract getFirstNamesFemaleJson(): string[];
protected abstract getLastNamesJson(): string[];
/**
* Generate a random date of birth.
*/
protected generateRandomDateOfBirth(): Date;
/**
* Generate a random identity.
*/
generateRandomIdentity(): Identity;
}
/**
* Identity generator for English language using English word dictionaries.
*/
declare class IdentityGeneratorEn extends IdentityGenerator {
/**
* Get the male first names.
*/
protected getFirstNamesMaleJson(): string[];
/**
* Get the female first names.
*/
protected getFirstNamesFemaleJson(): string[];
/**
* Get the last names.
*/
protected getLastNamesJson(): string[];
}
/**
* Identity generator for Dutch language using Dutch word dictionaries.
*/
declare class IdentityGeneratorNl extends IdentityGenerator {
/**
* Get the male first names.
*/
protected getFirstNamesMaleJson(): string[];
/**
* Get the female first names.
*/
protected getFirstNamesFemaleJson(): string[];
/**
* Get the last names.
*/
protected getLastNamesJson(): string[];
}
/**
* Helper utilities for identity generation that can be used by multiple client applications.
*/
declare class IdentityHelperUtils {
/**
* Normalize a birth date for display.
*/
static normalizeBirthDateForDisplay(birthDate: string | undefined): string;
/**
* Normalize a birth date for database.
*/
static normalizeBirthDateForDb(input: string | undefined): string;
/**
* Check if a birth date is valid.
*/
static isValidBirthDate(input: string | undefined): boolean;
}
/**
* Generate a username or email prefix.
*/
@@ -49,87 +132,18 @@ declare class UsernameEmailGenerator {
private getSecureRandom;
}
interface IIdentityGenerator {
generateRandomIdentity(): Promise<Identity>;
}
/**
* Creates a new identity generator based on the language.
* @param language - The language to use for generating the identity (e.g. "en", "nl").
* @returns A new identity generator instance.
*/
declare const CreateIdentityGenerator: (language: string) => IIdentityGenerator;
/**
* Base identity generator.
* Creates a new username email generator. This is used by the .NET Blazor WASM JSinterop
* as it cannot create instances of classes directly, it has to use a factory method.
* @returns A new username email generator instance.
*/
declare abstract class BaseIdentityGenerator implements IIdentityGenerator {
protected firstNamesMale: string[];
protected firstNamesFemale: string[];
protected lastNames: string[];
private readonly random;
/**
* Constructor.
*/
constructor();
protected abstract getFirstNamesMaleJson(): string[];
protected abstract getFirstNamesFemaleJson(): string[];
protected abstract getLastNamesJson(): string[];
/**
* Generate a random date of birth.
*/
protected generateRandomDateOfBirth(): Date;
/**
* Generate a random identity.
*/
generateRandomIdentity(): Promise<Identity>;
}
declare const CreateUsernameEmailGenerator: () => UsernameEmailGenerator;
/**
* Identity generator for English language using English word dictionaries.
*/
declare class IdentityGeneratorEn extends BaseIdentityGenerator {
/**
* Get the male first names.
*/
protected getFirstNamesMaleJson(): string[];
/**
* Get the female first names.
*/
protected getFirstNamesFemaleJson(): string[];
/**
* Get the last names.
*/
protected getLastNamesJson(): string[];
}
/**
* Identity generator for Dutch language using Dutch word dictionaries.
*/
declare class IdentityGeneratorNl extends BaseIdentityGenerator {
/**
* Get the male first names.
*/
protected getFirstNamesMaleJson(): string[];
/**
* Get the female first names.
*/
protected getFirstNamesFemaleJson(): string[];
/**
* Get the last names.
*/
protected getLastNamesJson(): string[];
}
/**
* Helper utilities for identity generation that can be used by multiple client applications.
*/
declare class IdentityHelperUtils {
/**
* Normalize a birth date for display.
*/
static normalizeBirthDateForDisplay(birthDate: string | undefined): string;
/**
* Normalize a birth date for database.
*/
static normalizeBirthDateForDb(input: string | undefined): string;
/**
* Check if a birth date is valid.
*/
static isValidBirthDate(input: string | undefined): boolean;
}
export { BaseIdentityGenerator, Gender, type Identity, IdentityGeneratorEn, IdentityGeneratorNl, IdentityHelperUtils, UsernameEmailGenerator };
export { CreateIdentityGenerator, CreateUsernameEmailGenerator, Gender, type Identity, IdentityGenerator, IdentityGeneratorEn, IdentityGeneratorNl, IdentityHelperUtils, UsernameEmailGenerator };

View File

@@ -1,3 +1,6 @@
// <auto-generated>
// This file was automatically generated. Do not edit manually.
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -20,8 +23,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
// src/index.ts
var index_exports = {};
__export(index_exports, {
BaseIdentityGenerator: () => BaseIdentityGenerator,
CreateIdentityGenerator: () => CreateIdentityGenerator,
CreateUsernameEmailGenerator: () => CreateUsernameEmailGenerator,
Gender: () => Gender,
IdentityGenerator: () => IdentityGenerator,
IdentityGeneratorEn: () => IdentityGeneratorEn,
IdentityGeneratorNl: () => IdentityGeneratorNl,
IdentityHelperUtils: () => IdentityHelperUtils,
@@ -29,6 +34,14 @@ __export(index_exports, {
});
module.exports = __toCommonJS(index_exports);
// src/types/Gender.ts
var Gender = /* @__PURE__ */ ((Gender2) => {
Gender2["Male"] = "Male";
Gender2["Female"] = "Female";
Gender2["Other"] = "Other";
return Gender2;
})(Gender || {});
// src/utils/UsernameEmailGenerator.ts
var _UsernameEmailGenerator = class _UsernameEmailGenerator {
constructor() {
@@ -52,6 +65,9 @@ var _UsernameEmailGenerator = class _UsernameEmailGenerator {
*/
generateEmailPrefix(identity) {
const parts = [];
if (typeof identity.birthDate === "string") {
identity.birthDate = new Date(identity.birthDate);
}
switch (this.getSecureRandom(4)) {
case 0:
parts.push(identity.firstName.substring(0, 1).toLowerCase() + identity.lastName.toLowerCase());
@@ -127,16 +143,8 @@ _UsernameEmailGenerator.MIN_LENGTH = 6;
_UsernameEmailGenerator.MAX_LENGTH = 20;
var UsernameEmailGenerator = _UsernameEmailGenerator;
// src/types/Gender.ts
var Gender = /* @__PURE__ */ ((Gender2) => {
Gender2["Male"] = "Male";
Gender2["Female"] = "Female";
Gender2["Other"] = "Other";
return Gender2;
})(Gender || {});
// src/implementations/base/BaseIdentityGenerator.ts
var BaseIdentityGenerator = class {
// src/implementations/base/IdentityGenerator.ts
var IdentityGenerator = class {
/**
* Constructor.
*/
@@ -164,7 +172,7 @@ var BaseIdentityGenerator = class {
/**
* Generate a random identity.
*/
async generateRandomIdentity() {
generateRandomIdentity() {
const identity = {
firstName: "",
lastName: "",
@@ -979,7 +987,7 @@ var lastnames_default = [
];
// src/implementations/IdentityGeneratorEn.ts
var IdentityGeneratorEn = class extends BaseIdentityGenerator {
var IdentityGeneratorEn = class extends IdentityGenerator {
/**
* Get the male first names.
*/
@@ -1643,7 +1651,7 @@ var lastnames_default2 = [
];
// src/implementations/IdentityGeneratorNl.ts
var IdentityGeneratorNl = class extends BaseIdentityGenerator {
var IdentityGeneratorNl = class extends IdentityGenerator {
/**
* Get the male first names.
*/
@@ -1712,10 +1720,28 @@ var IdentityHelperUtils = class {
return yearValid;
}
};
// src/factories/IdentityGeneratorFactory.ts
var CreateIdentityGenerator = (language) => {
switch (language) {
case "en":
return new IdentityGeneratorEn();
case "nl":
return new IdentityGeneratorNl();
}
throw new Error(`Unsupported language: ${language}`);
};
// src/factories/UsernameEmailGeneratorFactory.ts
var CreateUsernameEmailGenerator = () => {
return new UsernameEmailGenerator();
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
BaseIdentityGenerator,
CreateIdentityGenerator,
CreateUsernameEmailGenerator,
Gender,
IdentityGenerator,
IdentityGeneratorEn,
IdentityGeneratorNl,
IdentityHelperUtils,

View File

@@ -1,3 +1,15 @@
// <auto-generated>
// This file was automatically generated. Do not edit manually.
// src/types/Gender.ts
var Gender = /* @__PURE__ */ ((Gender2) => {
Gender2["Male"] = "Male";
Gender2["Female"] = "Female";
Gender2["Other"] = "Other";
return Gender2;
})(Gender || {});
// src/utils/UsernameEmailGenerator.ts
var _UsernameEmailGenerator = class _UsernameEmailGenerator {
constructor() {
@@ -21,6 +33,9 @@ var _UsernameEmailGenerator = class _UsernameEmailGenerator {
*/
generateEmailPrefix(identity) {
const parts = [];
if (typeof identity.birthDate === "string") {
identity.birthDate = new Date(identity.birthDate);
}
switch (this.getSecureRandom(4)) {
case 0:
parts.push(identity.firstName.substring(0, 1).toLowerCase() + identity.lastName.toLowerCase());
@@ -96,16 +111,8 @@ _UsernameEmailGenerator.MIN_LENGTH = 6;
_UsernameEmailGenerator.MAX_LENGTH = 20;
var UsernameEmailGenerator = _UsernameEmailGenerator;
// src/types/Gender.ts
var Gender = /* @__PURE__ */ ((Gender2) => {
Gender2["Male"] = "Male";
Gender2["Female"] = "Female";
Gender2["Other"] = "Other";
return Gender2;
})(Gender || {});
// src/implementations/base/BaseIdentityGenerator.ts
var BaseIdentityGenerator = class {
// src/implementations/base/IdentityGenerator.ts
var IdentityGenerator = class {
/**
* Constructor.
*/
@@ -133,7 +140,7 @@ var BaseIdentityGenerator = class {
/**
* Generate a random identity.
*/
async generateRandomIdentity() {
generateRandomIdentity() {
const identity = {
firstName: "",
lastName: "",
@@ -948,7 +955,7 @@ var lastnames_default = [
];
// src/implementations/IdentityGeneratorEn.ts
var IdentityGeneratorEn = class extends BaseIdentityGenerator {
var IdentityGeneratorEn = class extends IdentityGenerator {
/**
* Get the male first names.
*/
@@ -1612,7 +1619,7 @@ var lastnames_default2 = [
];
// src/implementations/IdentityGeneratorNl.ts
var IdentityGeneratorNl = class extends BaseIdentityGenerator {
var IdentityGeneratorNl = class extends IdentityGenerator {
/**
* Get the male first names.
*/
@@ -1681,9 +1688,27 @@ var IdentityHelperUtils = class {
return yearValid;
}
};
// src/factories/IdentityGeneratorFactory.ts
var CreateIdentityGenerator = (language) => {
switch (language) {
case "en":
return new IdentityGeneratorEn();
case "nl":
return new IdentityGeneratorNl();
}
throw new Error(`Unsupported language: ${language}`);
};
// src/factories/UsernameEmailGeneratorFactory.ts
var CreateUsernameEmailGenerator = () => {
return new UsernameEmailGenerator();
};
export {
BaseIdentityGenerator,
CreateIdentityGenerator,
CreateUsernameEmailGenerator,
Gender,
IdentityGenerator,
IdentityGeneratorEn,
IdentityGeneratorNl,
IdentityHelperUtils,

View File

@@ -0,0 +1,9 @@
# ⚠️ Auto-Generated Files
This folder contains the output of the shared `models` module from the `/shared` directory in the AliasVault project.
**Do not edit any of these files manually.**
To make changes:
1. Update the source files in the `/shared/models/src` directory
2. Run the `build.sh` script in the module directory to regenerate the outputs and copy them here.

View File

@@ -0,0 +1,18 @@
type VaultMetadata = {
publicEmailDomains: string[];
privateEmailDomains: string[];
vaultRevisionNumber: number;
};
/**
* These parameters for deriving encryption key from plain text password. These are stored
* as metadata in the vault upon initial login, and are used to derive the encryption key
* from the plain text password in the unlock screen.
*/
type EncryptionKeyDerivationParams = {
encryptionType: string;
encryptionSettings: string;
salt: string;
};
export type { EncryptionKeyDerivationParams, VaultMetadata };

View File

@@ -0,0 +1,3 @@
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map

View File

@@ -0,0 +1,80 @@
/**
* Encryption key SQLite database type.
*/
type EncryptionKey = {
Id: string;
PublicKey: string;
PrivateKey: string;
IsPrimary: boolean;
};
/**
* Settings for password generation stored in SQLite database settings table as string.
*/
type PasswordSettings = {
/**
* The length of the password.
*/
Length: number;
/**
* Whether to use lowercase letters.
*/
UseLowercase: boolean;
/**
* Whether to use uppercase letters.
*/
UseUppercase: boolean;
/**
* Whether to use numbers.
*/
UseNumbers: boolean;
/**
* Whether to use special characters.
*/
UseSpecialChars: boolean;
/**
* Whether to use non-ambiguous characters.
*/
UseNonAmbiguousChars: boolean;
};
/**
* TotpCode SQLite database type.
*/
type TotpCode = {
/** The ID of the TOTP code */
Id: string;
/** The name of the TOTP code */
Name: string;
/** The secret key for the TOTP code */
SecretKey: string;
/** The credential ID this TOTP code belongs to */
CredentialId: string;
};
/**
* Credential SQLite database type.
*/
type Credential = {
Id: string;
Username?: string;
Password: string;
ServiceName: string;
ServiceUrl?: string;
Logo?: Uint8Array | number[];
Notes?: string;
Alias: Alias;
};
/**
* Alias SQLite database type.
*/
type Alias = {
FirstName?: string;
LastName?: string;
NickName?: string;
BirthDate: string;
Gender?: string;
Email?: string;
};
export type { Alias, Credential, EncryptionKey, PasswordSettings, TotpCode };

View File

@@ -0,0 +1,3 @@
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map

View File

@@ -0,0 +1,382 @@
/**
* Represents the error response returned by the API.
*/
type ApiErrorResponse = {
/**
* The main error message.
*/
message: string;
/**
* The error code associated with this error.
*/
code: string;
/**
* Additional details about the error.
*/
details: Record<string, unknown>;
/**
* The HTTP status code associated with this error.
*/
statusCode: number;
/**
* The timestamp when the error occurred.
*/
timestamp: string;
};
/**
* Vault type.
*/
type Vault = {
blob: string;
createdAt: string;
credentialsCount: number;
currentRevisionNumber: number;
emailAddressList: string[];
privateEmailDomainList: string[];
publicEmailDomainList: string[];
encryptionPublicKey: string;
updatedAt: string;
username: string;
version: string;
client: string;
};
/**
* Vault response type.
*/
type VaultResponse = {
status: number;
vault: Vault;
};
/**
* Vault post response type returned after uploading a new vault to the server.
*/
type VaultPostResponse = {
status: number;
newRevisionNumber: number;
};
/**
* Status response type.
*/
type StatusResponse = {
clientVersionSupported: boolean;
serverVersion: string;
vaultRevision: number;
};
/**
* Login request type.
*/
type LoginRequest = {
username: string;
};
/**
* Login response type.
*/
type LoginResponse = {
salt: string;
serverEphemeral: string;
encryptionType: string;
encryptionSettings: string;
};
/**
* Validate login request type.
*/
type ValidateLoginRequest = {
username: string;
rememberMe: boolean;
clientPublicEphemeral: string;
clientSessionProof: string;
};
/**
* Validate login request type for 2FA.
*/
type ValidateLoginRequest2Fa = {
username: string;
code2Fa: number;
rememberMe: boolean;
clientPublicEphemeral: string;
clientSessionProof: string;
};
/**
* Validate login response type.
*/
type ValidateLoginResponse = {
requiresTwoFactor: boolean;
token?: {
token: string;
refreshToken: string;
};
serverSessionProof: string;
};
type MailboxEmail = {
/** The preview of the email message */
messagePreview: string;
/** Indicates whether the email has attachments */
hasAttachments: boolean;
/** The ID of the email */
id: number;
/** The subject of the email */
subject: string;
/** The display name of the sender */
fromDisplay: string;
/** The domain of the sender's email address */
fromDomain: string;
/** The local part of the sender's email address */
fromLocal: string;
/** The domain of the recipient's email address */
toDomain: string;
/** The local part of the recipient's email address */
toLocal: string;
/** The date of the email */
date: string;
/** The system date of the email */
dateSystem: string;
/** The number of seconds ago the email was received */
secondsAgo: number;
/**
* The encrypted symmetric key which was used to encrypt the email message.
* This key is encrypted with the public key of the user.
*/
encryptedSymmetricKey: string;
/** The public key of the user used to encrypt the symmetric key */
encryptionKey: string;
};
/**
* Mailbox bulk request type.
*/
type MailboxBulkRequest = {
addresses: string[];
page: number;
pageSize: number;
};
/**
* Mailbox bulk response type.
*/
type MailboxBulkResponse = {
addresses: string[];
currentPage: number;
pageSize: number;
totalRecords: number;
mails: MailboxEmail[];
};
/**
* Email attachment type.
*/
type EmailAttachment = {
/** The ID of the attachment */
id: number;
/** The ID of the email the attachment belongs to */
emailId: number;
/** The filename of the attachment */
filename: string;
/** The MIME type of the attachment */
mimeType: string;
/** The size of the attachment in bytes */
filesize: number;
};
type Email = {
/** The body of the email message */
messageHtml: string;
/** The plain text body of the email message */
messagePlain: string;
/** The ID of the email */
id: number;
/** The subject of the email */
subject: string;
/** The display name of the sender */
fromDisplay: string;
/** The domain of the sender's email address */
fromDomain: string;
/** The local part of the sender's email address */
fromLocal: string;
/** The domain of the recipient's email address */
toDomain: string;
/** The local part of the recipient's email address */
toLocal: string;
/** The date of the email */
date: string;
/** The system date of the email */
dateSystem: string;
/** The number of seconds ago the email was received */
secondsAgo: number;
/**
* The encrypted symmetric key which was used to encrypt the email message.
* This key is encrypted with the public key of the user.
*/
encryptedSymmetricKey: string;
/** The public key of the user used to encrypt the symmetric key */
encryptionKey: string;
/** The attachments of the email */
attachments: EmailAttachment[];
};
/**
* Auth Log model.
*/
type AuthLogModel = {
/**
* Gets or sets the primary key for the auth log entry.
*/
id: number;
/**
* Gets or sets the timestamp of the auth log entry.
*/
timestamp: string;
/**
* Gets or sets the type of authentication event.
*/
eventType: number;
/**
* Gets or sets the username associated with the auth log entry.
*/
username: string;
/**
* Gets or sets the IP address from which the authentication attempt was made.
*/
ipAddress: string;
/**
* Gets or sets the user agent string of the device used for the authentication attempt.
*/
userAgent: string;
/**
* Gets or sets the client application name and version.
*/
client: string;
/**
* Gets or sets a value indicating whether the authentication attempt was successful.
*/
isSuccess: boolean;
};
type RefreshToken = {
/**
* Gets or sets the unique identifier for the refresh token.
*/
id: string;
/**
* Gets or sets the device identifier associated with the refresh token.
*/
deviceIdentifier: string;
/**
* Gets or sets the expiration date of the refresh token.
*/
expireDate: string;
/**
* Gets or sets the creation date of the refresh token.
*/
createdAt: string;
};
type FaviconExtractModel = {
image: string | null;
};
/**
* Represents a delete account initiate response.
*/
type DeleteAccountInitiateRequest = {
username: string;
};
/**
* Represents a delete account initiate response.
*/
type DeleteAccountInitiateResponse = {
salt: string;
serverEphemeral: string;
encryptionType: string;
encryptionSettings: string;
};
/**
* Represents a delete account request.
*/
type DeleteAccountRequest = {
username: string;
clientPublicEphemeral: string;
clientSessionProof: string;
};
/**
* Represents a password change initiate response.
*/
type PasswordChangeInitiateResponse = {
salt: string;
serverEphemeral: string;
encryptionType: string;
encryptionSettings: string;
};
/**
* Represents a request to change the users password including a new vault that is encrypted with the new password.
*/
type VaultPasswordChangeRequest = Vault & {
currentClientPublicEphemeral: string;
currentClientSessionProof: string;
newPasswordSalt: string;
newPasswordVerifier: string;
};
type BadRequestResponse = {
type: string;
title: string;
status: number;
errors: Record<string, string[]>;
traceId: string;
};
/**
* Represents the type of authentication event.
*/
declare enum AuthEventType {
/**
* Represents a standard login attempt.
*/
Login = 1,
/**
* Represents a two-factor authentication attempt.
*/
TwoFactorAuthentication = 2,
/**
* Represents a user logout event.
*/
Logout = 3,
/**
* Represents JWT access token refresh event issued by client to API.
*/
TokenRefresh = 10,
/**
* Represents a password reset event.
*/
PasswordReset = 20,
/**
* Represents a password change event.
*/
PasswordChange = 21,
/**
* Represents enabling two-factor authentication in settings.
*/
TwoFactorAuthEnable = 22,
/**
* Represents disabling two-factor authentication in settings.
*/
TwoFactorAuthDisable = 23,
/**
* Represents a user registration event.
*/
Register = 30,
/**
* Represents a user account deletion event.
*/
AccountDeletion = 99
}
export { type ApiErrorResponse, AuthEventType, type AuthLogModel, type BadRequestResponse, type DeleteAccountInitiateRequest, type DeleteAccountInitiateResponse, type DeleteAccountRequest, type Email, type EmailAttachment, type FaviconExtractModel, type LoginRequest, type LoginResponse, type MailboxBulkRequest, type MailboxBulkResponse, type MailboxEmail, type PasswordChangeInitiateResponse, type RefreshToken, type StatusResponse, type ValidateLoginRequest, type ValidateLoginRequest2Fa, type ValidateLoginResponse, type Vault, type VaultPasswordChangeRequest, type VaultPostResponse, type VaultResponse };

View File

@@ -0,0 +1,22 @@
// <auto-generated>
// This file was automatically generated. Do not edit manually.
// src/webapi/AuthEventType.ts
var AuthEventType = /* @__PURE__ */ ((AuthEventType2) => {
AuthEventType2[AuthEventType2["Login"] = 1] = "Login";
AuthEventType2[AuthEventType2["TwoFactorAuthentication"] = 2] = "TwoFactorAuthentication";
AuthEventType2[AuthEventType2["Logout"] = 3] = "Logout";
AuthEventType2[AuthEventType2["TokenRefresh"] = 10] = "TokenRefresh";
AuthEventType2[AuthEventType2["PasswordReset"] = 20] = "PasswordReset";
AuthEventType2[AuthEventType2["PasswordChange"] = 21] = "PasswordChange";
AuthEventType2[AuthEventType2["TwoFactorAuthEnable"] = 22] = "TwoFactorAuthEnable";
AuthEventType2[AuthEventType2["TwoFactorAuthDisable"] = 23] = "TwoFactorAuthDisable";
AuthEventType2[AuthEventType2["Register"] = 30] = "Register";
AuthEventType2[AuthEventType2["AccountDeletion"] = 99] = "AccountDeletion";
return AuthEventType2;
})(AuthEventType || {});
export { AuthEventType };
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map

View File

@@ -5,5 +5,5 @@ This folder contains the output of the shared `password-generator` module from t
**Do not edit any of these files manually.**
To make changes:
1. Update the source files in `packages/password-generator/src`
2. Run the `build-and-distribute.sh` script at the root of the project to regenerate the outputs and copy them here.
1. Update the source files in the `/shared/password-generator/src` directory
2. Run the `build.sh` script in the module directory to regenerate the outputs and copy them here.

View File

@@ -110,4 +110,11 @@ declare class PasswordGenerator {
private addCharacterFromSet;
}
export { PasswordGenerator, type PasswordSettings };
/**
* Creates a new password generator.
* @param settings - The settings for the password generator.
* @returns A new password generator instance.
*/
declare const CreatePasswordGenerator: (settings: PasswordSettings) => PasswordGenerator;
export { CreatePasswordGenerator, PasswordGenerator, type PasswordSettings };

View File

@@ -1,3 +1,6 @@
// <auto-generated>
// This file was automatically generated. Do not edit manually.
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -20,6 +23,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
// src/index.ts
var index_exports = {};
__export(index_exports, {
CreatePasswordGenerator: () => CreatePasswordGenerator,
PasswordGenerator: () => PasswordGenerator
});
module.exports = __toCommonJS(index_exports);
@@ -230,8 +234,14 @@ var PasswordGenerator = class {
return password.substring(0, pos) + char + password.substring(pos + 1);
}
};
// src/factories/PasswordGeneratorFactory.ts
var CreatePasswordGenerator = (settings) => {
return new PasswordGenerator(settings);
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
CreatePasswordGenerator,
PasswordGenerator
});
//# sourceMappingURL=index.js.map

View File

@@ -1,3 +1,7 @@
// <auto-generated>
// This file was automatically generated. Do not edit manually.
// src/utils/PasswordGenerator.ts
var PasswordGenerator = class {
/**
@@ -204,7 +208,13 @@ var PasswordGenerator = class {
return password.substring(0, pos) + char + password.substring(pos + 1);
}
};
// src/factories/PasswordGeneratorFactory.ts
var CreatePasswordGenerator = (settings) => {
return new PasswordGenerator(settings);
};
export {
CreatePasswordGenerator,
PasswordGenerator
};
//# sourceMappingURL=index.mjs.map

View File

@@ -1,5 +1,5 @@
import { FormFields } from "./types/FormFields";
import { CombinedFieldPatterns, CombinedGenderOptionPatterns, CombinedStopWords } from "./FieldPatterns";
import { FormFields } from "./types/FormFields";
/**
* Form detector.
@@ -22,7 +22,7 @@ export class FormDetector {
* Detect login forms on the page based on the clicked element.
*/
public containsLoginForm(): boolean {
let formWrapper = this.clickedElement?.closest('form, [role="dialog"]') as HTMLElement | null;
let formWrapper = this.getFormWrapper();
if (formWrapper?.getAttribute('role') === 'dialog') {
// If we hit a dialog, search for form only within the dialog
formWrapper = formWrapper.querySelector('form') as HTMLElement | null ?? formWrapper;
@@ -58,7 +58,7 @@ export class FormDetector {
return null;
}
const formWrapper = this.clickedElement.closest('form') ?? this.document.body;
const formWrapper = this.getFormWrapper();
return this.detectFormFields(formWrapper);
}
@@ -162,6 +162,13 @@ export class FormDetector {
return [domainSuggestion];
}
/**
* Get the form wrapper element.
*/
private getFormWrapper(): HTMLElement | null {
return this.clickedElement?.closest('form, [role="dialog"]') as HTMLElement | null;
}
/**
* Check if an element and all its parents are visible.
* This checks for display:none, visibility:hidden, and opacity:0
@@ -242,73 +249,76 @@ export class FormDetector {
}
/**
* Find an input field based on common patterns in its attributes.
* Find all input/select elements matching patterns and types, ordered by best match.
*/
private findInputField(
private findAllInputFields(
form: HTMLFormElement | null,
patterns: string[],
types: string[],
excludeElements: HTMLInputElement[] = []
): HTMLInputElement | null {
): HTMLInputElement[] {
// Query for both standard input elements and any element with a type attribute
const candidates = form
? form.querySelectorAll<HTMLInputElement>('input, select')
: this.document.querySelectorAll<HTMLInputElement>('input, select');
? form.querySelectorAll<HTMLElement>('input, select, [type]')
: this.document.querySelectorAll<HTMLElement>('input, select, [type]');
// Track best match and its pattern index
let bestMatch: HTMLInputElement | null = null;
let bestMatchIndex = patterns.length;
const matches: { input: HTMLInputElement; score: number }[] = [];
for (const input of Array.from(candidates)) {
// Skip if this element is already used
if (excludeElements.includes(input)) {
if (excludeElements.includes(input as HTMLInputElement)) {
continue;
}
// Skip if element is not visible
if (!this.isElementVisible(input)) {
continue;
}
// Handle both input and select elements
const type = input.tagName.toLowerCase() === 'select' ? 'select' : input.type.toLowerCase();
// Get type from either the element's type property or its type attribute
const type = input.tagName.toLowerCase() === 'select'
? 'select'
: (input as HTMLInputElement).type?.toLowerCase() || input.getAttribute('type')?.toLowerCase() || '';
if (!types.includes(type)) {
continue;
}
// Check for exact type match if types contains email, as that most likely is the email field.
if (types.includes('email') && input.type.toLowerCase() === 'email') {
return input;
if (types.includes('email') && type === 'email') {
matches.push({ input: input as HTMLInputElement, score: -1 });
continue;
}
// Collect all text attributes to check
const attributes = [
const attributesToCheck = [
input.id,
input.name,
input.placeholder
].map(attr => attr?.toLowerCase() ?? '');
input.getAttribute('name'),
input.getAttribute('placeholder')
]
.map(a => a?.toLowerCase() ?? '');
// Check for associated labels if input has an ID or name
if (input.id || input.name) {
const label = this.document.querySelector(`label[for="${input.id || input.name}"]`);
if (input.id || input.getAttribute('name')) {
const label = this.document.querySelector(`label[for="${input.id || input.getAttribute('name')}"]`);
if (label) {
attributes.push(label.textContent?.toLowerCase() ?? '');
attributesToCheck.push(label.textContent?.toLowerCase() ?? '');
}
}
// Check for sibling elements with class containing "label"
const parent = input.parentElement;
if (parent) {
const siblings = Array.from(parent.children);
for (const sibling of siblings) {
if (sibling !== input && Array.from(sibling.classList).some(c => c.toLowerCase().includes('label'))) {
attributes.push(sibling.textContent?.toLowerCase() ?? '');
for (const sib of Array.from(parent.children)) {
if (
sib !== input &&
Array.from(sib.classList).some(c => c.toLowerCase().includes('label'))
) {
attributesToCheck.push(sib.textContent?.toLowerCase() ?? '');
}
}
}
// Check for parent label and table cell structure
let currentElement = input;
for (let i = 0; i < 5; i++) {
let currentElement: HTMLElement | null = input;
for (let depth = 0; depth < 5 && currentElement; depth++) {
// Stop if we have too many child elements (near body)
if (currentElement.children.length > 15) {
break;
@@ -317,48 +327,65 @@ export class FormDetector {
// Check for label - search both parent and child elements
const childLabel = currentElement.querySelector('label');
if (childLabel) {
attributes.push(childLabel.textContent?.toLowerCase() ?? '');
attributesToCheck.push(childLabel.textContent?.toLowerCase() ?? '');
break;
}
// Check for table cell structure
const parentTd = currentElement.closest('td');
if (parentTd) {
const td = currentElement.closest('td');
if (td) {
// Get the parent row
const parentTr = parentTd.closest('tr');
if (parentTr) {
const row = td.closest('tr');
if (row) {
// Check all sibling cells in the row
const siblingTds = parentTr.querySelectorAll('td');
for (const td of siblingTds) {
if (td !== parentTd) { // Skip the cell containing the input
attributes.push(td.textContent?.toLowerCase() ?? '');
for (const cell of Array.from(row.querySelectorAll('td'))) {
if (cell !== td) {
attributesToCheck.push(cell.textContent?.toLowerCase() ?? '');
break;
}
}
}
break; // Found table structure, no need to continue up the tree
break;
}
if (currentElement.parentElement) {
currentElement = currentElement.parentElement as HTMLInputElement;
} else {
currentElement = currentElement.parentElement;
}
let bestIndex = patterns.length;
for (let i = 0; i < patterns.length; i++) {
if (attributesToCheck.some(a => a.includes(patterns[i]))) {
bestIndex = i;
break;
}
}
// Find the earliest matching pattern
for (let i = 0; i < patterns.length; i++) {
if (i >= bestMatchIndex) {
break;
} // Skip if we already have a better match
if (attributes.some(attr => attr.includes(patterns[i]))) {
bestMatch = input;
bestMatchIndex = i;
break; // Found the best possible match for this input
}
if (bestIndex < patterns.length) {
matches.push({ input: input as HTMLInputElement, score: bestIndex });
}
}
return bestMatch;
return matches
.sort((a, b) => a.score - b.score)
.map(m => m.input);
}
/**
* Find a single input/select element based on common patterns in its attributes.
*/
private findInputField(
form: HTMLFormElement | null,
patterns: string[],
types: string[],
excludeElements: HTMLInputElement[] = []
): HTMLInputElement | null {
const all = this.findAllInputFields(form, patterns, types, excludeElements);
// if email type explicitly requested, prefer actual <input type="email">
if (types.includes('email')) {
const emailMatch = all.find(i => (i.type || '').toLowerCase() === 'email');
if (emailMatch) {
return emailMatch;
}
}
return all.length > 0 ? all[0] : null;
}
/**
@@ -546,15 +573,11 @@ export class FormDetector {
primary: HTMLInputElement | null,
confirm: HTMLInputElement | null
} {
const candidates = form
? form.querySelectorAll<HTMLInputElement>('input[type="password"]')
: this.document.querySelectorAll<HTMLInputElement>('input[type="password"]');
const visibleCandidates = Array.from(candidates).filter(input => this.isElementVisible(input));
const passwordFields = this.findAllInputFields(form, CombinedFieldPatterns.password, ['password']);
return {
primary: visibleCandidates[0] ?? null,
confirm: visibleCandidates[1] ?? null
primary: passwordFields[0] ?? null,
confirm: passwordFields[1] ?? null
};
}
@@ -601,6 +624,34 @@ export class FormDetector {
return false;
}
/**
* Check if a field is an autofill-triggerable field (username, email, or password).
*/
public isAutofillTriggerableField(): boolean {
// Check if it's a username, email or password field by reusing the existing detection logic
const formWrapper = this.getFormWrapper();
// Check if the clicked element is a username field.
const usernameFields = this.findAllInputFields(formWrapper as HTMLFormElement | null, CombinedFieldPatterns.username, ['text']);
if (usernameFields.some(input => input === this.clickedElement)) {
return true;
}
// Check if the clicked element is a password field.
const passwordField = this.findPasswordField(formWrapper as HTMLFormElement | null);
if (passwordField.primary === this.clickedElement || passwordField.confirm === this.clickedElement) {
return true;
}
// Check if the clicked element is an email field.
const emailFields = this.findAllInputFields(formWrapper as HTMLFormElement | null, CombinedFieldPatterns.email, ['text', 'email']);
if (emailFields.some(input => input === this.clickedElement)) {
return true;
}
return false;
}
/**
* Create a form entry.
*/

View File

@@ -1,7 +1,7 @@
import { Credential } from "@/utils/types/Credential";
import { FormFields } from "@/utils/formDetector/types/FormFields";
import { Gender, IdentityHelperUtils } from "@/utils/dist/shared/identity-generator";
import type { Credential } from "@/utils/dist/shared/models/vault";
import { CombinedDateOptionPatterns, CombinedGenderOptionPatterns } from "@/utils/formDetector/FieldPatterns";
import { Gender, IdentityHelperUtils } from "@/utils/shared/identity-generator";
import { FormFields } from "@/utils/formDetector/types/FormFields";
/**
* Class to fill the fields of a form with the given credential.
*/

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { FormField, testField } from './TestUtils';
describe('FormDetector English tests', () => {

View File

@@ -1,7 +1,9 @@
import { describe, it, expect } from 'vitest';
import { createTestDom } from './TestUtils';
import { FormDetector } from '../FormDetector';
import { createTestDom } from './TestUtils';
describe('FormDetector generic tests', () => {
describe('Invalid form not detected as login form 1', () => {
const htmlFile = 'invalid-form1.html';

View File

@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest';
import { FormField, testField, testBirthdateFormat } from './TestUtils';
describe('FormDetector Dutch tests', () => {

View File

@@ -1,7 +1,9 @@
import { describe, it, expect } from 'vitest';
import { createTestDocument } from './TestUtils';
import { FormDetector } from '../FormDetector';
import { createTestDocument } from './TestUtils';
describe('FormDetector.getSuggestedServiceName (English)', () => {
it('should extract service name from title with divider and include domain', () => {
const { document, location } = createTestDocument(

View File

@@ -1,7 +1,9 @@
import { describe, it, expect } from 'vitest';
import { createTestDocument } from './TestUtils';
import { FormDetector } from '../FormDetector';
import { createTestDocument } from './TestUtils';
describe('FormDetector.getSuggestedServiceName (Dutch)', () => {
it('should extract service name from title with divider and include domain', () => {
const { document, location } = createTestDocument(

View File

@@ -1,9 +1,12 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { FormFiller } from '../FormFiller';
import { JSDOM } from 'jsdom';
import { setupTestDOM, createMockFormFields, createMockCredential, wasTriggerCalledFor, createDateSelects } from './TestUtils';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Credential } from '@/utils/dist/shared/models/vault';
import { FormFiller } from '../FormFiller';
import { FormFields } from '../types/FormFields';
import { Credential } from '../../types/Credential';
import { setupTestDOM, createMockFormFields, createMockCredential, wasTriggerCalledFor, createDateSelects } from './TestUtils';
const { window } = new JSDOM('<!DOCTYPE html>');
global.HTMLSelectElement = window.HTMLSelectElement;

View File

@@ -1,9 +1,12 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { FormFiller } from '../FormFiller';
import { JSDOM } from 'jsdom';
import { setupTestDOM, createMockFormFields, createMockCredential, wasTriggerCalledFor, createDateSelects } from './TestUtils';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Credential } from '@/utils/dist/shared/models/vault';
import { FormFiller } from '../FormFiller';
import { FormFields } from '../types/FormFields';
import { Credential } from '../../types/Credential';
import { setupTestDOM, createMockFormFields, createMockCredential, wasTriggerCalledFor, createDateSelects } from './TestUtils';
const { window } = new JSDOM('<!DOCTYPE html>');
global.HTMLSelectElement = window.HTMLSelectElement;

View File

@@ -1,9 +1,12 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { FormFiller } from '../FormFiller';
import { JSDOM } from 'jsdom';
import { setupTestDOM, createMockFormFields, createMockCredential, wasTriggerCalledFor, createDateSelects } from './TestUtils';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Credential } from '@/utils/dist/shared/models/vault';
import { FormFiller } from '../FormFiller';
import { FormFields } from '../types/FormFields';
import { Credential } from '../../types/Credential';
import { setupTestDOM, createMockFormFields, createMockCredential, wasTriggerCalledFor, createDateSelects } from './TestUtils';
const { window } = new JSDOM('<!DOCTYPE html>');
global.HTMLSelectElement = window.HTMLSelectElement;

View File

@@ -1,11 +1,13 @@
import { FormDetector } from '@/utils/formDetector/FormDetector';
import { readFileSync } from 'fs';
import { join } from 'path';
import { it, expect, vi } from 'vitest';
import { JSDOM, DOMWindow } from 'jsdom';
import { it, expect, vi } from 'vitest';
import { Gender } from '@/utils/dist/shared/identity-generator';
import type { Credential } from '@/utils/dist/shared/models/vault';
import { FormDetector } from '@/utils/formDetector/FormDetector';
import { FormFields } from '@/utils/formDetector/types/FormFields';
import { Credential } from '@/utils/types/Credential';
import { Gender } from '@/utils/shared/identity-generator';
export enum FormField {
Username = 'username',

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