Compare commits

...

379 Commits

Author SHA1 Message Date
Leendert de Borst
50c401cee4 Merge branch 'main' of https://github.com/lanedirt/AliasVault
* 'main' of https://github.com/lanedirt/AliasVault:
  Revert image versions back to :latest (#986)
  Add docker-compose.yml check for latest version (#986)
2025-07-02 10:26:24 +02:00
Leendert de Borst
4e09912420 Bump version to 0.20.2 2025-07-02 10:26:22 +02:00
Leendert de Borst
6c8843dc5b Revert image versions back to :latest (#986) 2025-07-02 10:25:58 +02:00
Leendert de Borst
4c4aa4ba26 Add docker-compose.yml check for latest version (#986) 2025-07-02 10:25:58 +02:00
Leendert de Borst
5ac5f54f78 Add browser extension changelog (#983) 2025-07-01 22:45:05 +02:00
Leendert de Borst
d488107b75 Bump version (#983) 2025-07-01 22:45:05 +02:00
Leendert de Borst
fe30116b33 Check for null with API base URL (#983) 2025-07-01 22:45:05 +02:00
Leendert de Borst
77ced32206 Update install.sh (#981) 2025-07-01 14:27:34 +02:00
Leendert de Borst
299d1f6075 Fix issue with vault upgrade that used the wrong migration key (#981) 2025-07-01 14:27:34 +02:00
Leendert de Borst
9811e32a73 Add changelog for 0.20.0 (#981) 2025-07-01 14:27:34 +02:00
Leendert de Borst
7655773fa3 Bump version (#981) 2025-07-01 14:27:34 +02:00
Leendert de Borst
7a5afcac9c Update publish release docs (#981) 2025-07-01 14:27:34 +02:00
Leendert de Borst
1ab736fd03 Add fastlane Android app metadata for 0.19.0 (#979) 2025-06-30 22:50:20 +02:00
Leendert de Borst
018895e8e9 Update browser extension setting page margins 2025-06-30 16:11:15 +02:00
Leendert de Borst
0b07a37d73 Simplify loop (#976) 2025-06-30 14:53:09 +02:00
Leendert de Borst
5c0d7fc571 Make email delete not fully refresh page, refactoring (#976) 2025-06-30 14:53:09 +02:00
Leendert de Borst
d9d84dd90f Add auto refresh to emails page (#976) 2025-06-30 14:53:09 +02:00
Leendert de Borst
70b7063af2 Remove rememberMe flag from mobile app login (#974) 2025-06-30 14:24:08 +02:00
Leendert de Borst
87287e0237 Update setting update query (#974) 2025-06-30 14:24:08 +02:00
Leendert de Borst
477e786454 Update settings titles (#974) 2025-06-30 14:24:08 +02:00
Leendert de Borst
361ea77ab7 Add identity generator settings scaffolding to app (#974) 2025-06-30 14:24:08 +02:00
Leendert de Borst
36237176fd Update install.md DNS instructions 2025-06-30 14:04:51 +02:00
Leendert de Borst
e15ecaf793 Add mobile app identity generator setting retrieval (#861) 2025-06-29 11:08:02 +02:00
Leendert de Borst
4422ddcaa3 Add identity setting retrieval to content script (#861) 2025-06-29 11:08:02 +02:00
Leendert de Borst
e34e96746f Update terminology (#861) 2025-06-29 11:08:02 +02:00
Leendert de Borst
4c4d51d78e Implement identity generator gender in browser extension AddEdit screen (#861) 2025-06-29 11:08:02 +02:00
Leendert de Borst
e4b12c4617 Add alias gender config option to general settings (#861) 2025-06-29 11:08:02 +02:00
Leendert de Borst
1cf9b5e93c Revert default config for AliasVault.Client 2025-06-28 12:17:12 +02:00
Leendert de Borst
6664266c3f Update email DNS config docs (#971) 2025-06-28 11:26:41 +02:00
Leendert de Borst
79af285124 Update tests (#969) 2025-06-27 16:05:39 +02:00
Leendert de Borst
66928f74b7 Add improved email interface with sidebar for desktop browsers (#969) 2025-06-27 16:05:39 +02:00
Leendert de Borst
c8599ccd9e Add SMTP service run to vscode tasks.json (#969) 2025-06-27 16:05:39 +02:00
Leendert de Borst
53f69c97af Make new admin links relative (#967) 2025-06-27 14:41:21 +02:00
Leendert de Borst
11d8c941d2 Add all-time stats page to admin (#967) 2025-06-27 13:18:16 +02:00
Leendert de Borst
e31f3df45b Disable autocorrect on iOS autofill search field (#965) 2025-06-27 12:26:05 +02:00
Leendert de Borst
e2aafa3704 Update docs (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
c2290f3ba4 Update docker-build.yml (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
b134ef3aee Update port example (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
912c486266 Create env file before doing port availability check (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
51901e6ce3 Update docker-build.yml (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
0dbe417636 Remove redundant logic (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
6f9528ea2d Update newlines (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
3266f7394e Update README.md (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
9fd5848029 Update install script logic (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
0e2d7cabe8 Update success messages (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
2e5b00ea2c Update ssl-configuration command info (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
ff535188da Add reusable success message (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
bb41207cfe Update layout (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
5944cd3248 Add semver validation to install command (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
0f02412db2 Add minimum docker version instructions (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
db479182f0 Add port availability checks (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
d5f8516abc Add Docker lightweight dependency test (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
1682304ae7 Add dependency checks (#963) 2025-06-27 10:51:07 +02:00
Leendert de Borst
d0bbf3ac9f Update README.md 2025-06-25 21:09:21 +02:00
Leendert de Borst
12492c922d Start vault revisions from 1 instead of 0 (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
3240c3760a Remove deprecated method (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
58801926cc Make mobile app autofill more resilient towards failures (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
39b5c03ae1 Add unsupported vault detection to web client (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
b01cdc1f52 Update wording (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
ce0f466f01 Update DbService.cs (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
80e40b3ceb Improve mobile app flow for pending migration check (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
70bb8ef3e4 Add vault outdated status flag (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
00fb290598 Refactor upgrade to use vaultMutate hook (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
9d8a2e784f Add pending migration check to main app boot and reinitialize (app timeout) (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
e57cb01164 Do not wait for logout call to finish when explicitly logging out so its compatible with offline mode (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
6f421bbdc1 Only do pendingmigrations check in sync if vault is unlocked (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
eaa42196f8 Revert app index back to credentials navigation redirect (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
e844e20322 Fix self-host check based on Api Url (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
b53a4334ca Prevent double sync when opening popup (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
afe2ba52b5 Add vault upgrade check to autofill popup (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
3e82c6e5d0 Implement modal in upgrade page (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
68dbecd536 Update unlock and upgrade UI (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
c0c1b75e73 Throw error if vault version is unknown (newer) during login (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
8510648b5f Show upgrade screen when unlocking inline (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
0e803205c0 Refactor unlock success flow (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
2fc7ffa509 Linting refactor (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
b16fd8e157 Update unlock page UI (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
effeb211ff Delete UserMenu.tsx (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
bfc15fcea6 Make unlock work, simplify db upgrade checks (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
6bb204efb9 Update upgrade page UI (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
dbc9724377 Fix vault mutation issue that caused redirect to fail (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
71783f1af2 Add upgrade required checks (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
7ead1d270b Prefer /logout navigation instead of directly calling apis (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
19b89cbfda Refactor navigation in browser extension to follow mobile app reinitialize structure (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
0617ccb42e Remove min vault version check (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
a3d702f2e5 Update database version retrieval to use VaultVersion objects (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
3967b0f832 Add isSelfHosted check (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
867dd90000 Add Upgrade.tsx scaffolding (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
6ed1be3b91 Hide bottom nav for specific non-auth pages (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
56e065feea Implement ApiUrlUtility (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
3b27e647ef Add self-host warning to vault upgrade page (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
62732a71f0 Add known vault version check: logout if vault is newer than the app knows about (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
f3ad61a77a Add upgrade version info tooltip to AliasVault.Client (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
0d878f669f Show vault upgrade description in popup (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
6fba784cfe Update vault-sql (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
c46a95cf82 Add mobile app executeRaw query native implementations (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
bba16e6e14 Show API url in settings page, refactor login api url rendering (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
b4c4603868 Add onUpgradeRequired and executeRaw logic to iOS (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
925455b5d6 Update vault-sql and remove unnecessary update commands (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
6aa0c2b9df Remove obsolete version identifier (#957) 2025-06-25 16:50:26 +02:00
Leendert de Borst
1799a2f580 Update login-settings.tsx layout scaffolding (#959) 2025-06-24 19:30:19 +02:00
Leendert de Borst
615b5b2883 Update top level _layout.tsx so header has correct size on Android (#959) 2025-06-24 19:30:19 +02:00
Leendert de Borst
006f89b6b7 Update CONTRIBUTING.md 2025-06-24 11:18:27 +02:00
dependabot[bot]
76c60ad200 Bump the npm_and_yarn group across 1 directory with 3 updates
Bumps the npm_and_yarn group with 3 updates in the /shared/vault-sql directory: [esbuild](https://github.com/evanw/esbuild), [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) and [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8).


Updates `esbuild` from 0.21.5 to 0.25.5
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.25.5)

Updates `vitest` from 2.1.9 to 3.2.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v3.2.4/packages/vitest)

Updates `@vitest/coverage-v8` from 2.1.9 to 3.2.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v3.2.4/packages/coverage-v8)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.25.5
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: vitest
  dependency-version: 3.2.4
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 3.2.4
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-23 19:42:53 +02:00
Leendert de Borst
1830dc0ca1 Exclude static sql files from sonarcloud scanner (#955) 2025-06-23 16:37:10 +02:00
Leendert de Borst
c3599c9f26 Simplify structure (#955) 2025-06-23 16:37:10 +02:00
Leendert de Borst
5d050cd278 Commit generated SQL files to Git for documentation purposes (#955) 2025-06-23 16:37:10 +02:00
Leendert de Borst
ff57091eef Update service-worker.published.js to include new shared TS libs to cache (#955) 2025-06-23 16:37:10 +02:00
Leendert de Borst
64ef5837c0 Add vault-sql shared module binaries to browser extension and mobile app (#955) 2025-06-23 16:37:10 +02:00
Leendert de Borst
771f372434 Replace EF pending migrations check with JsInterop version (#955) 2025-06-23 16:37:10 +02:00
Leendert de Borst
7690355434 Refactor (#955) 2025-06-23 16:37:10 +02:00
Leendert de Borst
822b95d940 Refactor vault sql to include release info (#955) 2025-06-23 16:37:10 +02:00
Leendert de Borst
41b2a959ed Add scripts to convert EF core structure to Typescript definitions (#955) 2025-06-23 16:37:10 +02:00
Leendert de Borst
3e82f78fe9 Make vault creation work via vault-sql lib in AliasVault.Client (#955) 2025-06-23 16:37:10 +02:00
Leendert de Borst
421884e301 Update shared package scaffolding (#955) 2025-06-23 16:37:10 +02:00
Leendert de Borst
d149e5aeec Add vault-sql shared project scaffolding 2025-06-23 16:37:10 +02:00
Leendert de Borst
8b2702cbe3 Update App.tsx (#953) 2025-06-22 11:23:12 +02:00
Leendert de Borst
7b1cfd363c Add popout button to the credential and email pages via new methods (#953) 2025-06-22 11:23:12 +02:00
Leendert de Borst
5e965d7b3f Add popout button to login and unlock page (#953) 2025-06-22 11:23:12 +02:00
Leendert de Borst
d8ac05f325 Add favicon to browser extension html (#953) 2025-06-22 11:23:12 +02:00
Leendert de Borst
a1c13a15f9 Add manual CSV unit test (#951) 2025-06-21 23:39:52 +02:00
Leendert de Borst
f285b36c61 Add generic CSV importer based on an example template (#951) 2025-06-21 23:39:52 +02:00
Leendert de Borst
c6fa90e00c Update .gitignore (#948) 2025-06-21 15:51:39 +02:00
Leendert de Borst
cb8de80f08 Update null check (#948) 2025-06-21 15:51:39 +02:00
Leendert de Borst
15bb7f6593 Add recent auth log attempts to user details page (#948) 2025-06-21 15:51:39 +02:00
Leendert de Borst
516dd524df Make auth log username clickable (#948) 2025-06-21 15:51:39 +02:00
Leendert de Borst
87e58f8546 Add LastPass import unit test (#947) 2025-06-21 13:02:34 +02:00
Leendert de Borst
3baaf78689 Add LastPass importer logic (#947) 2025-06-21 13:02:34 +02:00
Leendert de Borst
336bbafe27 Fix inline unlock confirm message (#945) 2025-06-20 18:55:58 +02:00
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
79221f35c6 Bump version to 0.17.2 (#850) 2025-05-17 17:39:16 +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
baf81392eb Restore docker-compose.yml container versions to :latest (#848) 2025-05-17 17:14:01 +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
738 changed files with 43645 additions and 37140 deletions

View File

@@ -14,9 +14,9 @@
# Docker containers to apply the changes.
# ----------------------------------------------------------------------------
# Set the ports that your AliasVault will be accessible at.
# These are the default ports that will be used by the `reverse-proxy` and `smtp` containers.
# You can change these to any other ports that are available on your system.
# Configure the network ports used by AliasVault by the `reverse-proxy` and `smtp` containers.
# You can change these if the defaults are in use on your system.
# After making changes, re-run the install script to apply them.
HTTP_PORT=80
HTTPS_PORT=443
SMTP_PORT=25

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,10 @@ 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"
"apps/browser-extension/src/utils/dist/shared/vault-sql"
)
for dir in "${TARGET_DIRS[@]}"; do
@@ -42,15 +44,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 +56,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

@@ -32,6 +32,23 @@ jobs:
echo "BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_ENV
fi
- uses: actions/checkout@v2
- name: Check local docker-compose.yml for :latest tags
run: |
# Check for explicit version tags instead of :latest
if grep -E "ghcr\.io/lanedirt/aliasvault-[^:]+:[0-9]+\.[0-9]+\.[0-9]+" docker-compose.yml; then
echo "❌ Error: docker-compose.yml contains explicit version tags instead of :latest"
echo "Found the following explicit versions:"
grep -E "ghcr\.io/lanedirt/aliasvault-[^:]+:[0-9]+\.[0-9]+\.[0-9]+" docker-compose.yml
echo ""
echo "All AliasVault images in docker-compose.yml must use ':latest' tags, not explicit versions."
echo "Please update docker-compose.yml to use ':latest' for all AliasVault images."
exit 1
fi
echo "✅ docker-compose.yml correctly uses :latest tags for all AliasVault images"
- name: Download install script from current branch
run: |
INSTALL_SCRIPT_URL="https://raw.githubusercontent.com/$REPO_FULL_NAME/$BRANCH_NAME/install.sh"
@@ -125,8 +142,8 @@ jobs:
- name: Test reset-admin-password output
if: ${{ !steps.install_script.outputs.skip_remaining }}
run: |
output=$(./install.sh reset-admin-password)
if ! echo "$output" | grep -E '.*New admin password: [A-Za-z0-9+/=]{8,}.*'; then
output=$(./install.sh reset-admin-password | sed 's/\x1b\[[0-9;]*m//g')
if ! echo "$output" | grep -Eq '^\s*Password: [A-Za-z0-9+/=]{8,}'; then
echo "Invalid reset-admin-password output"
exit 1
fi
@@ -143,6 +160,21 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Check local docker-compose.yml for :latest tags
run: |
# Check for explicit version tags instead of :latest
if grep -E "ghcr\.io/lanedirt/aliasvault-[^:]+:[0-9]+\.[0-9]+\.[0-9]+" docker-compose.yml; then
echo "❌ Error: docker-compose.yml contains explicit version tags instead of :latest"
echo "Found the following explicit versions:"
grep -E "ghcr\.io/lanedirt/aliasvault-[^:]+:[0-9]+\.[0-9]+\.[0-9]+" docker-compose.yml
echo ""
echo "All AliasVault images in docker-compose.yml must use ':latest' tags, not explicit versions."
echo "Please update docker-compose.yml to use ':latest' for all AliasVault images."
exit 1
fi
echo "✅ docker-compose.yml correctly uses :latest tags for all AliasVault images"
- name: Create .env file with custom SMTP port
run: echo "SMTP_PORT=2525" > .env
@@ -197,9 +229,10 @@ jobs:
fi
- name: Test reset-admin-password output
if: ${{ !steps.install_script.outputs.skip_remaining }}
run: |
output=$(./install.sh reset-admin-password)
if ! echo "$output" | grep -E '.*New admin password: [A-Za-z0-9+/=]{8,}.*'; then
output=$(./install.sh reset-admin-password | sed 's/\x1b\[[0-9;]*m//g')
if ! echo "$output" | grep -Eq '^\s*Password: [A-Za-z0-9+/=]{8,}'; then
echo "Invalid reset-admin-password output"
exit 1
fi

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,10 @@ 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"
"utils/dist/shared/vault-sql"
)
for dir in "${TARGET_DIRS[@]}"; do
@@ -48,15 +65,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 +77,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 +110,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/**,**/*.sql"
} 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/**,**/*.sql"
}
dotnet build
dotnet test -c Release /p:CollectCoverage=true /p:CoverletOutput=coverage /p:CoverletOutputFormat=opencover --filter 'FullyQualifiedName!~AliasVault.E2ETests'

10
.gitignore vendored
View File

@@ -9,7 +9,6 @@
*.user
*.userosscache
*.sln.docstates
*.code-workspace
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
@@ -379,6 +378,10 @@ FodyWeavers.xsd
# Codebuddy Rider plugin
.codebuddy
# Claude Code
.claude
CLAUDE.md
# -------------------
# AliasVault specifics
# -------------------
@@ -419,4 +422,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": {}
}

18
.vscode/tasks.json vendored
View File

@@ -43,6 +43,20 @@
"cwd": "${workspaceFolder}/apps/server/AliasVault.Admin"
}
},
{
"label": "Build and watch SMTP Service",
"type": "shell",
"command": "dotnet watch",
"args": [],
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
},
"options": {
"cwd": "${workspaceFolder}/apps/server/Services/AliasVault.SmtpService"
}
},
{
"label": "Build and watch Client CSS",
"type": "shell",
@@ -155,10 +169,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,26 +1,60 @@
# Contributing to the source code
We welcome contributions to AliasVault. Please read the guidelines on the official AliasVault docs website on how to get your local development environment setup and the general contribution guidelines:
# Contributing to AliasVault
Thanks for your interest in contributing to the AliasVault project! There are a lot of ways to help out.
## Community Engagement
Become active in AliasVault's community, helping by:
- **Answering questions** in our [Discord community](https://discord.gg/DsaXMTEtpF)
- **Helping users** with self-hosting setup and troubleshooting
- **Reporting bugs** and suggesting improvements
- **Participating in discussions** about features and improvements
## Spreading the Word
Getting the word out about AliasVault is important so we can reach and help more people to improve their privacy. You can help by:
- **Sharing on social media** (X, Reddit, Mastodon, etc.)
- **Writing blog posts** about your AliasVault experience
- **Creating video tutorials** or walkthroughs
- **Mentioning AliasVault** in privacy/self-hosting discussions
- **Telling friends and colleagues** about the project
## Contributing to the Documentation
The docs are built using Jekyll and automatically deploy to GitHub Pages via GitHub Actions. You can build the docs locally by running `docker compose up` in the `./docs` folder.
The docs site is based on the open-source template called Just The Docs. Find more information about how this template works in the [official docs](https://just-the-docs.github.io/just-the-docs/).
To make changes to the AliasVault documentation please make a PR that directly edits the `docs` markdown files in this repository.
## Contributing to the Main Codebase
### Get in contact
If youre planning to work on a new feature or improvement for AliasVault, we strongly encourage you to get in touch with us first. This ensures that your proposed changes align with the project's direction and increases the likelihood of your work being accepted into the official repository. You can reach us through:
- Opening an issue on GitHub to discuss your proposed changes
- Reaching out via Discord or email
- Contacting the maintainers directly
### Set up your local development environment
You can find instructions on how to get your local development environment setup for the different parts of the AliasVault codebase here:
https://docs.aliasvault.net/misc/dev/
> Tip: if the URL above is not available, the raw doc pages can also be found in the `docs` folder in this repository.
## Contributing to the documentation
The docs are built using Jekyll and automatically deploy to GitHub Pages via GitHub Actions. You can build the docs locally by running `docker compose up` in the `./docs` folder.
If you run into any issues, feel free to join our [Discord](https://discord.gg/DsaXMTEtpF) to chat with the maintainers and author.
The docs site is based on the open-source template called Just The Docs. Find more information about how this template works in the [official docs](https://just-the-docs.github.io/just-the-docs/).
## License and Contributions
AliasVault is licensed under the GNU Affero General Public License v3.0 (AGPLv3). By submitting code, documentation, or other contributions to this project, you agree that:
To make changes to the AliasVault documentation please make a PR that directly edits the `docs` markdown files in this repository.
1. Your contribution will be licensed under the same AGPLv3 license as the project
2. You have the legal right to grant this license (e.g., you are the author, or have permission)
3. You understand that your contribution will be made public under the AGPLv3 terms
4. You are not expected to provide support or warranties for your contribution
## Contributor License Agreement (CLA)
Thank you for your interest in contributing to AliasVault (“Project”).
✅ There is no Contributor License Agreement (CLA) required. We believe in a balanced open source model where all contributors are treated equally under the terms of the AGPLv3.
By submitting code, documentation, or other contributions to this Project, you agree to the following:
1. You are legally entitled to grant this license (e.g., you are the author, or have permission).
2. You grant the Project maintainers a perpetual, worldwide, non-exclusive, royalty-free license to use, modify, distribute, and sublicense your contribution as part of the Project and any derivative works.
3. You understand that your contribution will be made public and licensed under the same terms as the Project (e.g., AGPLv3), or any later version the maintainers may release.
4. You are not expected to provide support or warranties for your contribution.
> All contributors must accept the CLA as a condition of contributing. By opening a pull request, you agree to these terms. We may enforce this automatically via GitHub if needed.
> By opening a pull request, you agree to these terms. Your contributions will be published under the AGPLv3 license.

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.
AliasVault is a privacy-first password and email alias manager. Create unique identities, strong passwords, and random email aliases for every website you use. Fully end-to-end encrypted, with a built-in email server and zero third-party dependencies.
[<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)
@@ -8,12 +8,12 @@ End-to-end encrypted password manager with built-in alias and email generation
<a href="https://app.aliasvault.net">Try the cloud version 🔥</a> | <a href="https://aliasvault.net?utm_source=gh-readme">Website </a> | <a href="https://docs.aliasvault.net?utm_source=gh-readme">Documentation </a> | <a href="#self-hosting">Self-host instructions</a>
⭐ Star us on GitHub — it motivates us a lot!
## About
AliasVault helps protect your privacy online by generating a unique password, identity, and email alias for every service you use. Everything is end-to-end encrypted and under your control — whether in the cloud or self-hosted.
Built on 15 years of experience, AliasVault is independent, open-source, self-hostable and community-driven. Its the response to a web that tracks everything: a way to take back control of your digital privacy and help you stay secure online.
Built on 15 years of experience, AliasVault is open-source, self-hostable and community-driven. Its the response to a web that tracks everything: a way to take back control of your digital privacy and help you stay secure online.
Leendert de Borst (@lanedirt), Creator of AliasVault
Leendert de Borst ([@lanedirt](https://github.com/lanedirt)), Creator of AliasVault
## Screenshots
@@ -47,7 +47,17 @@ 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 (universal)](https://app.aliasvault.net)
- [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)
<p>
<a href="https://apps.apple.com/app/id6745490915" style="display: inline-block; margin-right: 20px;"><img src="https://github.com/user-attachments/assets/bad09b85-2635-4e3e-b154-9f348b88f6d6" style="height: 40px;margin-right:10px;" alt="Download on the App Store"></a>
<a href="https://play.google.com/store/apps/details?id=net.aliasvault.app" style="display: inline-block;"><img src="https://github.com/user-attachments/assets/b28979c9-f4b8-4090-8735-e384a7fdaa47" style="height: 40px;" alt="Get it on Google Play"></a>
</p>
[<img width="700" alt="Screenshot of AliasVault" src="docs/assets/img/screenshot.png">](https://app.aliasvault.net)
@@ -62,7 +72,7 @@ This method uses pre-built Docker images and works on minimal hardware specifica
- 1 vCPU
- 1GB RAM
- 16GB disk space
- Docker installed
- Docker (20.10+) and Docker Compose (2.0+)
```bash
# Download install script from latest stable release
@@ -114,8 +124,9 @@ 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
- [x] Editing in browser extension
- [ ] Data model and usability improvements (more flexible aliases and credential types, folder support, bulk selecting etc.)
- [ ] Support for FIDO2/WebAuthn hardware keys and passkeys
- [ ] Adding support for family/team sharing (organization features)
@@ -127,5 +138,4 @@ Feel free to open an issue or join our [Discord](https://discord.gg/DsaXMTEtpF)!
### Support the mission
Your donation helps me dedicate more time and resources to improving AliasVault, making the internet safer for everyone!
<a href="https://www.buymeacoffee.com/lanedirt" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
<a href="https://www.buymeacoffee.com/lanedirt" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 50px !important;" ></a>

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.20.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 = 23;
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.20.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 = 23;
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.20.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 = 23;
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.1;
MARKETING_VERSION = 0.20.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 = 23;
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.1;
MARKETING_VERSION = 0.20.2;
OTHER_LDFLAGS = (
"-framework",
SafariServices,

View File

@@ -1,3 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind utilities;
@media (max-width: 380px) {
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, handleGetDefaultIdentitySettings, 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,25 +16,30 @@ 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());
onMessage('GET_CREDENTIALS', () => handleGetCredentials());
onMessage('CREATE_IDENTITY', ({ data }) => handleCreateIdentity(data));
onMessage('GET_DEFAULT_EMAIL_DOMAIN', () => handleGetDefaultEmailDomain());
onMessage('GET_DEFAULT_IDENTITY_LANGUAGE', () => handleGetDefaultIdentityLanguage());
onMessage('GET_DEFAULT_IDENTITY_SETTINGS', () => handleGetDefaultIdentitySettings());
onMessage('GET_PASSWORD_SETTINGS', () => handleGetPasswordSettings());
onMessage('GET_DERIVED_KEY', () => handleGetDerivedKey());
onMessage('OPEN_POPUP', () => handleOpenPopup());
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.
@@ -60,9 +62,7 @@ export function handleContextMenuClick(info: Browser.contextMenus.OnClickData, t
args: [password]
});
}
}
if (info.menuItemId === "aliasvault-activate-form" && tab?.id) {
} else if (info.menuItemId === "aliasvault-activate-form" && tab?.id) {
// First get the active element's identifier
browser.scripting.executeScript({
target: { tabId: tab.id },

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,21 +1,23 @@
/* 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 { IdentitySettingsResponse } from '@/utils/types/messaging/IdentitySettingsResponse';
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.
* Check if the user is logged in and if the vault is locked, and also check for pending migrations.
*/
export async function handleCheckAuthStatus() : Promise<{ isLoggedIn: boolean, isVaultLocked: boolean }> {
export async function handleCheckAuthStatus() : Promise<{ isLoggedIn: boolean, isVaultLocked: boolean, hasPendingMigrations: boolean, error?: string }> {
const username = await storage.getItem('local:username');
const accessToken = await storage.getItem('local:accessToken');
const vaultData = await storage.getItem('session:encryptedVault');
@@ -23,10 +25,42 @@ export async function handleCheckAuthStatus() : Promise<{ isLoggedIn: boolean, i
const isLoggedIn = username !== null && accessToken !== null;
const isVaultLocked = isLoggedIn && vaultData === null;
return {
isLoggedIn,
isVaultLocked
};
// If vault is locked, we can't check for pending migrations
if (isVaultLocked) {
return {
isLoggedIn,
isVaultLocked,
hasPendingMigrations: false
};
}
// If not logged in, no need to check migrations
if (!isLoggedIn) {
return {
isLoggedIn,
isVaultLocked,
hasPendingMigrations: false
};
}
// Vault is unlocked, check for pending migrations
try {
const sqliteClient = await createVaultSqliteClient();
const hasPendingMigrations = await sqliteClient.hasPendingMigrations();
return {
isLoggedIn,
isVaultLocked,
hasPendingMigrations
};
} catch (error) {
console.error('Error checking pending migrations:', error);
return {
isLoggedIn,
isVaultLocked,
hasPendingMigrations: false,
error: error instanceof Error ? error.message : 'An unknown error occurred'
};
}
}
/**
@@ -36,17 +70,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 +259,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' };
@@ -260,18 +277,25 @@ export function handleGetDefaultEmailDomain(
}
/**
* Get the default identity language.
* Get the default identity settings.
*/
export async function handleGetDefaultIdentityLanguage(
) : Promise<stringResponse> {
export async function handleGetDefaultIdentitySettings(
) : Promise<IdentitySettingsResponse> {
try {
const sqliteClient = await createVaultSqliteClient();
const settingValue = sqliteClient.getDefaultIdentityLanguage();
const language = sqliteClient.getDefaultIdentityLanguage();
const gender = sqliteClient.getDefaultIdentityGender();
return { success: true, value: settingValue };
return {
success: true,
settings: {
language,
gender
}
};
} catch (error) {
console.error('Error getting default identity language:', error);
return { success: false, error: 'Failed to get default identity language' };
console.error('Error getting default identity settings:', error);
return { success: false, error: 'Failed to get default identity settings' };
}
}
@@ -300,10 +324,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;
@@ -335,7 +431,7 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<void
client: '', // Empty on purpose, API will not use this for vault updates.
updatedAt: new Date().toISOString(),
username: username,
version: sqliteClient.getDatabaseVersion() ?? '0.0.0'
version: sqliteClient.getDatabaseVersion().version
};
const webApi = new WebApiService(() => {});
@@ -347,6 +443,8 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<void
} else {
throw new Error('Failed to upload new vault to server');
}
return response;
}
/**
@@ -355,7 +453,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, createUpgradeRequiredPopup } 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,13 +58,18 @@ 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);
// Only show popup if debounce time has passed
if (popupDebounceTimeHasPassed()) {
openAutofillPopup(inputElement, container);
await showPopupWithAuthCheck(inputElement, container);
}
}
}
@@ -117,13 +125,55 @@ 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);
await showPopupWithAuthCheck(inputElement, container);
}
}
/**
* Show popup with auth check.
*/
async function showPopupWithAuthCheck(inputElement: HTMLInputElement, container: HTMLElement) : Promise<void> {
try {
// Check auth status and pending migrations in a single call
const { sendMessage } = await import('webext-bridge/content-script');
const authStatus = await sendMessage('CHECK_AUTH_STATUS', {}, 'background') as {
isLoggedIn: boolean,
isVaultLocked: boolean,
hasPendingMigrations: boolean,
error?: string
};
if (authStatus.isVaultLocked) {
// Vault is locked, show vault locked popup
const { createVaultLockedPopup } = await import('@/entrypoints/contentScript/Popup');
createVaultLockedPopup(inputElement, container);
return;
}
if (authStatus.hasPendingMigrations) {
// Show upgrade required popup
createUpgradeRequiredPopup(inputElement, container, 'Vault upgrade required.');
return;
}
if (authStatus.error) {
// Show upgrade required popup for version-related errors
createUpgradeRequiredPopup(inputElement, container, authStatus.error);
return;
}
// No upgrade required, show normal autofill popup
openAutofillPopup(inputElement, container);
} catch (error) {
console.error('Error checking vault status:', error);
// Fall back to normal autofill popup if check fails
openAutofillPopup(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,20 @@
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 { 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 { 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 { IdentitySettingsResponse } from '@/utils/types/messaging/IdentitySettingsResponse';
import { PasswordSettingsResponse } from '@/utils/types/messaging/PasswordSettingsResponse';
import { StringResponse } from '@/utils/types/messaging/StringResponse';
import { storage } from '#imports';
/**
* WeakMap to store event listeners for popup containers
@@ -242,24 +245,22 @@ 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 identitySettings = await sendMessage('GET_DEFAULT_IDENTITY_SETTINGS', {}, 'background') as IdentitySettingsResponse;
const identityGenerator = CreateIdentityGenerator(identitySettings.settings?.language ?? 'en');
const identity = identityGenerator.generateRandomIdentity(identitySettings.settings?.gender);
// 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 +947,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 +977,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);
@@ -1455,3 +1463,92 @@ function addReliableClickHandler(element: HTMLElement, handler: (e: Event) => vo
isMouseDown = false;
}, { capture: true });
}
/**
* Create upgrade required popup.
*/
export function createUpgradeRequiredPopup(input: HTMLInputElement, rootContainer: HTMLElement, errorMessage: string): void {
/**
* Handle upgrade click.
*/
const handleUpgradeClick = () : void => {
sendMessage('OPEN_POPUP', {}, 'background');
removeExistingPopup(rootContainer);
}
const popup = createBasePopup(input, rootContainer);
popup.classList.add('av-upgrade-required');
// Create container for message and button
const container = document.createElement('div');
container.className = 'av-upgrade-required-container';
// Make the entire container clickable
addReliableClickHandler(container, handleUpgradeClick);
container.style.cursor = 'pointer';
// Add message
const messageElement = document.createElement('div');
messageElement.className = 'av-upgrade-required-message';
messageElement.textContent = errorMessage;
container.appendChild(messageElement);
// Add upgrade button with SVG icon
const button = document.createElement('button');
button.title = 'Open AliasVault to upgrade';
button.className = 'av-upgrade-required-button';
button.innerHTML = `
<svg class="av-icon-upgrade" viewBox="0 0 24 24">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"></path>
</svg>
`;
container.appendChild(button);
// Add the container to the popup
popup.appendChild(container);
// Add close button as a separate element positioned to the right
const closeButton = document.createElement('button');
closeButton.className = 'av-button av-button-close av-upgrade-required-close';
closeButton.title = 'Dismiss popup';
closeButton.innerHTML = `
<svg class="av-icon" viewBox="0 0 24 24">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
`;
// Position the close button to the right of the container
closeButton.style.position = 'absolute';
closeButton.style.right = '8px';
closeButton.style.top = '50%';
closeButton.style.transform = 'translateY(-50%)';
// Handle close button click
addReliableClickHandler(closeButton, (e) => {
e.stopPropagation(); // Prevent opening the upgrade popup
removeExistingPopup(rootContainer);
});
popup.appendChild(closeButton);
/**
* Add event listener to document to close popup when clicking outside.
*/
const handleClickOutside = (event: MouseEvent): void => {
const target = event.target as Node;
const targetElement = event.target as HTMLElement;
// Check if the click is outside the popup and outside the shadow UI
if (popup && !popup.contains(target) && !input.contains(target) && targetElement.tagName !== 'ALIASVAULT-UI') {
removeExistingPopup(rootContainer);
document.removeEventListener('mousedown', handleClickOutside);
}
};
setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside);
}, 100);
rootContainer.appendChild(popup);
}

View File

@@ -299,6 +299,71 @@ body {
border: 1px solid #6f6f6f;
}
/* Upgrade Required Popup */
.av-upgrade-required {
padding: 12px 16px;
position: relative;
}
.av-upgrade-required:hover {
background-color: #374151;
}
.av-upgrade-required-container {
display: flex;
align-items: center;
padding-right: 32px;
width: 100%;
transition: background-color 0.2s ease;
border-radius: 4px;
}
.av-upgrade-required-message {
color: #d1d5db;
font-size: 14px;
flex-grow: 1;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.av-upgrade-required-button {
background: none;
border: none;
cursor: pointer;
padding: 4px;
padding-right: 28px;
display: flex;
align-items: center;
justify-content: center;
color: #f59e0b;
border-radius: 4px;
margin-left: 8px;
}
.av-upgrade-required-close {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
padding: 4px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
border: 1px solid #6f6f6f;
}
.av-icon-upgrade {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Create Name Popup */
.av-create-popup-overlay {
position: fixed;

View File

@@ -1,20 +1,30 @@
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 Header from '@/entrypoints/popup/components/Layout/Header';
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 { 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 Index from '@/entrypoints/popup/pages/Index';
import Login from '@/entrypoints/popup/pages/Login';
import Logout from '@/entrypoints/popup/pages/Logout';
import Reinitialize from '@/entrypoints/popup/pages/Reinitialize';
import Settings from '@/entrypoints/popup/pages/Settings';
import Unlock from '@/entrypoints/popup/pages/Unlock';
import UnlockSuccess from '@/entrypoints/popup/pages/UnlockSuccess';
import Upgrade from '@/entrypoints/popup/pages/Upgrade';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import '@/entrypoints/popup/style.css';
/**
@@ -35,13 +45,21 @@ 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: '/', element: <Index />, showBackButton: false },
{ path: '/reinitialize', element: <Reinitialize />, showBackButton: false },
{ path: '/login', element: <Login />, showBackButton: false },
{ path: '/unlock', element: <Unlock />, showBackButton: false },
{ path: '/unlock-success', element: <UnlockSuccess />, showBackButton: false },
{ path: '/upgrade', element: <Upgrade />, 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 +85,44 @@ 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}
/>
<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,40 +0,0 @@
import React, { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
/**
* Global state change handler component which listens for global state changes and e.g. redirects user to login
* page if login state changes.
*/
const GlobalStateChangeHandler: React.FC = () => {
const authContext = useAuth();
const navigate = useNavigate();
const lastLoginState = useRef(authContext.isLoggedIn);
const initialRender = useRef(true);
/**
* Listen for auth logged in changes and redirect to home page if logged in state changes to handle logins and logouts.
*/
useEffect(() => {
// Only navigate when auth state is different from the last state we acted on.
if (lastLoginState.current !== authContext.isLoggedIn) {
lastLoginState.current = authContext.isLoggedIn;
/**
* Skip the first auth state change to avoid redirecting when popup opens for the first time
* which already causes the auth state to change from false to true.
*/
if (initialRender.current) {
initialRender.current = false;
return;
}
// Redirect to home page if logged in state changes.
navigate('/');
}
}, [authContext.isLoggedIn]); // eslint-disable-line react-hooks/exhaustive-deps
return null;
};
export default GlobalStateChangeHandler;

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,186 @@
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',
TAB = 'tab'
}
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>
),
[HeaderIconType.TAB]: (
<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="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 5a2 2 0 012-2h4a2 2 0 012 2v2H8V5z"
/>
</svg>
)
};
return icons[type] || null;
};

View File

@@ -1,7 +1,5 @@
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';
type TabName = 'credentials' | 'emails' | 'settings';
@@ -9,17 +7,19 @@ type TabName = 'credentials' | 'emails' | 'settings';
* Bottom nav component.
*/
const BottomNav: React.FC = () => {
const authContext = useAuth();
const dbContext = useDb();
const navigate = useNavigate();
const location = useLocation();
const [currentTab, setCurrentTab] = useState<TabName>('credentials');
// 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]);
@@ -31,7 +31,11 @@ const BottomNav: React.FC = () => {
navigate(`/${tab}`);
};
if (!authContext.isLoggedIn || !dbContext.dbAvailable) {
// Auth pages that don't show bottom navigation but still show header
const authPages = ['/login', '/auth-settings', '/unlock', '/unlock-success', '/upgrade'];
const isAuthPage = authPages.includes(location.pathname);
if (isAuthPage) {
return null;
}

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
@@ -58,6 +45,11 @@ const Header: React.FC<HeaderProps> = ({
* Handle logo click.
*/
const logoClick = () : void => {
// Don't navigate if on upgrade page or login page
if (location.pathname === '/upgrade' || location.pathname === '/login' || location.pathname === '/unlock') {
return;
}
// If logged in, navigate to credentials.
if (authContext.isLoggedIn) {
navigate('/credentials');
@@ -105,33 +97,25 @@ const Header: React.FC<HeaderProps> = ({
<div className="flex-grow" />
<div className="flex items-center">
{!currentRoute?.showBackButton ? (
<button
onClick={openClientTab}
className="p-2"
>
<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" />
</svg>
</button>
) : (<></>)}
<div className="flex items-center gap-2">
{!authContext.isLoggedIn ? (
<>
{rightButtons}
<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>
</>
) : (
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,91 +0,0 @@
import React, { useState, useRef, useEffect } 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 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.
*/
const toggleUserMenu = () : void => {
setIsUserMenuOpen(!isUserMenuOpen);
};
/**
* Handle logging out.
*/
const onLogout = async () : Promise<void> => {
showLoading();
navigate('/logout', { replace: true });
hideLoading();
};
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}
</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>
</div>
);
};
export default UserMenu;

View File

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

View File

@@ -1,28 +1,21 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { storage } from '#imports';
import { AppInfo } from '@/utils/AppInfo';
import { useApiUrl } from '@/entrypoints/popup/utils/ApiUrlUtility';
/**
* Component for displaying the login server information.
*/
const LoginServerInfo: React.FC = () => {
const [baseUrl, setBaseUrl] = useState<string>('');
const { loadApiUrl, getDisplayUrl } = useApiUrl();
const navigate = useNavigate();
useEffect(() => {
/**
* Loads the base URL for the login server.
*/
const loadApiUrl = async () : Promise<void> => {
const apiUrl = await storage.getItem('local:apiUrl') as string;
setBaseUrl(apiUrl ?? AppInfo.DEFAULT_API_URL);
};
loadApiUrl();
}, []);
const isDefaultServer = !baseUrl || baseUrl === AppInfo.DEFAULT_API_URL;
const displayUrl = isDefaultServer ? 'aliasvault.net' : new URL(baseUrl).hostname;
}, [loadApiUrl]);
/**
* Handles the click event for the login server information.
@@ -39,7 +32,7 @@ const LoginServerInfo: React.FC = () => {
type="button"
className="text-orange-500 hover:text-orange-600 dark:text-orange-400 dark:hover:text-orange-500 underline"
>
{displayUrl}
{getDisplayUrl()}
</button>)
</div>
);

View File

@@ -0,0 +1,103 @@
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 = '',
cancelText = '',
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">
{confirmText && (
<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>
)}
{cancelText && (
<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,32 @@ 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 }> => {
let isLoggedIn = false;
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);
isLoggedIn = true;
}
setIsInitialized(true);
return { isLoggedIn };
}, [setUsername, setIsLoggedIn]);
/**
* 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 +111,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,19 +1,22 @@
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;
dbInitialized: boolean;
dbAvailable: boolean;
initializeDatabase: (vaultResponse: VaultResponse, derivedKey: string) => Promise<void>;
initializeDatabase: (vaultResponse: VaultResponse, derivedKey: string) => Promise<SqliteClient>;
clearDatabase: () => void;
vaultRevision: number;
publicEmailDomains: string[];
privateEmailDomains: string[];
getVaultMetadata: () => Promise<VaultMetadata | null>;
setCurrentVaultRevisionNumber: (revisionNumber: number) => Promise<void>;
hasPendingMigrations: () => Promise<boolean>;
}
const DbContext = createContext<DbContextType | undefined>(undefined);
@@ -37,20 +40,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 +59,26 @@ 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');
return client;
}, []);
const checkStoredVault = useCallback(async () => {
@@ -89,9 +91,12 @@ 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 +108,34 @@ 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 there are pending migrations.
*/
const hasPendingMigrations = useCallback(async () => {
if (!sqliteClient) {
return false;
}
return await sqliteClient.hasPendingMigrations();
}, [sqliteClient]);
/**
* Check if database is initialized and try to retrieve vault from background
*/
@@ -118,6 +151,7 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
const clearDatabase = useCallback(() : void => {
setSqliteClient(null);
setDbInitialized(false);
setDbAvailable(false);
sendMessage('CLEAR_VAULT', {}, 'background');
}, []);
@@ -127,10 +161,10 @@ 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,
hasPendingMigrations,
}), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, clearDatabase, getVaultMetadata, setCurrentVaultRevisionNumber, hasPendingMigrations]);
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,102 @@
import React, { createContext, useContext, useEffect, useMemo, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { storage } from '#imports';
const LAST_VISITED_PAGE_KEY = 'session:lastVisitedPage';
const LAST_VISITED_TIME_KEY = 'session:lastVisitedTime';
const NAVIGATION_HISTORY_KEY = 'session:navigationHistory';
type NavigationHistoryEntry = {
pathname: string;
search: string;
hash: string;
};
type NavigationContextType = {
storeCurrentPage: () => Promise<void>;
isFullyInitialized: boolean;
requiresAuth: boolean;
};
const NavigationContext = createContext<NavigationContextType | undefined>(undefined);
/**
* Navigation provider component that handles storing the last visited page.
*/
export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const location = useLocation();
// Auth and DB state
const { isInitialized: authInitialized, isLoggedIn } = useAuth();
const { dbInitialized, dbAvailable, upgradeRequired } = useDb();
// Derived state
const isFullyInitialized = authInitialized && dbInitialized;
const requiresAuth = isFullyInitialized && (!isLoggedIn || (!dbAvailable && !upgradeRequired));
/**
* 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 = ['/', '/reinitialize', '/login', '/unlock', '/unlock-success', '/auth-settings', '/upgrade', '/logout'];
// 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]);
// Store the current page whenever it changes
useEffect(() => {
if (isFullyInitialized) {
storeCurrentPage();
}
}, [location.pathname, location.search, location.hash, isFullyInitialized, storeCurrentPage]);
const contextValue = useMemo(() => ({
storeCurrentPage,
isFullyInitialized,
requiresAuth
}), [storeCurrentPage, 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,164 @@
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;
skipSyncCheck?: boolean;
}
/**
* 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) {
// Note: vault merge is no longer allowed by the API as of 0.20.0, updates with the same revision number are rejected. So this check can be removed later.
throw new Error('Vault merge required. Please login via the web app to merge the multiple pending updates to your vault.');
} else if (response.status === 2) {
throw new Error('Your vault is outdated. Please login on the AliasVault website and follow the steps.');
} else {
throw new Error('Failed to upload vault to server. Please try again by re-opening the app.');
}
} 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');
// Skip sync check if requested (e.g., during upgrade operations)
if (options.skipSyncCheck) {
setSyncStatus('Executing operation...');
await executeMutateOperation(operation, options);
return;
}
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,175 @@
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;
onUpgradeRequired?: () => 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, onUpgradeRequired } = 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;
const sqliteClient = await dbContext.initializeDatabase(vaultResponseJson as VaultResponse, passwordHashBase64);
// Check if the current vault version is known and up to date, if not known trigger an exception, if not up to date redirect to the upgrade page.
if (await sqliteClient.hasPendingMigrations()) {
onUpgradeRequired?.();
return false;
}
onSuccess?.(true);
return true;
} catch (error) {
// Check if it's a version-related error (app needs to be updated)
if (error instanceof Error && error.message.includes('This browser extension is outdated')) {
await webApi.logout(error.message);
onError?.(error.message);
return false;
}
// Vault could not be decrypted, throw an error
throw new Error('Vault could not be decrypted, if the problem persists please logout and login again.');
}
}
// Check if the vault is up to date, if not, redirect to the upgrade page.
if (await dbContext.hasPendingMigrations()) {
onUpgradeRequired?.();
return false;
}
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 version-related error (app needs to be updated)
if (errorMessage.includes('This browser extension is outdated')) {
await webApi.logout(errorMessage);
onError?.(errorMessage);
return false;
}
/*
* 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

@@ -4,6 +4,11 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AliasVault</title>
<link rel="icon" type="image/png" sizes="16x16" href="/icon/16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/icon/32.png" />
<link rel="icon" type="image/png" sizes="48x48" href="/icon/48.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon/192.png" />
<link rel="apple-touch-icon" sizes="192x192" href="/icon/192.png" />
<link href="~/assets/tailwind.css" rel="stylesheet" />
<meta name="manifest.type" content="browser_action" />
</head>

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,687 @@
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();
// Get gender preference from database
const genderPreference = dbContext.sqliteClient!.getDefaultIdentityGender();
// Generate identity with gender preference
const identity = identityGenerator.generateRandomIdentity(genderPreference);
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"
cancelText="Cancel"
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,62 +9,42 @@ 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 { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
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();
/**
* Check if the current page is an expanded popup.
*/
const isPopup = (): boolean => {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('expanded') === 'true';
};
const { setHeaderButtons } = useHeaderButtons();
/**
* Open the credential details in a new expanded popup.
*/
const openInNewPopup = (): void => {
const width = 380;
const height = 600;
const left = window.screen.width / 2 - width / 2;
const top = window.screen.height / 2 - height / 2;
window.open(
`popup.html?expanded=true#/credentials/${id}`,
'CredentialDetails',
`width=${width},height=${height},left=${left},top=${top},popup=true`
);
window.close();
};
const openInNewPopup = useCallback((): void => {
PopoutUtility.openInNewPopup(`/credentials/${id}`);
}, [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()) {
if (PopoutUtility.isPopup()) {
window.history.replaceState({}, '', `popup.html#/credentials`);
window.history.pushState({}, '', `popup.html#/credentials/${id}`);
}
@@ -89,23 +67,51 @@ 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">
{!PopoutUtility.isPopup() && (
<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,21 @@
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 { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
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 +23,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 +50,88 @@ 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.');
navigate('/logout');
},
});
} catch (err) {
console.error('Refresh error:', err);
console.error('Error refreshing credentials:', err);
await webApi.logout('Error while syncing vault, please re-authenticate.');
navigate('/logout');
}
}, [dbContext, webApi, hideLoading]);
}, [dbContext, webApi, syncVault, navigate]);
/**
* 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);
}, [onRefresh, setIsLoading]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
const headerButtonsJSX = (
<div className="flex items-center gap-2">
{!PopoutUtility.isPopup() && (
<HeaderButton
onClick={() => PopoutUtility.openInNewPopup()}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
)}
<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, setIsInitialLoading]);
// Add this function to filter credentials
const filteredCredentials = credentials.filter(cred => {
@@ -135,7 +157,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,27 @@
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 { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
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,20 +29,14 @@ 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
if (isPopup()) {
if (PopoutUtility.isPopup()) {
// Clear existing history and create fresh entries
window.history.replaceState({}, '', `popup.html#/emails`);
window.history.pushState({}, '', `popup.html#/emails/${id}`);
@@ -62,58 +64,43 @@ 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 (PopoutUtility.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.
* Open the email details in a new expanded popup.
*/
const isPopup = () : boolean => {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('expanded') === 'true';
};
/**
* Open the credential details in a new expanded popup.
*/
const openInNewPopup = () : void => {
const width = 800;
const height = 1000;
const left = window.screen.width / 2 - width / 2;
const top = window.screen.height / 2 - height / 2;
window.open(
`popup.html?expanded=true#/emails/${id}`,
'EmailDetails',
`width=${width},height=${height},left=${left},top=${top},popup=true`
);
// Close the current tab
window.close();
};
const openInNewPopup = useCallback((): void => {
PopoutUtility.openInNewPopup(`/emails/${id}`);
}, [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 +110,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 +140,39 @@ 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">
{!PopoutUtility.isPopup() && (
<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 +191,25 @@ 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"
cancelText="Cancel"
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,20 @@
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 HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
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 { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
import type { MailboxBulkRequest, MailboxBulkResponse, MailboxEmail } from '@/utils/dist/shared/models/webapi';
import EncryptionUtility from '@/utils/EncryptionUtility';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
/**
* Emails list page.
@@ -15,8 +22,10 @@ import ReloadButton from '@/entrypoints/popup/components/ReloadButton';
const EmailsList: React.FC = () => {
const dbContext = useDb();
const webApi = useWebApi();
const { setHeaderButtons } = useHeaderButtons();
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,13 +70,31 @@ 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();
}, [loadEmails]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
const headerButtonsJSX = !PopoutUtility.isPopup() ? (
<HeaderButton
onClick={() => PopoutUtility.openInNewPopup()}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
) : null;
setHeaderButtons(headerButtonsJSX);
return () => {
setHeaderButtons(null);
};
}, [setHeaderButtons]);
/**
* Formats the date display for emails
*/

View File

@@ -1,61 +0,0 @@
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';
/**
* Home page that shows the correct page based on the user's authentication state.
*/
const Home: React.FC = () => {
const authContext = useAuth();
const dbContext = useDb();
const navigate = useNavigate();
const { setIsInitialLoading } = useLoading();
const [isInlineUnlockMode, setIsInlineUnlockMode] = useState(false);
// 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.
return null;
}
setIsInitialLoading(false);
if (!isAuthenticated) {
return <Login />;
}
if (!isDatabaseAvailable) {
return <Unlock />;
}
if (isInlineUnlockMode) {
return <UnlockSuccess onClose={() => setIsInlineUnlockMode(false)} />;
}
return null;
};
export default Home;

View File

@@ -0,0 +1,20 @@
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 { isFullyInitialized } = useNavigation();
if (!isFullyInitialized) {
return null;
}
return <Navigate to="/reinitialize" replace />;
};
export default Home;

View File

@@ -1,30 +1,42 @@
import React, { useEffect, useState } from 'react';
import { Buffer } from 'buffer';
import { storage } from '#imports';
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Button from '@/entrypoints/popup/components/Button';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
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 { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
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 { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
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
*/
const Login: React.FC = () => {
const navigate = useNavigate();
const authContext = useAuth();
const dbContext = useDb();
const { setHeaderButtons } = useHeaderButtons();
const [credentials, setCredentials] = useState({
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 +60,29 @@ const Login: React.FC = () => {
}
setClientUrl(clientUrl);
setIsInitialLoading(false);
};
loadClientUrl();
}, []);
}, [setIsInitialLoading]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
const headerButtonsJSX = !PopoutUtility.isPopup() ? (
<>
<HeaderButton
onClick={() => PopoutUtility.openInNewPopup()}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
</>
) : null;
setHeaderButtons(headerButtonsJSX);
return () => {
setHeaderButtons(null);
};
}, [setHeaderButtons]);
/**
* Handle submit
@@ -66,7 +98,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 +116,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,14 +154,31 @@ 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);
const sqliteClient = await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
// Set logged in status to true which refreshes the app.
await authContext.login();
// If there are pending migrations, redirect to the upgrade page.
try {
if (await sqliteClient.hasPendingMigrations()) {
navigate('/upgrade', { replace: true });
hideLoading();
return;
}
} catch (err) {
await authContext.logout();
setError(err instanceof Error ? err.message : 'An error occurred while checking for pending migrations.');
hideLoading();
return;
}
// Navigate to reinitialize page which will take care of the proper redirect.
navigate('/reinitialize', { replace: true });
// Show app.
hideLoading();
} catch (err) {
@@ -164,7 +213,7 @@ const Login: React.FC = () => {
}
const validationResponse = await srpUtil.validateLogin2Fa(
credentials.username,
ConversionUtility.normalizeUsername(credentials.username),
passwordHashString,
rememberMe,
loginResponse,
@@ -189,14 +238,31 @@ 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);
const sqliteClient = await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
// Set logged in status to true which refreshes the app.
await authContext.login();
// If there are pending migrations, redirect to the upgrade page.
try {
if (await sqliteClient.hasPendingMigrations()) {
navigate('/upgrade', { replace: true });
hideLoading();
return;
}
} catch (err) {
await authContext.logout();
setError(err instanceof Error ? err.message : 'An error occurred while checking for pending migrations.');
hideLoading();
return;
}
// Navigate to reinitialize page which will take care of the proper redirect.
navigate('/reinitialize', { replace: true });
// Reset 2FA state and login response as it's no longer needed
setTwoFactorRequired(false);
setTwoFactorCode('');
@@ -229,7 +295,7 @@ const Login: React.FC = () => {
if (twoFactorRequired) {
return (
<div className="max-w-md">
<div>
<form onSubmit={handleTwoFactorSubmit} className="bg-white dark:bg-gray-700 w-full shadow-md rounded px-8 pt-6 pb-8 mb-4">
{error && (
<div className="mb-4 text-red-500 dark:text-red-400 text-sm">
@@ -286,7 +352,7 @@ const Login: React.FC = () => {
}
return (
<div className="max-w-md">
<div>
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-700 w-full shadow-md rounded px-8 pt-6 pb-8 mb-4">
{error && (
<div className="mb-4 text-red-500 dark:text-red-400 text-sm">

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';
@@ -19,7 +20,7 @@ const Logout: React.FC = () => {
*/
const performLogout = async () : Promise<void> => {
await webApi.logout();
navigate('/');
navigate('/login');
};
performLogout();

View File

@@ -0,0 +1,153 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { 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 { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync';
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;
};
/**
* Initialize component that handles initial application setup, authentication checks,
* vault synchronization, and state restoration.
*/
const Reinitialize: React.FC = () => {
const navigate = useNavigate();
const { setIsInitialLoading } = useLoading();
const { syncVault } = useVaultSync();
const hasInitialized = useRef(false);
// Auth and DB state
const { isInitialized: authInitialized, isLoggedIn } = useAuth();
const { dbInitialized, dbAvailable } = useDb();
// Derived state
const isFullyInitialized = authInitialized && dbInitialized;
const requiresAuth = isFullyInitialized && (!isLoggedIn || !dbAvailable);
/**
* Restore the last visited page and navigation history if it was visited within the memory duration.
*/
const restoreLastPage = useCallback(async (): Promise<void> => {
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]);
useEffect(() => {
// Check for inline unlock mode
const urlParams = new URLSearchParams(window.location.search);
const inlineUnlock = urlParams.get('mode') === 'inline_unlock';
if (isFullyInitialized) {
// Prevent multiple vault syncs (only run sync once)
const shouldRunSync = !hasInitialized.current;
if (requiresAuth) {
setIsInitialLoading(false);
// Determine which auth page to show
if (!isLoggedIn) {
navigate('/login', { replace: true });
} else if (!dbAvailable) {
navigate('/unlock', { replace: true });
}
} else if (shouldRunSync) {
// Only perform vault sync once during initialization
hasInitialized.current = true;
// Perform vault sync and restore state
syncVault({
initialSync: false,
/**
* Handle successful vault sync.
*/
onSuccess: async () => {
// After successful sync, try to restore last page or go to credentials
if (inlineUnlock) {
setIsInitialLoading(false);
navigate('/unlock-success', { replace: true });
} else {
await restoreLastPage();
}
},
/**
* Handle vault sync error.
* @param error Error message
*/
onError: (error) => {
console.error('Vault sync error during initialization:', error);
// Even if sync fails, continue with initialization
restoreLastPage().then(() => {
setIsInitialLoading(false);
});
},
/**
* Handle upgrade required.
*/
onUpgradeRequired: () => {
navigate('/upgrade', { replace: true });
setIsInitialLoading(false);
}
});
} else {
// User is logged in and db is available, navigate to appropriate page
setIsInitialLoading(false);
restoreLastPage();
}
}
}, [isFullyInitialized, requiresAuth, isLoggedIn, dbAvailable, navigate, setIsInitialLoading, syncVault, restoreLastPage]);
// This component doesn't render anything visible - it just handles initialization
return null;
};
export default Reinitialize;

View File

@@ -1,10 +1,20 @@
import React, { useEffect, useState, useCallback } from 'react';
import { storage } from "#imports";
import { useNavigate } from 'react-router-dom';
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 { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { useTheme } from '@/entrypoints/popup/context/ThemeContext';
import { browser } from "#imports";
import { useApiUrl } from '@/entrypoints/popup/utils/ApiUrlUtility';
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
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 { storage, browser } from "#imports";
/**
* Popup settings type.
@@ -23,6 +33,11 @@ type PopupSettings = {
*/
const Settings: React.FC = () => {
const { theme, setTheme } = useTheme();
const authContext = useAuth();
const { setHeaderButtons } = useHeaderButtons();
const { setIsInitialLoading } = useLoading();
const { loadApiUrl, getDisplayUrl } = useApiUrl();
const navigate = useNavigate();
const [settings, setSettings] = useState<PopupSettings>({
disabledUrls: [],
temporaryDisabledUrls: {},
@@ -41,6 +56,44 @@ 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">
{!PopoutUtility.isPopup() && (
<>
<HeaderButton
onClick={() => PopoutUtility.openInNewPopup()}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
</>
)}
<HeaderButton
onClick={openClientTab}
title="Open web app"
iconType={HeaderIconType.EXTERNAL_LINK}
/>
</div>
);
setHeaderButtons(headerButtonsJSX);
return () => setHeaderButtons(null);
}, [setHeaderButtons]);
/**
* Load settings.
*/
@@ -64,6 +117,9 @@ const Settings: React.FC = () => {
await storage.setItem(TEMPORARY_DISABLED_SITES_KEY, cleanedTemporaryDisabledUrls);
}
// Load API URL
await loadApiUrl();
setSettings({
disabledUrls,
temporaryDisabledUrls: cleanedTemporaryDisabledUrls,
@@ -72,7 +128,8 @@ const Settings: React.FC = () => {
isGloballyEnabled,
isContextMenuEnabled
});
}, []);
setIsInitialLoading(false);
}, [setIsInitialLoading, loadApiUrl]);
useEffect(() => {
loadSettings();
@@ -188,12 +245,52 @@ const Settings: React.FC = () => {
}
};
/**
* Handle logout.
*/
const handleLogout = async () : Promise<void> => {
navigate('/logout', { replace: true });
};
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>
@@ -261,7 +358,7 @@ const Settings: React.FC = () => {
{settings.isGloballyEnabled && (
<button
onClick={toggleCurrentSite}
className={`px-4 py-2 rounded-md transition-colors ${
className={`px-4 py-2 ml-1 rounded-md transition-colors ${
settings.isEnabled
? 'bg-green-500 hover:bg-green-600 text-white'
: 'bg-red-500 hover:bg-red-600 text-white'
@@ -355,7 +452,7 @@ const Settings: React.FC = () => {
)}
<div className="text-center text-gray-400 dark:text-gray-600">
Version: {AppInfo.VERSION}
Version {AppInfo.VERSION} ({getDisplayUrl()})
</div>
</div>
);

View File

@@ -1,16 +1,24 @@
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 HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
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 { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
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
@@ -19,13 +27,14 @@ const Unlock: React.FC = () => {
const authContext = useAuth();
const dbContext = useDb();
const navigate = useNavigate();
const { setHeaderButtons } = useHeaderButtons();
const webApi = useWebApi();
const srpUtil = new SrpUtility(webApi);
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const { showLoading, hideLoading } = useLoading();
const { showLoading, hideLoading, setIsInitialLoading } = useLoading();
useEffect(() => {
/**
@@ -36,11 +45,30 @@ const Unlock: React.FC = () => {
const statusError = webApi.validateStatusResponse(statusResponse);
if (statusError !== null) {
await webApi.logout(statusError);
navigate('/logout');
}
setIsInitialLoading(false);
};
checkStatus();
}, [webApi, authContext]);
}, [webApi, authContext, setIsInitialLoading, navigate]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
const headerButtonsJSX = !PopoutUtility.isPopup() ? (
<HeaderButton
onClick={() => PopoutUtility.openInNewPopup()}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
) : null;
setHeaderButtons(headerButtonsJSX);
return () => {
setHeaderButtons(null);
};
}, [setHeaderButtons]);
/**
* Handle submit
@@ -80,6 +108,9 @@ const Unlock: React.FC = () => {
// Clear dismiss until (which can be enabled after user has dimissed vault is locked popup) to ensure popup is shown.
await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, 0);
// Redirect to reinitialize page
navigate('/reinitialize', { replace: true });
} catch (err) {
setError('Failed to unlock vault. Please check your password and try again.');
console.error('Unlock error:', err);
@@ -96,13 +127,31 @@ const Unlock: React.FC = () => {
};
return (
<div className="max-w-md">
<div>
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-700 w-full shadow-md rounded px-8 pt-6 pb-8 mb-4">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white break-all overflow-hidden mb-4">{authContext.username}</h2>
{/* User Avatar and Username Section */}
<div className="flex items-center space-x-3 mb-6">
<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>
<p className="text-base text-gray-500 dark:text-gray-200 mb-6">
Enter your master password to unlock your vault.
</p>
{/* Instruction Title */}
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
Unlock your vault
</h2>
{error && (
<div className="mb-4 text-red-500 dark:text-red-400 text-sm">
@@ -110,7 +159,7 @@ const Unlock: React.FC = () => {
</div>
)}
<div className="mb-6">
<div className="mb-2">
<label className="block text-gray-700 dark:text-gray-200 text-sm font-bold mb-2" htmlFor="password">
Password
</label>

View File

@@ -1,45 +1,55 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
/**
* Unlock success component shown when the vault is successfully unlocked in a separate popup
* asking the user if they want to close the popup.
*/
const UnlockSuccess: React.FC<{
onClose: () => void;
}> = ({ onClose }) => (
<div className="flex flex-col items-center justify-center p-6 text-center">
<div className="mb-4 text-green-600 dark:text-green-400">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
const UnlockSuccess: React.FC = () => {
const navigate = useNavigate();
/**
* Handle browsing vault contents - navigate to credentials page and reset mode parameter
*/
const handleBrowseVaultContents = (): void => {
// Remove mode=inline from URL before navigating
const url = new URL(window.location.href);
url.searchParams.delete('mode');
window.history.replaceState({}, '', url);
// Navigate to credentials page
navigate('/credentials');
};
return (
<div className="flex flex-col items-center justify-center p-6 text-center">
<div className="mb-4 text-green-600 dark:text-green-400">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Your vault is successfully unlocked
</h2>
<p className="mb-6 text-gray-600 dark:text-gray-400">
You can now use autofill in login forms in your browser.
</p>
<div className="space-y-3 w-full">
<button
onClick={() => window.close()}
className="w-full px-4 py-2 text-white bg-primary-600 rounded hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
>
Close this popup
</button>
<button
onClick={handleBrowseVaultContents}
className="w-full px-4 py-2 text-gray-700 bg-gray-100 rounded hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Browse vault contents
</button>
</div>
</div>
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Your vault is successfully unlocked
</h2>
<p className="mb-6 text-gray-600 dark:text-gray-400">
You can now use autofill in login forms in your browser.
</p>
<div className="space-y-3 w-full">
<button
onClick={() => window.close()}
className="w-full px-4 py-2 text-white bg-primary-600 rounded hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
>
Close this popup
</button>
<button
onClick={() => {
// Remove mode=inline from URL before closing
const url = new URL(window.location.href);
url.searchParams.delete('mode');
window.history.replaceState({}, '', url);
onClose();
}}
className="w-full px-4 py-2 text-gray-700 bg-gray-100 rounded hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Browse vault contents
</button>
</div>
</div>
);
);
};
export default UnlockSuccess;

View File

@@ -0,0 +1,339 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import Button from '@/entrypoints/popup/components/Button';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
import Modal from '@/entrypoints/popup/components/Modal';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
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 { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
import { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync';
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
import type { VaultVersion } from '@/utils/dist/shared/vault-sql';
import { VaultSqlGenerator } from '@/utils/dist/shared/vault-sql';
/**
* Upgrade page for handling vault version upgrades.
*/
const Upgrade: React.FC = () => {
const { username } = useAuth();
const dbContext = useDb();
const { sqliteClient } = dbContext;
const { setHeaderButtons } = useHeaderButtons();
const [isLoading, setIsLoading] = useState(false);
const [currentVersion, setCurrentVersion] = useState<VaultVersion | null>(null);
const [latestVersion, setLatestVersion] = useState<VaultVersion | null>(null);
const [error, setError] = useState<string | null>(null);
const [showSelfHostedWarning, setShowSelfHostedWarning] = useState(false);
const [showVersionInfo, setShowVersionInfo] = useState(false);
const { setIsInitialLoading } = useLoading();
const webApi = useWebApi();
const { executeVaultMutation, isLoading: isVaultMutationLoading, syncStatus } = useVaultMutate();
const { syncVault } = useVaultSync();
const navigate = useNavigate();
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
const headerButtonsJSX = !PopoutUtility.isPopup() ? (
<>
<HeaderButton
onClick={() => PopoutUtility.openInNewPopup()}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
</>
) : null;
setHeaderButtons(headerButtonsJSX);
return () => {
setHeaderButtons(null);
};
}, [setHeaderButtons]);
/**
* Load version information from the database.
*/
const loadVersionInfo = useCallback(async () => {
try {
if (sqliteClient) {
const current = sqliteClient.getDatabaseVersion();
const latest = await sqliteClient.getLatestDatabaseVersion();
setCurrentVersion(current);
setLatestVersion(latest);
}
setIsInitialLoading(false);
} catch (error) {
console.error('Failed to load version information:', error);
setError('Failed to load version information. Please try again.');
}
}, [sqliteClient, setIsInitialLoading]);
useEffect(() => {
loadVersionInfo();
}, [loadVersionInfo]);
/**
* Handle the vault upgrade.
*/
const handleUpgrade = async (): Promise<void> => {
if (!sqliteClient || !currentVersion || !latestVersion) {
setError('Unable to get version information. Please try again.');
return;
}
// Check if this is a self-hosted instance and show warning if needed
if (await webApi.isSelfHosted()) {
setShowSelfHostedWarning(true);
return;
}
await performUpgrade();
};
/**
* Perform the actual vault upgrade.
*/
const performUpgrade = async (): Promise<void> => {
if (!sqliteClient || !currentVersion || !latestVersion) {
setError('Unable to get version information. Please try again.');
return;
}
setIsLoading(true);
setError(null);
try {
// Get upgrade SQL commands from vault-sql shared library
const vaultSqlGenerator = new VaultSqlGenerator();
const upgradeResult = vaultSqlGenerator.getUpgradeVaultSql(currentVersion.revision, latestVersion.revision);
if (!upgradeResult.success) {
throw new Error(upgradeResult.error ?? 'Failed to generate upgrade SQL');
}
if (upgradeResult.sqlCommands.length === 0) {
// No upgrade needed, vault is already up to date
await handleUpgradeSuccess();
return;
}
// Use the useVaultMutate hook to handle the upgrade and vault upload
console.debug('executeVaultMutation');
await executeVaultMutation(async () => {
// Begin transaction
console.debug('beginTransaction');
sqliteClient.beginTransaction();
// Execute each SQL command
console.debug('executeRaw', upgradeResult.sqlCommands.length);
for (let i = 0; i < upgradeResult.sqlCommands.length; i++) {
const sqlCommand = upgradeResult.sqlCommands[i];
try {
console.debug('executeRaw', sqlCommand);
sqliteClient.executeRaw(sqlCommand);
} catch (error) {
console.debug('error', error);
console.error(`Error executing SQL command ${i + 1}:`, sqlCommand, error);
sqliteClient.rollbackTransaction();
throw new Error(`Failed to apply migration ${i + 1}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Commit transaction
console.debug('commitTransaction');
sqliteClient.commitTransaction();
}, {
skipSyncCheck: true, // Skip sync check during upgrade to prevent loop
/**
* Handle successful upgrade completion.
*/
onSuccess: () => {
console.debug('onSuccess');
void handleUpgradeSuccess();
},
/**
* Handle upgrade error.
*/
onError: (error: Error) => {
console.debug('onError');
console.error('Upgrade failed:', error);
setError(error.message);
}
});
console.debug('executeVaultMutation done?');
} catch (error) {
console.error('Upgrade failed:', error);
setError(error instanceof Error ? error.message : 'An unknown error occurred during the upgrade. Please try again.');
} finally {
setIsLoading(false);
}
};
/**
* Handle successful upgrade completion.
*/
const handleUpgradeSuccess = async (): Promise<void> => {
try {
// Sync vault to ensure we have the latest data
await syncVault({
/**
* Handle successful sync completion.
*/
onSuccess: () => {
// Navigate to credentials page
navigate('/credentials');
},
/**
* Handle sync error.
* @param error Error message
*/
onError: (error: string) => {
console.error('Sync error after upgrade:', error);
// Still navigate to credentials even if sync fails
navigate('/credentials');
}
});
} catch (error) {
console.error('Error during post-upgrade sync:', error);
// Navigate to credentials even if sync fails
navigate('/credentials');
}
};
/**
* Handle the logout.
*/
const handleLogout = async (): Promise<void> => {
navigate('/logout');
};
/**
* Show version description dialog.
*/
const showVersionDialog = (): void => {
setShowVersionInfo(true);
};
return (
<div>
{/* Full loading screen overlay */}
{(isLoading || isVaultMutationLoading) && (
<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 || 'Upgrading vault...'}
</div>
</div>
)}
{/* Self-hosted warning modal */}
<Modal
isOpen={showSelfHostedWarning}
onClose={() => setShowSelfHostedWarning(false)}
onConfirm={() => {
setShowSelfHostedWarning(false);
void performUpgrade();
}}
title="Self-Hosted Server"
message="If you're using a self-hosted server, make sure to also update your self-hosted instance as otherwise logging in to the web client will stop working. Do you want to continue with the upgrade?"
confirmText="Continue"
cancelText="Cancel"
/>
{/* Version info modal */}
<Modal
isOpen={showVersionInfo}
onClose={() => setShowVersionInfo(false)}
onConfirm={() => setShowVersionInfo(false)}
title="What's New"
message={`An upgrade is required to support the following changes:\n\n${latestVersion?.description ?? 'No description available for this version.'}`}
/>
<form className="w-full px-2 pt-2 pb-2 mb-4">
{error && (
<div className="mb-4 text-red-500 dark:text-red-400 text-sm">
{error}
</div>
)}
{/* User display section like settings page */}
<div className="flex items-center space-x-3 mb-4">
<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">
{username?.[0]?.toUpperCase() || '?'}
</span>
</div>
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{username}
</p>
</div>
</div>
<h2 className="text-xl font-bold dark:text-gray-200 mb-4">Upgrade Vault</h2>
<div className="mb-6">
<p className="text-gray-700 dark:text-gray-200 text-sm mb-4">
AliasVault has updated and your vault needs to be upgraded. This should only take a few seconds.
</p>
<div className="bg-gray-50 dark:bg-gray-800 rounded p-4 mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">Version Information</span>
<button
type="button"
onClick={showVersionDialog}
className="bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200 rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold hover:bg-gray-300 dark:hover:bg-gray-500"
title="Show version details"
>
?
</button>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400">Your vault:</span>
<span className="text-sm font-bold text-orange-600 dark:text-orange-400">
{currentVersion?.releaseVersion ?? '...'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400">New version:</span>
<span className="text-sm font-bold text-green-600 dark:text-green-400">
{latestVersion?.releaseVersion ?? '...'}
</span>
</div>
</div>
</div>
</div>
<div className="flex flex-col w-full space-y-2">
<Button
type="button"
onClick={handleUpgrade}
>
{isLoading || isVaultMutationLoading ? (syncStatus || 'Upgrading...') : 'Upgrade Vault'}
</Button>
<button
type="button"
onClick={handleLogout}
className="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm font-medium py-2"
disabled={isLoading || isVaultMutationLoading}
>
Logout
</button>
</div>
</form>
</div>
);
};
export default Upgrade;

View File

@@ -0,0 +1,46 @@
import { useState } from 'react';
import { AppInfo } from '@/utils/AppInfo';
import { storage } from '#imports';
/**
* Hook to manage API URL state and display logic.
* @returns Object containing apiUrl state and utility functions
*/
export const useApiUrl = (): {
apiUrl: string;
setApiUrl: (url: string) => void;
loadApiUrl: () => Promise<void>;
getDisplayUrl: () => string;
} => {
const [apiUrl, setApiUrl] = useState<string>(AppInfo.DEFAULT_API_URL);
/**
* Load the API URL from storage.
*/
const loadApiUrl = async (): Promise<void> => {
const storedUrl = await storage.getItem('local:apiUrl') as string;
if (storedUrl && storedUrl.length > 0) {
setApiUrl(storedUrl);
} else {
setApiUrl(AppInfo.DEFAULT_API_URL);
}
};
/**
* Get the display URL for UI presentation.
* @returns Formatted display URL
*/
const getDisplayUrl = (): string => {
const cleanUrl = apiUrl.replace('https://', '').replace('http://', '').replace(':443', '').replace('/api', '');
return cleanUrl === 'app.aliasvault.net' ? 'aliasvault.net' : cleanUrl;
};
return {
apiUrl,
setApiUrl,
loadApiUrl,
getDisplayUrl,
};
};

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

@@ -0,0 +1,44 @@
/**
* Utility class for handling popup window operations
*/
export class PopoutUtility {
/**
* Check if the current page is an expanded popup.
* Uses both URL parameter detection and window width as fallback.
*/
public static isPopup(): boolean {
// Primary method: Check URL parameter
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('expanded') === 'true') {
return true;
}
/**
* Fallback method: Check window width (popout windows are 800px wide)
* Regular popup extension windows are typically narrower (around 375-400px)
*/
return window.innerWidth > 390;
}
/**
* Open the current page in a new expanded popup window.
* @param path - The path to open in the popup (defaults to current path)
*/
public static openInNewPopup(path?: string): void {
const width = 800;
const height = 1000;
const left = window.screen.width / 2 - width / 2;
const top = window.screen.height / 2 - height / 2;
const currentPath = path || window.location.hash.replace('#', '');
const popupUrl = `popup.html?expanded=true#${currentPath}`;
window.open(
popupUrl,
'AliasVaultPopup',
`width=${width},height=${height},left=${left},top=${top},popup=true`
);
window.close();
}
}

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.1';
public static readonly VERSION = '0.20.2';
/**
* The minimum supported AliasVault server (API) version. If the server version is below this, the
@@ -14,11 +14,6 @@ export class AppInfo {
*/
public static readonly MIN_SERVER_VERSION = '0.12.0-dev';
/**
* The minimum supported AliasVault client vault version.
*/
public static readonly MIN_VAULT_VERSION = '1.4.1';
/**
* The client name to use in the X-AliasVault-Client header.
* Detects the specific browser being used.
@@ -61,15 +56,6 @@ export class AppInfo {
*/
private constructor() {}
/**
* Checks if a given vault version is supported
* @param vaultVersion The version to check
* @returns boolean indicating if the version is supported
*/
public static isVaultVersionSupported(vaultVersion: string): boolean {
return this.versionGreaterThanOrEqualTo(vaultVersion, this.MIN_VAULT_VERSION);
}
/**
* Checks if a given server version is supported
* @param serverVersion The version to check

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,8 @@
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';
import type { VaultVersion } from '@/utils/dist/shared/vault-sql';
import { VaultSqlGenerator } from '@/utils/dist/shared/vault-sql';
/**
* Placeholder base64 image for credentials without a logo.
@@ -14,6 +14,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 +46,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 +343,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;
}
/**
@@ -291,6 +387,13 @@ export class SqliteClient {
return this.getSetting('DefaultIdentityLanguage', 'en');
}
/**
* Get the default identity gender preference from the database.
*/
public getDefaultIdentityGender(): string {
return this.getSetting('DefaultIdentityGender', 'random');
}
/**
* Get the password settings from the database.
*/
@@ -321,15 +424,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 +520,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;
}
@@ -432,7 +535,7 @@ export class SqliteClient {
* Returns the semantic version (e.g., "1.4.1") from the latest migration.
* Returns null if no migrations are found.
*/
public getDatabaseVersion(): string | null {
public getDatabaseVersion(): VaultVersion {
if (!this.db) {
throw new Error('Database not initialized');
}
@@ -446,7 +549,7 @@ export class SqliteClient {
LIMIT 1`);
if (results.length === 0) {
return null;
throw new Error('No migrations found in the database.');
}
// Extract version using regex - matches patterns like "20240917191243_1.4.1-RenameAttachmentsPlural"
@@ -454,17 +557,53 @@ export class SqliteClient {
const versionRegex = /_(\d+\.\d+\.\d+)-/;
const versionMatch = versionRegex.exec(migrationId);
let currentVersion = null;
if (versionMatch?.[1]) {
return versionMatch[1];
currentVersion = versionMatch[1];
}
return null;
// Get all available vault versions to get the revision number of the current version.
const vaultSqlGenerator = new VaultSqlGenerator();
const allVersions = vaultSqlGenerator.getAllVersions();
const currentVersionRevision = allVersions.find(v => v.version === currentVersion);
if (!currentVersionRevision) {
throw new Error('This browser extension is outdated and cannot be used to access this vault. Please update this browser extension to continue.');
}
return currentVersionRevision;
} catch (error) {
console.error('Error getting database version:', error);
throw error;
}
}
/**
* Get the latest available database version
* @returns The latest VaultVersion
*/
public async getLatestDatabaseVersion(): Promise<VaultVersion> {
const vaultSqlGenerator = new VaultSqlGenerator();
const allVersions = vaultSqlGenerator.getAllVersions();
return allVersions[allVersions.length - 1];
}
/**
* Check if there are pending migrations
* @returns True if there are pending migrations, false otherwise
*/
public async hasPendingMigrations(): Promise<boolean> {
try {
const currentVersion = this.getDatabaseVersion();
const latestVersion = await this.getLatestDatabaseVersion();
return currentVersion.revision < latestVersion.revision;
} catch (error) {
console.error('Error checking pending migrations:', error);
throw error;
}
}
/**
* Get TOTP codes for a credential
* @param credentialId - The ID of the credential to get TOTP codes for
@@ -502,6 +641,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.
*/
@@ -618,6 +976,38 @@ export class SqliteClient {
return false;
}
}
/**
* Execute raw SQL command
* @param query - The SQL command to execute
*/
public executeRaw(query: string): void {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
// Split the query by semicolons to handle multiple statements
const statements = query.split(';');
for (const statement of statements) {
const trimmedStatement = statement.trim();
// Skip empty statements and transaction control statements (handled externally)
if (trimmedStatement.length === 0 ||
trimmedStatement.toUpperCase().startsWith('BEGIN TRANSACTION') ||
trimmedStatement.toUpperCase().startsWith('COMMIT') ||
trimmedStatement.toUpperCase().startsWith('ROLLBACK')) {
continue;
}
this.db.run(trimmedStatement);
}
} catch (error) {
console.error('Error executing raw SQL:', error);
throw error;
}
}
}
export default SqliteClient;

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;
@@ -28,12 +29,16 @@ export class WebApiService {
* Get the base URL for the API from settings.
*/
private async getBaseUrl(): Promise<string> {
const result = await storage.getItem('local:apiUrl') as string;
if (result && result.length > 0) {
return result.replace(/\/$/, '') + '/v1/';
}
const apiUrl = await this.getApiUrl();
return apiUrl.replace(/\/$/, '') + '/v1/';
}
return AppInfo.DEFAULT_API_URL.replace(/\/$/, '') + '/v1/';
/**
* Check if the current server is self-hosted.
*/
public async isSelfHosted(): Promise<boolean> {
const apiUrl = await this.getApiUrl();
return apiUrl !== AppInfo.DEFAULT_API_URL;
}
/**
@@ -42,7 +47,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 +86,7 @@ export class WebApiService {
}
}
if (!response.ok) {
if (!response.ok && throwOnError) {
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -162,9 +168,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 +179,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;
}
}
@@ -226,14 +232,12 @@ export class WebApiService {
// Logout and revoke tokens via WebApi.
try {
const refreshToken = await this.getRefreshToken();
if (!refreshToken) {
return;
if (refreshToken) {
await this.post('Auth/revoke', {
token: await this.getAccessToken(),
refreshToken: refreshToken,
}, false);
}
await this.post('Auth/revoke', {
token: await this.getAccessToken(),
refreshToken: refreshToken,
}, false);
} catch (err) {
console.error('WebApi logout error:', err);
}
@@ -288,18 +292,19 @@ export class WebApiService {
* Status 0 = OK, vault is ready.
* Status 1 = Merge required, which only the web client supports.
*/
if (vaultResponseJson.status !== 0) {
if (vaultResponseJson.status === 1) {
// Note: vault merge is no longer allowed by the API as of 0.20.0, updates with the same revision number are rejected. So this check can be removed later.
return 'Your vault needs to be updated. Please login on the AliasVault website and follow the steps.';
}
if (vaultResponseJson.status === 2) {
return 'Your vault is outdated. Please login on the AliasVault website and follow the steps.';
}
if (!vaultResponseJson.vault?.blob) {
return 'Your account does not have a vault yet. Please complete the tutorial in the AliasVault web client before using the browser extension.';
}
if (!AppInfo.isVaultVersionSupported(vaultResponseJson.vault.version)) {
return 'Your vault is outdated. Please login via the web client to update your vault.';
}
return null;
}
@@ -328,31 +333,14 @@ export class WebApiService {
}
/**
* Convert a Blob to a Base64 string.
* Get the API URL from settings.
*/
private async blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
private async getApiUrl(): Promise<string> {
const result = await storage.getItem('local:apiUrl') as string;
if (!result || result.length === 0) {
return AppInfo.DEFAULT_API_URL;
}
/**
* When the reader has finished loading, convert the result to a Base64 string.
*/
reader.onloadend = (): void => {
const result = reader.result;
if (typeof result === 'string') {
resolve(result.split(',')[1]); // Remove the data URL prefix
} else {
reject(new Error('Failed to convert Blob to Base64.'));
}
};
/**
* If the reader encounters an error, reject the promise with a proper Error object.
*/
reader.onerror = (): void => {
reject(new Error('Failed to read blob as Data URL'));
};
reader.readAsDataURL(blob);
});
return result;
}
}

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(gender?: string | 'random'): 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(gender?: string | 'random'): 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(gender) {
const identity = {
firstName: "",
lastName: "",
@@ -173,12 +181,26 @@ var BaseIdentityGenerator = class {
emailPrefix: "",
nickName: ""
};
if (this.random() < 0.5) {
identity.firstName = this.firstNamesMale[Math.floor(this.random() * this.firstNamesMale.length)];
identity.gender = "Male" /* Male */;
let selectedGender;
if (gender === "random" || gender === void 0) {
selectedGender = this.random() < 0.5 ? "Male" /* Male */ : "Female" /* Female */;
} else {
if (gender === "male") {
selectedGender = "Male" /* Male */;
} else if (gender === "female") {
selectedGender = "Female" /* Female */;
} else {
selectedGender = "Male" /* Male */;
}
}
identity.gender = selectedGender;
if (selectedGender === "Male" /* Male */) {
identity.firstName = this.firstNamesMale[Math.floor(this.random() * this.firstNamesMale.length)];
} else if (selectedGender === "Female" /* Female */) {
identity.firstName = this.firstNamesFemale[Math.floor(this.random() * this.firstNamesFemale.length)];
identity.gender = "Female" /* Female */;
} else {
const usesMaleNames = this.random() < 0.5;
identity.firstName = usesMaleNames ? this.firstNamesMale[Math.floor(this.random() * this.firstNamesMale.length)] : this.firstNamesFemale[Math.floor(this.random() * this.firstNamesFemale.length)];
}
identity.lastName = this.lastNames[Math.floor(this.random() * this.lastNames.length)];
identity.birthDate = this.generateRandomDateOfBirth();
@@ -979,7 +1001,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 +1665,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 +1734,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(gender) {
const identity = {
firstName: "",
lastName: "",
@@ -142,12 +149,26 @@ var BaseIdentityGenerator = class {
emailPrefix: "",
nickName: ""
};
if (this.random() < 0.5) {
identity.firstName = this.firstNamesMale[Math.floor(this.random() * this.firstNamesMale.length)];
identity.gender = "Male" /* Male */;
let selectedGender;
if (gender === "random" || gender === void 0) {
selectedGender = this.random() < 0.5 ? "Male" /* Male */ : "Female" /* Female */;
} else {
if (gender === "male") {
selectedGender = "Male" /* Male */;
} else if (gender === "female") {
selectedGender = "Female" /* Female */;
} else {
selectedGender = "Male" /* Male */;
}
}
identity.gender = selectedGender;
if (selectedGender === "Male" /* Male */) {
identity.firstName = this.firstNamesMale[Math.floor(this.random() * this.firstNamesMale.length)];
} else if (selectedGender === "Female" /* Female */) {
identity.firstName = this.firstNamesFemale[Math.floor(this.random() * this.firstNamesFemale.length)];
identity.gender = "Female" /* Female */;
} else {
const usesMaleNames = this.random() < 0.5;
identity.firstName = usesMaleNames ? this.firstNamesMale[Math.floor(this.random() * this.firstNamesMale.length)] : this.firstNamesFemale[Math.floor(this.random() * this.firstNamesFemale.length)];
}
identity.lastName = this.lastNames[Math.floor(this.random() * this.lastNames.length)];
identity.birthDate = this.generateRandomDateOfBirth();
@@ -948,7 +969,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 +1633,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 +1702,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

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