Compare commits

...

140 Commits

Author SHA1 Message Date
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
252 changed files with 19826 additions and 1626 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

@@ -35,6 +35,7 @@ jobs:
"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

View File

@@ -125,8 +125,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
@@ -197,9 +197,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

@@ -56,6 +56,7 @@ jobs:
"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

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,**/dist/shared/**"
& $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,**/dist/shared/**"
& $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'

4
.gitignore vendored
View File

@@ -378,6 +378,10 @@ FodyWeavers.xsd
# Codebuddy Rider plugin
.codebuddy
# Claude Code
.claude
CLAUDE.md
# -------------------
# AliasVault specifics
# -------------------

14
.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",

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
The privacy-first password & email alias manager. Fully end-to-end encrypted, with built-in alias generation and email server — giving you full control over your online identity and safeguarding your privacy.
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 @@ The privacy-first password & email alias manager. Fully end-to-end encrypted, wi
<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) | [Android](https://play.google.com/store/apps/details?id=net.aliasvault.app) | [Chrome](https://chromewebstore.google.com/detail/aliasvault/bmoggiinmnodjphdjnmpcnlleamkfedj) | [Firefox](https://addons.mozilla.org/en-US/firefox/addon/aliasvault/) | [Edge](https://microsoftedge.microsoft.com/addons/detail/aliasvault/kabaanafahnjkfkplbnllebdmppdemfo) | [Safari](https://apps.apple.com/app/id6743163173)
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
@@ -115,7 +125,8 @@ Core features that are being worked on:
- [x] Import passwords from traditional password managers
- [x] iOS native app
- [x] Android native app
- [ ] Data model and usability improvements (more flexible aliases and credential types, folder support, editing in browser extension, bulk selecting etc.)
- [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

@@ -2,7 +2,7 @@
"name": "aliasvault-browser-extension",
"description": "AliasVault Browser Extension",
"private": true,
"version": "0.19.0",
"version": "0.20.0",
"type": "module",
"scripts": {
"dev:chrome": "wxt -b chrome",

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 = 20;
CURRENT_PROJECT_VERSION = 22;
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 = 0.19.0;
MARKETING_VERSION = 0.20.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -479,7 +479,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "AliasVault Extension/AliasVault_Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 22;
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 = 0.19.0;
MARKETING_VERSION = 0.20.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -515,7 +515,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 22;
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.19.0;
MARKETING_VERSION = 0.20.0;
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 = 20;
CURRENT_PROJECT_VERSION = 22;
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.19.0;
MARKETING_VERSION = 0.20.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,

View File

@@ -2,7 +2,7 @@
@tailwind components;
@tailwind utilities;
@media (max-width: 400px) {
@media (max-width: 380px) {
html, body {
width: 350px;
max-width: 350px;

View File

@@ -2,7 +2,7 @@ import { onMessage, sendMessage } from "webext-bridge/background";
import { setupContextMenus } from '@/entrypoints/background/ContextMenu';
import { handleOpenPopup, handlePopupWithCredential, handleToggleContextMenu } from '@/entrypoints/background/PopupMessageHandler';
import { handleCheckAuthStatus, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDefaultIdentityLanguage, handleGetDerivedKey, handleGetPasswordSettings, handleGetVault, handleStoreVault, handleSyncVault, handleUploadVault } from '@/entrypoints/background/VaultMessageHandler';
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';
@@ -23,13 +23,17 @@ export default defineBackground({
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) {

View File

@@ -62,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,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BoolResponse } from '@/utils/types/messaging/BoolResponse';
import { setupContextMenus } from '@/entrypoints/background/ContextMenu';
import { setupContextMenus } from './ContextMenu';
import { BoolResponse } from '@/utils/types/messaging/BoolResponse';
import { browser } from '#imports';

View File

@@ -6,6 +6,7 @@ import { EncryptionUtility } from '@/utils/EncryptionUtility';
import { SqliteClient } from '@/utils/SqliteClient';
import { BoolResponse as messageBoolResponse } from '@/utils/types/messaging/BoolResponse';
import { CredentialsResponse as messageCredentialsResponse } from '@/utils/types/messaging/CredentialsResponse';
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';
@@ -14,9 +15,9 @@ import { VaultUploadResponse as messageVaultUploadResponse } from '@/utils/types
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');
@@ -24,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'
};
}
}
/**
@@ -244,18 +277,25 @@ export function handleGetDefaultEmailDomain(): Promise<stringResponse> {
}
/**
* 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' };
}
}
@@ -306,6 +346,56 @@ export async function handleUploadVault(
}
}
/**
* 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.
*/
@@ -341,7 +431,7 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<Vaul
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(() => {});

View File

@@ -2,7 +2,7 @@ import '@/entrypoints/contentScript/style.css';
import { onMessage } from "webext-bridge/content-script";
import { injectIcon, popupDebounceTimeHasPassed, validateInputField } from '@/entrypoints/contentScript/Form';
import { isAutoShowPopupEnabled, openAutofillPopup, removeExistingPopup } from '@/entrypoints/contentScript/Popup';
import { isAutoShowPopupEnabled, openAutofillPopup, removeExistingPopup, createUpgradeRequiredPopup } from '@/entrypoints/contentScript/Popup';
import { FormDetector } from '@/utils/formDetector/FormDetector';
import { BoolResponse as messageBoolResponse } from '@/utils/types/messaging/BoolResponse';
@@ -69,7 +69,7 @@ export default defineContentScript({
// Only show popup if debounce time has passed
if (popupDebounceTimeHasPassed()) {
openAutofillPopup(inputElement, container);
await showPopupWithAuthCheck(inputElement, container);
}
}
}
@@ -132,6 +132,48 @@ export default defineContentScript({
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

@@ -10,6 +10,7 @@ import { CreatePasswordGenerator, PasswordGenerator } from '@/utils/dist/shared/
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';
@@ -244,9 +245,9 @@ 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;
const identityGenerator = CreateIdentityGenerator(identityLanguage.value ?? 'en');
const identity = 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;
@@ -1462,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,24 +1,30 @@
import React, { useState, useEffect } from 'react';
import { HashRouter as Router, Routes, Route } from 'react-router-dom';
import GlobalStateChangeHandler from '@/entrypoints/popup/components/GlobalStateChangeHandler';
import BottomNav from '@/entrypoints/popup/components/Layout/BottomNav';
import Header from '@/entrypoints/popup/components/Layout/Header';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { NavigationProvider } from '@/entrypoints/popup/context/NavigationContext';
import AuthSettings from '@/entrypoints/popup/pages/AuthSettings';
import CredentialAddEdit from '@/entrypoints/popup/pages/CredentialAddEdit';
import CredentialDetails from '@/entrypoints/popup/pages/CredentialDetails';
import CredentialsList from '@/entrypoints/popup/pages/CredentialsList';
import EmailDetails from '@/entrypoints/popup/pages/EmailDetails';
import EmailsList from '@/entrypoints/popup/pages/EmailsList';
import Home from '@/entrypoints/popup/pages/Home';
import 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';
/**
@@ -43,7 +49,12 @@ const App: React.FC = () => {
// 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' },
@@ -74,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 max-h-[600px]">
{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}
rightButtons={headerButtons}
/>
<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)',
}}
>
<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

@@ -23,6 +23,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
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();
@@ -35,6 +36,15 @@ 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.
@@ -43,7 +53,15 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
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
@@ -73,7 +91,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
}
setEmails(latestMails);
} else {
} else if (isPrivate) {
// For private domains, use existing encrypted email logic
try {
/**
@@ -134,6 +152,11 @@ 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">

View File

@@ -1,41 +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

@@ -1,6 +1,6 @@
import React from 'react';
import { HeaderIcon, HeaderIconType } from './icons/HeaderIcons';
import { HeaderIcon, HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
type HeaderButtonProps = {
onClick: () => void;

View File

@@ -8,7 +8,8 @@ export enum HeaderIconType {
RELOAD = 'reload',
EXTERNAL_LINK = 'external_link',
SAVE = 'save',
PLUS = 'plus'
PLUS = 'plus',
TAB = 'tab'
}
type HeaderIconProps = {
@@ -156,6 +157,28 @@ export const HeaderIcon: React.FC<HeaderIconProps> = ({ type, className = 'w-5 h
>
<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>
)
};

View File

@@ -1,26 +1,25 @@
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';
/**
* 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]);
@@ -32,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

@@ -45,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');
@@ -94,16 +99,19 @@ const Header: React.FC<HeaderProps> = ({
<div className="flex items-center gap-2">
{!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>
<>
{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
)}

View File

@@ -1,52 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
/**
* User menu component.
*/
const UserMenu: React.FC = () => {
const authContext = useAuth();
const navigate = useNavigate();
/**
* Handle logout.
*/
const handleLogout = async () : Promise<void> => {
await authContext.logout();
navigate('/');
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
<span className="text-primary-600 dark:text-primary-400 text-lg font-medium">
{authContext.username?.[0]?.toUpperCase() || '?'}
</span>
</div>
</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>
);
};
export default UserMenu;

View File

@@ -1,30 +1,21 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { AppInfo } from '@/utils/AppInfo';
import { storage } from '#imports';
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.
@@ -41,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

@@ -20,8 +20,8 @@ const Modal: React.FC<IModalProps> = ({
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmText = '',
cancelText = '',
variant = 'default'
}) => {
if (!isOpen) {
@@ -75,20 +75,24 @@ const Modal: React.FC<IModalProps> = ({
{/* Actions */}
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button
type="button"
className={`inline-flex w-full justify-center rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto ${confirmButtonClass}`}
onClick={onConfirm}
>
{confirmText}
</button>
<button
type="button"
className="mt-3 inline-flex w-full justify-center rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:mt-0 sm:w-auto"
onClick={onClose}
>
{cancelText}
</button>
{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>

View File

@@ -40,17 +40,20 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
* @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, isLoggedIn]);
}, [setUsername, setIsLoggedIn]);
/**
* Check for tokens in browser local storage on initial load when this context is mounted.

View File

@@ -12,10 +12,11 @@ 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;
getVaultMetadata: () => Promise<VaultMetadata | null>;
setCurrentVaultRevisionNumber: (revisionNumber: number) => Promise<void>;
hasPendingMigrations: () => Promise<boolean>;
}
const DbContext = createContext<DbContextType | undefined>(undefined);
@@ -76,6 +77,8 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
};
await sendMessage('STORE_VAULT', request, 'background');
return client;
}, []);
const checkStoredVault = useCallback(async () => {
@@ -88,6 +91,7 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
setSqliteClient(client);
setDbInitialized(true);
setDbAvailable(true);
setVaultMetadata({
publicEmailDomains: response.publicEmailDomains ?? [],
privateEmailDomains: response.privateEmailDomains ?? [],
@@ -122,6 +126,16 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
});
}, [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
*/
@@ -137,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');
}, []);
@@ -148,7 +163,8 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
clearDatabase,
getVaultMetadata,
setCurrentVaultRevisionNumber,
}), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, clearDatabase, getVaultMetadata, setCurrentVaultRevisionNumber]);
hasPendingMigrations,
}), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, clearDatabase, getVaultMetadata, setCurrentVaultRevisionNumber, hasPendingMigrations]);
return (
<DbContext.Provider value={contextValue}>

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

@@ -11,6 +11,7 @@ import { VaultUploadResponse as messageVaultUploadResponse } from '@/utils/types
type VaultMutationOptions = {
onSuccess?: () => void;
onError?: (error: Error) => void;
skipSyncCheck?: boolean;
}
/**
@@ -69,9 +70,12 @@ export function useVaultMutate() : {
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');
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
@@ -99,6 +103,13 @@ export function useVaultMutate() : {
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.

View File

@@ -37,6 +37,7 @@ type VaultSyncOptions = {
onError?: (error: string) => void;
onStatus?: (message: string) => void;
_onOffline?: () => void;
onUpgradeRequired?: () => void;
}
/**
@@ -50,7 +51,7 @@ export const useVaultSync = () : {
const webApi = useWebApi();
const syncVault = useCallback(async (options: VaultSyncOptions = {}) => {
const { initialSync = false, onSuccess, onError, onStatus, _onOffline } = options;
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;
@@ -113,21 +114,47 @@ export const useVaultSync = () : {
try {
// Get derived key from background worker
const passwordHashBase64 = await sendMessage('GET_DERIVED_KEY', {}, 'background') as string;
await dbContext.initializeDatabase(vaultResponseJson as VaultResponse, passwordHashBase64);
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 {
} 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 problem persists please logout and login again.');
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.

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,6 +1,8 @@
import React, { useState, useEffect } from 'react';
import * as Yup from 'yup';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { AppInfo } from '@/utils/AppInfo';
import { GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, DISABLED_SITES_KEY, VAULT_LOCKED_DISMISS_UNTIL_KEY } from '@/utils/Constants';
@@ -55,6 +57,7 @@ const AuthSettings: React.FC = () => {
const [customClientUrl, setCustomClientUrl] = useState<string>('');
const [isGloballyEnabled, setIsGloballyEnabled] = useState<boolean>(true);
const [errors, setErrors] = useState<{ apiUrl?: string; clientUrl?: string }>({});
const { setIsInitialLoading } = useLoading();
useEffect(() => {
/**
@@ -83,10 +86,11 @@ const AuthSettings: React.FC = () => {
} else {
setSelectedOption(DEFAULT_OPTIONS[0].value);
}
setIsInitialLoading(false);
};
loadStoredSettings();
}, []);
}, [setIsInitialLoading]);
/**
* Handle option change

View File

@@ -4,11 +4,12 @@ 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 { 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';
@@ -24,6 +25,13 @@ 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.
*/
@@ -67,7 +75,7 @@ const CredentialAddEdit: React.FC = () => {
const [mode, setMode] = useState<CredentialMode>('random');
const { setHeaderButtons } = useHeaderButtons();
const { setIsInitialLoading } = useLoading();
const [localLoading, setLocalLoading] = useState(false);
const [localLoading, setLocalLoading] = useState(true);
const [showPassword, setShowPassword] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const webApi = useWebApi();
@@ -94,19 +102,128 @@ const CredentialAddEdit: React.FC = () => {
}
});
/**
* 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 || !id) {
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;
}
@@ -122,16 +239,19 @@ const CredentialAddEdit: React.FC = () => {
});
setMode('manual');
setIsInitialLoading(false);
// On create mode, focus the service name field after a short delay to ensure the component is mounted
// 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]);
}, [dbContext.sqliteClient, id, navigate, setIsInitialLoading, setValue, loadPersistedValues]);
/**
* Handle the delete button click.
@@ -148,10 +268,11 @@ const CredentialAddEdit: React.FC = () => {
* Navigate to the credentials list page on success.
*/
onSuccess: () => {
void clearPersistedValues();
navigate('/credentials');
}
});
}, [id, executeVaultMutation, dbContext.sqliteClient, navigate]);
}, [id, executeVaultMutation, dbContext.sqliteClient, navigate, clearPersistedValues]);
/**
* Initialize the identity and password generators with settings from user's vault.
@@ -176,7 +297,11 @@ const CredentialAddEdit: React.FC = () => {
const generateRandomAlias = useCallback(async () => {
const { identityGenerator, passwordGenerator } = await initializeGenerators();
const identity = identityGenerator.generateRandomIdentity();
// 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();
@@ -312,6 +437,7 @@ const CredentialAddEdit: React.FC = () => {
* 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.
@@ -322,7 +448,7 @@ const CredentialAddEdit: React.FC = () => {
}
},
});
}, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate, mode, watch, generateRandomAlias, webApi]);
}, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate, mode, watch, generateRandomAlias, webApi, clearPersistedValues]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
@@ -380,6 +506,7 @@ const CredentialAddEdit: React.FC = () => {
title="Delete Credential"
message="Are you sure you want to delete this credential? This action cannot be undone."
confirmText="Delete"
cancelText="Cancel"
variant="danger"
/>

View File

@@ -10,10 +10,11 @@ import {
NotesBlock
} from '@/entrypoints/popup/components/CredentialDetails';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/icons/HeaderIcons';
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';
@@ -28,30 +29,11 @@ const CredentialDetails: React.FC = (): React.ReactElement => {
const { setIsInitialLoading } = useLoading();
const { setHeaderButtons } = useHeaderButtons();
/**
* Check if the current page is an 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 = useCallback((): 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#/credentials/${id}`,
'CredentialDetails',
`width=${width},height=${height},left=${left},top=${top},popup=true`
);
window.close();
PopoutUtility.openInNewPopup(`/credentials/${id}`);
}, [id]);
/**
@@ -62,7 +44,7 @@ const CredentialDetails: React.FC = (): React.ReactElement => {
}, [id, navigate]);
useEffect(() => {
if (isPopup()) {
if (PopoutUtility.isPopup()) {
window.history.replaceState({}, '', `popup.html#/credentials`);
window.history.pushState({}, '', `popup.html#/credentials/${id}`);
}
@@ -89,11 +71,13 @@ const CredentialDetails: React.FC = (): React.ReactElement => {
useEffect((): (() => void) => {
const headerButtonsJSX = (
<div className="flex items-center gap-2">
<HeaderButton
onClick={openInNewPopup}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
{!PopoutUtility.isPopup() && (
<HeaderButton
onClick={openInNewPopup}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
)}
<HeaderButton
onClick={handleEdit}
title="Edit credential"
@@ -124,10 +108,10 @@ const CredentialDetails: React.FC = (): React.ReactElement => {
email={credential.Alias.Email}
/>
)}
<NotesBlock notes={credential.Notes} />
<TotpBlock credentialId={credential.Id} />
<LoginCredentialsBlock credential={credential} />
<AliasBlock credential={credential} />
<NotesBlock notes={credential.Notes} />
</div>
);
};

View File

@@ -3,7 +3,7 @@ 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 { 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';
@@ -11,6 +11,7 @@ import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsConte
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync';
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
import type { Credential } from '@/utils/dist/shared/models/vault';
@@ -70,13 +71,15 @@ const CredentialsList: React.FC = () => {
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('Error refreshing credentials:', err);
await webApi.logout('Error while syncing vault, please re-authenticate.');
navigate('/logout');
}
}, [dbContext, webApi, syncVault]);
}, [dbContext, webApi, syncVault, navigate]);
/**
* Get latest vault from server and refresh the credentials list.
@@ -85,13 +88,19 @@ const CredentialsList: React.FC = () => {
setIsLoading(true);
await onRefresh();
setIsLoading(false);
setIsInitialLoading(false);
}, [onRefresh, setIsLoading, setIsInitialLoading]);
}, [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"
@@ -117,16 +126,12 @@ const CredentialsList: React.FC = () => {
const results = dbContext.sqliteClient?.getAllCredentials() ?? [];
setCredentials(results);
setIsLoading(false);
setIsInitialLoading(false);
}
};
refreshCredentials();
}, [dbContext?.sqliteClient, setIsLoading]);
// Call syncVaultAndRefresh when the page first mounts
useEffect(() => {
syncVaultAndRefresh();
}, [syncVaultAndRefresh]);
}, [dbContext?.sqliteClient, setIsLoading, setIsInitialLoading]);
// Add this function to filter credentials
const filteredCredentials = credentials.filter(cred => {

View File

@@ -8,6 +8,7 @@ import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsConte
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';
@@ -15,7 +16,7 @@ import EncryptionUtility from '@/utils/EncryptionUtility';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import HeaderButton from '../components/HeaderButton';
import { HeaderIconType } from '../components/icons/HeaderIcons';
import { HeaderIconType } from '../components/Icons/HeaderIcons';
/**
* Email details page.
@@ -35,7 +36,7 @@ const EmailDetails: React.FC = (): React.ReactElement => {
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}`);
@@ -76,7 +77,7 @@ const EmailDetails: React.FC = (): React.ReactElement => {
const handleDelete = useCallback(async () : Promise<void> => {
try {
await webApi.delete(`Email/${id}`);
if (isPopup()) {
if (PopoutUtility.isPopup()) {
window.close();
} else {
navigate('/emails');
@@ -87,30 +88,10 @@ const EmailDetails: React.FC = (): React.ReactElement => {
}, [id, webApi, navigate]);
/**
* Check if the current page is an 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.
* Open the email details in a new expanded popup.
*/
const openInNewPopup = useCallback((): void => {
const width = 800;
const height = 1000;
const left = window.screen.width / 2 - width / 2;
const top = window.screen.height / 2 - height / 2;
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();
PopoutUtility.openInNewPopup(`/emails/${id}`);
}, [id]);
/**
@@ -165,11 +146,13 @@ const EmailDetails: React.FC = (): React.ReactElement => {
if (!headerButtonsConfigured) {
const headerButtonsJSX = (
<div className="flex items-center gap-2">
<HeaderButton
onClick={openInNewPopup}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
{!PopoutUtility.isPopup() && (
<HeaderButton
onClick={openInNewPopup}
title="Open in new window"
iconType={HeaderIconType.EXPAND}
/>
)}
<HeaderButton
onClick={() => setShowDeleteModal(true)}
title="Delete email"
@@ -218,6 +201,7 @@ const EmailDetails: React.FC = (): React.ReactElement => {
title="Delete Email"
message="Are you sure you want to delete this email? This action cannot be undone."
confirmText="Delete"
cancelText="Cancel"
variant="danger"
/>

View File

@@ -1,11 +1,15 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
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 { 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';
@@ -18,6 +22,7 @@ import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
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();
@@ -73,6 +78,23 @@ const EmailsList: React.FC = () => {
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,62 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import Login from '@/entrypoints/popup/pages/Login';
import Unlock from '@/entrypoints/popup/pages/Unlock';
import UnlockSuccess from '@/entrypoints/popup/pages/UnlockSuccess';
/**
* 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,13 +1,18 @@
import { Buffer } from 'buffer';
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 { 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 { AppInfo } from '@/utils/AppInfo';
@@ -23,13 +28,15 @@ 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);
@@ -53,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
@@ -130,11 +157,28 @@ const Login: React.FC = () => {
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) {
@@ -197,11 +241,28 @@ const Login: React.FC = () => {
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('');
@@ -234,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">
@@ -291,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

@@ -20,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,17 +1,19 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { sendMessage } from 'webext-bridge/popup';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/icons/HeaderIcons';
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 { 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 { useLoading } from '../context/LoadingContext';
import { storage, browser } from "#imports";
/**
@@ -34,6 +36,8 @@ const Settings: React.FC = () => {
const authContext = useAuth();
const { setHeaderButtons } = useHeaderButtons();
const { setIsInitialLoading } = useLoading();
const { loadApiUrl, getDisplayUrl } = useApiUrl();
const navigate = useNavigate();
const [settings, setSettings] = useState<PopupSettings>({
disabledUrls: [],
temporaryDisabledUrls: {},
@@ -69,6 +73,15 @@ const Settings: React.FC = () => {
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"
@@ -104,6 +117,9 @@ const Settings: React.FC = () => {
await storage.setItem(TEMPORARY_DISABLED_SITES_KEY, cleanedTemporaryDisabledUrls);
}
// Load API URL
await loadApiUrl();
setSettings({
disabledUrls,
temporaryDisabledUrls: cleanedTemporaryDisabledUrls,
@@ -113,7 +129,7 @@ const Settings: React.FC = () => {
isContextMenuEnabled
});
setIsInitialLoading(false);
}, [setIsInitialLoading]);
}, [setIsInitialLoading, loadApiUrl]);
useEffect(() => {
loadSettings();
@@ -233,7 +249,7 @@ const Settings: React.FC = () => {
* Handle logout.
*/
const handleLogout = async () : Promise<void> => {
await authContext.logout();
navigate('/logout', { replace: true });
};
return (
@@ -342,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'
@@ -436,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

@@ -4,10 +4,14 @@ 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 { 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';
@@ -23,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(() => {
/**
@@ -40,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
@@ -84,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);
@@ -100,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">
@@ -114,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

@@ -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

@@ -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.19.0';
public static readonly VERSION = '0.20.0';
/**
* 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,6 +1,8 @@
import initSqlJs, { Database } from 'sql.js';
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.
@@ -385,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.
*/
@@ -526,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');
}
@@ -540,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"
@@ -548,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
@@ -931,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

@@ -29,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;
}
/**
@@ -228,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);
}
@@ -290,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;
}
@@ -330,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.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

@@ -17,7 +17,7 @@ type Identity = {
};
interface IIdentityGenerator {
generateRandomIdentity(): Identity;
generateRandomIdentity(gender?: string | 'random'): Identity;
}
/**
@@ -42,7 +42,7 @@ declare abstract class IdentityGenerator implements IIdentityGenerator {
/**
* Generate a random identity.
*/
generateRandomIdentity(): Identity;
generateRandomIdentity(gender?: string | 'random'): Identity;
}
/**

View File

@@ -172,7 +172,7 @@ var IdentityGenerator = class {
/**
* Generate a random identity.
*/
generateRandomIdentity() {
generateRandomIdentity(gender) {
const identity = {
firstName: "",
lastName: "",
@@ -181,12 +181,26 @@ var IdentityGenerator = 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();

View File

@@ -140,7 +140,7 @@ var IdentityGenerator = class {
/**
* Generate a random identity.
*/
generateRandomIdentity() {
generateRandomIdentity(gender) {
const identity = {
firstName: "",
lastName: "",
@@ -149,12 +149,26 @@ var IdentityGenerator = 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();

View File

@@ -0,0 +1,9 @@
# ⚠️ Auto-Generated Files
This folder contains the output of the shared `vault-sql` 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/vault-sql/src` directory
2. Run the `build.sh` script in the module directory to regenerate the outputs and copy them here.

View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,735 @@
// <auto-generated>
// This file was automatically generated. Do not edit manually.
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
COMPLETE_SCHEMA_SQL: () => COMPLETE_SCHEMA_SQL,
CreateVaultSqlGenerator: () => CreateVaultSqlGenerator,
MIGRATION_SCRIPTS: () => MIGRATION_SCRIPTS,
VAULT_VERSIONS: () => VAULT_VERSIONS,
VaultSqlGenerator: () => VaultSqlGenerator
});
module.exports = __toCommonJS(index_exports);
// src/sql/SqlConstants.ts
var COMPLETE_SCHEMA_SQL = `
\uFEFFCREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
);
BEGIN TRANSACTION;
CREATE TABLE "Aliases" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Aliases" PRIMARY KEY,
"Gender" VARCHAR NULL,
"FirstName" VARCHAR NULL,
"LastName" VARCHAR NULL,
"NickName" VARCHAR NULL,
"BirthDate" TEXT NOT NULL,
"AddressStreet" VARCHAR NULL,
"AddressCity" VARCHAR NULL,
"AddressState" VARCHAR NULL,
"AddressZipCode" VARCHAR NULL,
"AddressCountry" VARCHAR NULL,
"Hobbies" TEXT NULL,
"EmailPrefix" TEXT NULL,
"PhoneMobile" TEXT NULL,
"BankAccountIBAN" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
CREATE TABLE "Services" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Services" PRIMARY KEY,
"Name" TEXT NULL,
"Url" TEXT NULL,
"Logo" BLOB NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
CREATE TABLE "Credentials" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Credentials" PRIMARY KEY,
"AliasId" TEXT NOT NULL,
"Notes" TEXT NULL,
"Username" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
"ServiceId" TEXT NOT NULL,
CONSTRAINT "FK_Credentials_Aliases_AliasId" FOREIGN KEY ("AliasId") REFERENCES "Aliases" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_Credentials_Services_ServiceId" FOREIGN KEY ("ServiceId") REFERENCES "Services" ("Id") ON DELETE CASCADE
);
CREATE TABLE "Attachment" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Attachment" PRIMARY KEY,
"Filename" TEXT NOT NULL,
"Blob" BLOB NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
"CredentialId" TEXT NOT NULL,
CONSTRAINT "FK_Attachment_Credentials_CredentialId" FOREIGN KEY ("CredentialId") REFERENCES "Credentials" ("Id") ON DELETE CASCADE
);
CREATE TABLE "Passwords" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Passwords" PRIMARY KEY,
"Value" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
"CredentialId" TEXT NOT NULL,
CONSTRAINT "FK_Passwords_Credentials_CredentialId" FOREIGN KEY ("CredentialId") REFERENCES "Credentials" ("Id") ON DELETE CASCADE
);
CREATE INDEX "IX_Attachment_CredentialId" ON "Attachment" ("CredentialId");
CREATE INDEX "IX_Credentials_AliasId" ON "Credentials" ("AliasId");
CREATE INDEX "IX_Credentials_ServiceId" ON "Credentials" ("ServiceId");
CREATE INDEX "IX_Passwords_CredentialId" ON "Passwords" ("CredentialId");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240708094944_1.0.0-InitialMigration', '9.0.4');
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240708224522_1.0.1-EmptyTestMigration', '9.0.4');
ALTER TABLE "Aliases" RENAME COLUMN "EmailPrefix" TO "Email";
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240711204207_1.0.2-ChangeEmailColumn', '9.0.4');
CREATE TABLE "EncryptionKeys" (
"Id" TEXT NOT NULL CONSTRAINT "PK_EncryptionKeys" PRIMARY KEY,
"PublicKey" TEXT NOT NULL,
"PrivateKey" TEXT NOT NULL,
"IsPrimary" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240729105618_1.1.0-AddPkiTables', '9.0.4');
CREATE TABLE "Settings" (
"Key" TEXT NOT NULL CONSTRAINT "PK_Settings" PRIMARY KEY,
"Value" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240805073413_1.2.0-AddSettingsTable', '9.0.4');
CREATE TABLE "ef_temp_Aliases" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Aliases" PRIMARY KEY,
"BirthDate" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"Email" TEXT NULL,
"FirstName" VARCHAR NULL,
"Gender" VARCHAR NULL,
"LastName" VARCHAR NULL,
"NickName" VARCHAR NULL,
"UpdatedAt" TEXT NOT NULL
);
INSERT INTO "ef_temp_Aliases" ("Id", "BirthDate", "CreatedAt", "Email", "FirstName", "Gender", "LastName", "NickName", "UpdatedAt")
SELECT "Id", "BirthDate", "CreatedAt", "Email", "FirstName", "Gender", "LastName", "NickName", "UpdatedAt"
FROM "Aliases";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "Aliases";
ALTER TABLE "ef_temp_Aliases" RENAME TO "Aliases";
COMMIT;
PRAGMA foreign_keys = 1;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240805122422_1.3.0-UpdateIdentityStructure', '9.0.4');
BEGIN TRANSACTION;
CREATE TABLE "ef_temp_Credentials" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Credentials" PRIMARY KEY,
"AliasId" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"Notes" TEXT NULL,
"ServiceId" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
"Username" TEXT NULL,
CONSTRAINT "FK_Credentials_Aliases_AliasId" FOREIGN KEY ("AliasId") REFERENCES "Aliases" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_Credentials_Services_ServiceId" FOREIGN KEY ("ServiceId") REFERENCES "Services" ("Id") ON DELETE CASCADE
);
INSERT INTO "ef_temp_Credentials" ("Id", "AliasId", "CreatedAt", "Notes", "ServiceId", "UpdatedAt", "Username")
SELECT "Id", "AliasId", "CreatedAt", "Notes", "ServiceId", "UpdatedAt", "Username"
FROM "Credentials";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "Credentials";
ALTER TABLE "ef_temp_Credentials" RENAME TO "Credentials";
COMMIT;
PRAGMA foreign_keys = 1;
BEGIN TRANSACTION;
CREATE INDEX "IX_Credentials_AliasId" ON "Credentials" ("AliasId");
CREATE INDEX "IX_Credentials_ServiceId" ON "Credentials" ("ServiceId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240812141727_1.3.1-MakeUsernameOptional', '9.0.4');
BEGIN TRANSACTION;
ALTER TABLE "Settings" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Services" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Passwords" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "EncryptionKeys" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Credentials" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Attachment" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Aliases" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240916105320_1.4.0-AddSyncSupport', '9.0.4');
ALTER TABLE "Attachment" RENAME TO "Attachments";
CREATE TABLE "ef_temp_Attachments" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Attachments" PRIMARY KEY,
"Blob" BLOB NOT NULL,
"CreatedAt" TEXT NOT NULL,
"CredentialId" TEXT NOT NULL,
"Filename" TEXT NOT NULL,
"IsDeleted" INTEGER NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_Attachments_Credentials_CredentialId" FOREIGN KEY ("CredentialId") REFERENCES "Credentials" ("Id") ON DELETE CASCADE
);
INSERT INTO "ef_temp_Attachments" ("Id", "Blob", "CreatedAt", "CredentialId", "Filename", "IsDeleted", "UpdatedAt")
SELECT "Id", "Blob", "CreatedAt", "CredentialId", "Filename", "IsDeleted", "UpdatedAt"
FROM "Attachments";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "Attachments";
ALTER TABLE "ef_temp_Attachments" RENAME TO "Attachments";
COMMIT;
PRAGMA foreign_keys = 1;
BEGIN TRANSACTION;
CREATE INDEX "IX_Attachments_CredentialId" ON "Attachments" ("CredentialId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240917191243_1.4.1-RenameAttachmentsPlural', '9.0.4');
BEGIN TRANSACTION;
CREATE TABLE "TotpCodes" (
"Id" TEXT NOT NULL CONSTRAINT "PK_TotpCodes" PRIMARY KEY,
"Name" TEXT NOT NULL,
"SecretKey" TEXT NOT NULL,
"CredentialId" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
"IsDeleted" INTEGER NOT NULL,
CONSTRAINT "FK_TotpCodes_Credentials_CredentialId" FOREIGN KEY ("CredentialId") REFERENCES "Credentials" ("Id") ON DELETE CASCADE
);
CREATE INDEX "IX_TotpCodes_CredentialId" ON "TotpCodes" ("CredentialId");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250310131554_1.5.0-AddTotpCodes', '9.0.4');
COMMIT;
`;
var MIGRATION_SCRIPTS = {
1: `\uFEFFBEGIN TRANSACTION;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240708224522_1.0.1-EmptyTestMigration', '9.0.4');
COMMIT;`,
2: `\uFEFFBEGIN TRANSACTION;
ALTER TABLE "Aliases" RENAME COLUMN "EmailPrefix" TO "Email";
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240711204207_1.0.2-ChangeEmailColumn', '9.0.4');
COMMIT;`,
3: `\uFEFFBEGIN TRANSACTION;
CREATE TABLE "EncryptionKeys" (
"Id" TEXT NOT NULL CONSTRAINT "PK_EncryptionKeys" PRIMARY KEY,
"PublicKey" TEXT NOT NULL,
"PrivateKey" TEXT NOT NULL,
"IsPrimary" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240729105618_1.1.0-AddPkiTables', '9.0.4');
COMMIT;`,
4: `\uFEFFBEGIN TRANSACTION;
CREATE TABLE "Settings" (
"Key" TEXT NOT NULL CONSTRAINT "PK_Settings" PRIMARY KEY,
"Value" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240805073413_1.2.0-AddSettingsTable', '9.0.4');
COMMIT;`,
5: `\uFEFFBEGIN TRANSACTION;
CREATE TABLE "ef_temp_Aliases" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Aliases" PRIMARY KEY,
"BirthDate" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"Email" TEXT NULL,
"FirstName" VARCHAR NULL,
"Gender" VARCHAR NULL,
"LastName" VARCHAR NULL,
"NickName" VARCHAR NULL,
"UpdatedAt" TEXT NOT NULL
);
INSERT INTO "ef_temp_Aliases" ("Id", "BirthDate", "CreatedAt", "Email", "FirstName", "Gender", "LastName", "NickName", "UpdatedAt")
SELECT "Id", "BirthDate", "CreatedAt", "Email", "FirstName", "Gender", "LastName", "NickName", "UpdatedAt"
FROM "Aliases";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "Aliases";
ALTER TABLE "ef_temp_Aliases" RENAME TO "Aliases";
COMMIT;
PRAGMA foreign_keys = 1;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240805122422_1.3.0-UpdateIdentityStructure', '9.0.4');`,
6: `\uFEFFBEGIN TRANSACTION;
CREATE TABLE "ef_temp_Credentials" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Credentials" PRIMARY KEY,
"AliasId" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"Notes" TEXT NULL,
"ServiceId" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
"Username" TEXT NULL,
CONSTRAINT "FK_Credentials_Aliases_AliasId" FOREIGN KEY ("AliasId") REFERENCES "Aliases" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_Credentials_Services_ServiceId" FOREIGN KEY ("ServiceId") REFERENCES "Services" ("Id") ON DELETE CASCADE
);
INSERT INTO "ef_temp_Credentials" ("Id", "AliasId", "CreatedAt", "Notes", "ServiceId", "UpdatedAt", "Username")
SELECT "Id", "AliasId", "CreatedAt", "Notes", "ServiceId", "UpdatedAt", "Username"
FROM "Credentials";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "Credentials";
ALTER TABLE "ef_temp_Credentials" RENAME TO "Credentials";
COMMIT;
PRAGMA foreign_keys = 1;
BEGIN TRANSACTION;
CREATE INDEX "IX_Credentials_AliasId" ON "Credentials" ("AliasId");
CREATE INDEX "IX_Credentials_ServiceId" ON "Credentials" ("ServiceId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240812141727_1.3.1-MakeUsernameOptional', '9.0.4');`,
7: `\uFEFFBEGIN TRANSACTION;
ALTER TABLE "Settings" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Services" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Passwords" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "EncryptionKeys" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Credentials" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Attachment" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Aliases" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240916105320_1.4.0-AddSyncSupport', '9.0.4');
COMMIT;`,
8: `\uFEFFBEGIN TRANSACTION;
ALTER TABLE "Attachment" RENAME TO "Attachments";
CREATE TABLE "ef_temp_Attachments" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Attachments" PRIMARY KEY,
"Blob" BLOB NOT NULL,
"CreatedAt" TEXT NOT NULL,
"CredentialId" TEXT NOT NULL,
"Filename" TEXT NOT NULL,
"IsDeleted" INTEGER NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_Attachments_Credentials_CredentialId" FOREIGN KEY ("CredentialId") REFERENCES "Credentials" ("Id") ON DELETE CASCADE
);
INSERT INTO "ef_temp_Attachments" ("Id", "Blob", "CreatedAt", "CredentialId", "Filename", "IsDeleted", "UpdatedAt")
SELECT "Id", "Blob", "CreatedAt", "CredentialId", "Filename", "IsDeleted", "UpdatedAt"
FROM "Attachments";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "Attachments";
ALTER TABLE "ef_temp_Attachments" RENAME TO "Attachments";
COMMIT;
PRAGMA foreign_keys = 1;
BEGIN TRANSACTION;
CREATE INDEX "IX_Attachments_CredentialId" ON "Attachments" ("CredentialId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240917191243_1.4.1-RenameAttachmentsPlural', '9.0.4');`,
9: `\uFEFFBEGIN TRANSACTION;
CREATE TABLE "TotpCodes" (
"Id" TEXT NOT NULL CONSTRAINT "PK_TotpCodes" PRIMARY KEY,
"Name" TEXT NOT NULL,
"SecretKey" TEXT NOT NULL,
"CredentialId" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
"IsDeleted" INTEGER NOT NULL,
CONSTRAINT "FK_TotpCodes_Credentials_CredentialId" FOREIGN KEY ("CredentialId") REFERENCES "Credentials" ("Id") ON DELETE CASCADE
);
CREATE INDEX "IX_TotpCodes_CredentialId" ON "TotpCodes" ("CredentialId");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250310131554_1.5.0-AddTotpCodes', '9.0.4');
COMMIT;`
};
// src/sql/VaultVersions.ts
var VAULT_VERSIONS = [
{
revision: 1,
version: "1.0.0",
description: "Initial Migration",
releaseVersion: "0.1.0"
},
{
revision: 2,
version: "1.0.1",
description: "Empty Test Migration",
releaseVersion: "0.2.0"
},
{
revision: 3,
version: "1.0.2",
description: "Change Email Column",
releaseVersion: "0.3.0"
},
{
revision: 4,
version: "1.1.0",
description: "Add Pki Tables",
releaseVersion: "0.4.0"
},
{
revision: 5,
version: "1.2.0",
description: "Add Settings Table",
releaseVersion: "0.4.0"
},
{
revision: 6,
version: "1.3.0",
description: "Update Identity Structure",
releaseVersion: "0.5.0"
},
{
revision: 7,
version: "1.3.1",
description: "Make Username Optional",
releaseVersion: "0.5.0"
},
{
revision: 8,
version: "1.4.0",
description: "Add Sync Support",
releaseVersion: "0.6.0"
},
{
revision: 9,
version: "1.4.1",
description: "Rename Attachments Plural",
releaseVersion: "0.6.0"
},
{
revision: 10,
version: "1.5.0",
description: "Add 2FA Tokens to credentials",
releaseVersion: "0.14.0"
}
];
// src/sql/VaultSqlGenerator.ts
var VaultSqlGenerator = class {
/**
* Get SQL commands to create a new vault with the latest schema
*/
getCreateVaultSql() {
try {
const sqlCommands = [
COMPLETE_SCHEMA_SQL
];
return {
success: true,
sqlCommands,
version: VAULT_VERSIONS[VAULT_VERSIONS.length - 1].version,
migrationNumber: VAULT_VERSIONS[VAULT_VERSIONS.length - 1].revision
};
} catch (error) {
return {
success: false,
sqlCommands: [],
version: "0.0.0",
migrationNumber: 0,
error: error instanceof Error ? error.message : "Unknown error creating vault SQL"
};
}
}
/**
* Get SQL commands to upgrade vault from current version to target version
*/
getUpgradeVaultSql(currentMigrationNumber, targetMigrationNumber) {
try {
const targetMigration = targetMigrationNumber ?? VAULT_VERSIONS[VAULT_VERSIONS.length - 1].revision;
const targetVersionInfo = VAULT_VERSIONS.find((v) => v.revision === targetMigration);
if (!targetVersionInfo) {
return {
success: false,
sqlCommands: [],
version: "0.0.0",
migrationNumber: 0,
error: `Target migration number ${targetMigration} not found`
};
}
if (currentMigrationNumber >= targetMigration) {
return {
success: true,
sqlCommands: [],
version: targetVersionInfo.version,
migrationNumber: targetMigration
};
}
const migrationsToApply = VAULT_VERSIONS.filter(
(v) => v.revision > currentMigrationNumber && v.revision <= targetMigration
);
const sqlCommands = [];
for (const migration of migrationsToApply) {
const migrationKey = migration.revision - 1;
const migrationSql = MIGRATION_SCRIPTS[migrationKey];
if (migrationSql) {
sqlCommands.push(migrationSql);
}
}
return {
success: true,
sqlCommands,
version: targetVersionInfo.version,
migrationNumber: targetMigration
};
} catch (error) {
return {
success: false,
sqlCommands: [],
version: "0.0.0",
migrationNumber: 0,
error: error instanceof Error ? error.message : "Unknown error generating upgrade SQL"
};
}
}
/**
* Get SQL commands to upgrade vault to latest version
*/
getUpgradeToLatestSql(currentMigrationNumber) {
return this.getUpgradeVaultSql(currentMigrationNumber);
}
/**
* Get SQL commands to upgrade vault to a specific version
*/
getUpgradeToVersionSql(currentMigrationNumber, targetVersion) {
const targetVersionInfo = VAULT_VERSIONS.find((v) => v.version === targetVersion);
if (!targetVersionInfo) {
return {
success: false,
sqlCommands: [],
version: "0.0.0",
migrationNumber: 0,
error: `Target version ${targetVersion} not found`
};
}
return this.getUpgradeVaultSql(currentMigrationNumber, targetVersionInfo.revision);
}
/**
* Get SQL commands to check current vault version
*/
getVersionCheckSql() {
return [
// Check if Settings table exists
"SELECT name FROM sqlite_master WHERE type='table' AND name='Settings';",
// Get vault version
"SELECT Value FROM Settings WHERE Key = 'vault_version' AND IsDeleted = 0 LIMIT 1;",
// Get migration number
"SELECT Value FROM Settings WHERE Key = 'vault_migration_number' AND IsDeleted = 0 LIMIT 1;"
];
}
/**
* Get SQL command to validate vault structure
*/
getVaultValidationSql() {
return `SELECT name FROM sqlite_master WHERE type='table' AND name IN
('Aliases', 'Services', 'Credentials', 'Passwords', 'Attachments', 'EncryptionKeys', 'Settings', 'TotpCodes');`;
}
/**
* Parse vault version information from query results
*/
parseVaultVersionInfo(settingsTableExists, versionResult, migrationResult) {
let currentVersion = "0.0.0";
let currentMigrationNumber = 0;
if (settingsTableExists) {
if (versionResult) {
currentVersion = versionResult;
} else {
currentVersion = "1.0.0";
currentMigrationNumber = 1;
}
if (migrationResult) {
currentMigrationNumber = parseInt(migrationResult, 10);
}
}
const latestVersion = VAULT_VERSIONS[VAULT_VERSIONS.length - 1];
const needsUpgrade = currentMigrationNumber < latestVersion.revision;
const availableUpgrades = VAULT_VERSIONS.filter((v) => v.revision > currentMigrationNumber);
return {
currentVersion,
currentMigrationNumber,
targetVersion: latestVersion.version,
targetMigrationNumber: latestVersion.revision,
needsUpgrade,
availableUpgrades
};
}
/**
* Validate vault structure from table names
*/
validateVaultStructure(tableNames) {
const requiredTables = ["Aliases", "Services", "Credentials", "Passwords", "Attachments", "EncryptionKeys", "Settings", "TotpCodes"];
const foundTables = tableNames.filter((name) => requiredTables.includes(name));
return foundTables.length >= 5;
}
/**
* Get all available vault versions
*/
getAllVersions() {
return [...VAULT_VERSIONS];
}
/**
* Get current/latest vault version info
*/
getLatestVersion() {
return VAULT_VERSIONS[VAULT_VERSIONS.length - 1];
}
/**
* Get specific migration SQL by migration number
*/
getMigrationSql(migrationNumber) {
return MIGRATION_SCRIPTS[migrationNumber];
}
/**
* Get complete schema SQL for creating new vault
*/
getCompleteSchemaSql() {
return COMPLETE_SCHEMA_SQL;
}
};
// src/factories/VaultSqlGeneratorFactory.ts
var CreateVaultSqlGenerator = () => {
return new VaultSqlGenerator();
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
COMPLETE_SCHEMA_SQL,
CreateVaultSqlGenerator,
MIGRATION_SCRIPTS,
VAULT_VERSIONS,
VaultSqlGenerator
});
//# sourceMappingURL=index.js.map

View File

@@ -0,0 +1,705 @@
// <auto-generated>
// This file was automatically generated. Do not edit manually.
// src/sql/SqlConstants.ts
var COMPLETE_SCHEMA_SQL = `
\uFEFFCREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
);
BEGIN TRANSACTION;
CREATE TABLE "Aliases" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Aliases" PRIMARY KEY,
"Gender" VARCHAR NULL,
"FirstName" VARCHAR NULL,
"LastName" VARCHAR NULL,
"NickName" VARCHAR NULL,
"BirthDate" TEXT NOT NULL,
"AddressStreet" VARCHAR NULL,
"AddressCity" VARCHAR NULL,
"AddressState" VARCHAR NULL,
"AddressZipCode" VARCHAR NULL,
"AddressCountry" VARCHAR NULL,
"Hobbies" TEXT NULL,
"EmailPrefix" TEXT NULL,
"PhoneMobile" TEXT NULL,
"BankAccountIBAN" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
CREATE TABLE "Services" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Services" PRIMARY KEY,
"Name" TEXT NULL,
"Url" TEXT NULL,
"Logo" BLOB NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
CREATE TABLE "Credentials" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Credentials" PRIMARY KEY,
"AliasId" TEXT NOT NULL,
"Notes" TEXT NULL,
"Username" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
"ServiceId" TEXT NOT NULL,
CONSTRAINT "FK_Credentials_Aliases_AliasId" FOREIGN KEY ("AliasId") REFERENCES "Aliases" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_Credentials_Services_ServiceId" FOREIGN KEY ("ServiceId") REFERENCES "Services" ("Id") ON DELETE CASCADE
);
CREATE TABLE "Attachment" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Attachment" PRIMARY KEY,
"Filename" TEXT NOT NULL,
"Blob" BLOB NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
"CredentialId" TEXT NOT NULL,
CONSTRAINT "FK_Attachment_Credentials_CredentialId" FOREIGN KEY ("CredentialId") REFERENCES "Credentials" ("Id") ON DELETE CASCADE
);
CREATE TABLE "Passwords" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Passwords" PRIMARY KEY,
"Value" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
"CredentialId" TEXT NOT NULL,
CONSTRAINT "FK_Passwords_Credentials_CredentialId" FOREIGN KEY ("CredentialId") REFERENCES "Credentials" ("Id") ON DELETE CASCADE
);
CREATE INDEX "IX_Attachment_CredentialId" ON "Attachment" ("CredentialId");
CREATE INDEX "IX_Credentials_AliasId" ON "Credentials" ("AliasId");
CREATE INDEX "IX_Credentials_ServiceId" ON "Credentials" ("ServiceId");
CREATE INDEX "IX_Passwords_CredentialId" ON "Passwords" ("CredentialId");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240708094944_1.0.0-InitialMigration', '9.0.4');
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240708224522_1.0.1-EmptyTestMigration', '9.0.4');
ALTER TABLE "Aliases" RENAME COLUMN "EmailPrefix" TO "Email";
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240711204207_1.0.2-ChangeEmailColumn', '9.0.4');
CREATE TABLE "EncryptionKeys" (
"Id" TEXT NOT NULL CONSTRAINT "PK_EncryptionKeys" PRIMARY KEY,
"PublicKey" TEXT NOT NULL,
"PrivateKey" TEXT NOT NULL,
"IsPrimary" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240729105618_1.1.0-AddPkiTables', '9.0.4');
CREATE TABLE "Settings" (
"Key" TEXT NOT NULL CONSTRAINT "PK_Settings" PRIMARY KEY,
"Value" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240805073413_1.2.0-AddSettingsTable', '9.0.4');
CREATE TABLE "ef_temp_Aliases" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Aliases" PRIMARY KEY,
"BirthDate" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"Email" TEXT NULL,
"FirstName" VARCHAR NULL,
"Gender" VARCHAR NULL,
"LastName" VARCHAR NULL,
"NickName" VARCHAR NULL,
"UpdatedAt" TEXT NOT NULL
);
INSERT INTO "ef_temp_Aliases" ("Id", "BirthDate", "CreatedAt", "Email", "FirstName", "Gender", "LastName", "NickName", "UpdatedAt")
SELECT "Id", "BirthDate", "CreatedAt", "Email", "FirstName", "Gender", "LastName", "NickName", "UpdatedAt"
FROM "Aliases";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "Aliases";
ALTER TABLE "ef_temp_Aliases" RENAME TO "Aliases";
COMMIT;
PRAGMA foreign_keys = 1;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240805122422_1.3.0-UpdateIdentityStructure', '9.0.4');
BEGIN TRANSACTION;
CREATE TABLE "ef_temp_Credentials" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Credentials" PRIMARY KEY,
"AliasId" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"Notes" TEXT NULL,
"ServiceId" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
"Username" TEXT NULL,
CONSTRAINT "FK_Credentials_Aliases_AliasId" FOREIGN KEY ("AliasId") REFERENCES "Aliases" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_Credentials_Services_ServiceId" FOREIGN KEY ("ServiceId") REFERENCES "Services" ("Id") ON DELETE CASCADE
);
INSERT INTO "ef_temp_Credentials" ("Id", "AliasId", "CreatedAt", "Notes", "ServiceId", "UpdatedAt", "Username")
SELECT "Id", "AliasId", "CreatedAt", "Notes", "ServiceId", "UpdatedAt", "Username"
FROM "Credentials";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "Credentials";
ALTER TABLE "ef_temp_Credentials" RENAME TO "Credentials";
COMMIT;
PRAGMA foreign_keys = 1;
BEGIN TRANSACTION;
CREATE INDEX "IX_Credentials_AliasId" ON "Credentials" ("AliasId");
CREATE INDEX "IX_Credentials_ServiceId" ON "Credentials" ("ServiceId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240812141727_1.3.1-MakeUsernameOptional', '9.0.4');
BEGIN TRANSACTION;
ALTER TABLE "Settings" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Services" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Passwords" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "EncryptionKeys" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Credentials" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Attachment" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Aliases" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240916105320_1.4.0-AddSyncSupport', '9.0.4');
ALTER TABLE "Attachment" RENAME TO "Attachments";
CREATE TABLE "ef_temp_Attachments" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Attachments" PRIMARY KEY,
"Blob" BLOB NOT NULL,
"CreatedAt" TEXT NOT NULL,
"CredentialId" TEXT NOT NULL,
"Filename" TEXT NOT NULL,
"IsDeleted" INTEGER NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_Attachments_Credentials_CredentialId" FOREIGN KEY ("CredentialId") REFERENCES "Credentials" ("Id") ON DELETE CASCADE
);
INSERT INTO "ef_temp_Attachments" ("Id", "Blob", "CreatedAt", "CredentialId", "Filename", "IsDeleted", "UpdatedAt")
SELECT "Id", "Blob", "CreatedAt", "CredentialId", "Filename", "IsDeleted", "UpdatedAt"
FROM "Attachments";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "Attachments";
ALTER TABLE "ef_temp_Attachments" RENAME TO "Attachments";
COMMIT;
PRAGMA foreign_keys = 1;
BEGIN TRANSACTION;
CREATE INDEX "IX_Attachments_CredentialId" ON "Attachments" ("CredentialId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240917191243_1.4.1-RenameAttachmentsPlural', '9.0.4');
BEGIN TRANSACTION;
CREATE TABLE "TotpCodes" (
"Id" TEXT NOT NULL CONSTRAINT "PK_TotpCodes" PRIMARY KEY,
"Name" TEXT NOT NULL,
"SecretKey" TEXT NOT NULL,
"CredentialId" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
"IsDeleted" INTEGER NOT NULL,
CONSTRAINT "FK_TotpCodes_Credentials_CredentialId" FOREIGN KEY ("CredentialId") REFERENCES "Credentials" ("Id") ON DELETE CASCADE
);
CREATE INDEX "IX_TotpCodes_CredentialId" ON "TotpCodes" ("CredentialId");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250310131554_1.5.0-AddTotpCodes', '9.0.4');
COMMIT;
`;
var MIGRATION_SCRIPTS = {
1: `\uFEFFBEGIN TRANSACTION;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240708224522_1.0.1-EmptyTestMigration', '9.0.4');
COMMIT;`,
2: `\uFEFFBEGIN TRANSACTION;
ALTER TABLE "Aliases" RENAME COLUMN "EmailPrefix" TO "Email";
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240711204207_1.0.2-ChangeEmailColumn', '9.0.4');
COMMIT;`,
3: `\uFEFFBEGIN TRANSACTION;
CREATE TABLE "EncryptionKeys" (
"Id" TEXT NOT NULL CONSTRAINT "PK_EncryptionKeys" PRIMARY KEY,
"PublicKey" TEXT NOT NULL,
"PrivateKey" TEXT NOT NULL,
"IsPrimary" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240729105618_1.1.0-AddPkiTables', '9.0.4');
COMMIT;`,
4: `\uFEFFBEGIN TRANSACTION;
CREATE TABLE "Settings" (
"Key" TEXT NOT NULL CONSTRAINT "PK_Settings" PRIMARY KEY,
"Value" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240805073413_1.2.0-AddSettingsTable', '9.0.4');
COMMIT;`,
5: `\uFEFFBEGIN TRANSACTION;
CREATE TABLE "ef_temp_Aliases" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Aliases" PRIMARY KEY,
"BirthDate" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"Email" TEXT NULL,
"FirstName" VARCHAR NULL,
"Gender" VARCHAR NULL,
"LastName" VARCHAR NULL,
"NickName" VARCHAR NULL,
"UpdatedAt" TEXT NOT NULL
);
INSERT INTO "ef_temp_Aliases" ("Id", "BirthDate", "CreatedAt", "Email", "FirstName", "Gender", "LastName", "NickName", "UpdatedAt")
SELECT "Id", "BirthDate", "CreatedAt", "Email", "FirstName", "Gender", "LastName", "NickName", "UpdatedAt"
FROM "Aliases";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "Aliases";
ALTER TABLE "ef_temp_Aliases" RENAME TO "Aliases";
COMMIT;
PRAGMA foreign_keys = 1;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240805122422_1.3.0-UpdateIdentityStructure', '9.0.4');`,
6: `\uFEFFBEGIN TRANSACTION;
CREATE TABLE "ef_temp_Credentials" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Credentials" PRIMARY KEY,
"AliasId" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"Notes" TEXT NULL,
"ServiceId" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
"Username" TEXT NULL,
CONSTRAINT "FK_Credentials_Aliases_AliasId" FOREIGN KEY ("AliasId") REFERENCES "Aliases" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_Credentials_Services_ServiceId" FOREIGN KEY ("ServiceId") REFERENCES "Services" ("Id") ON DELETE CASCADE
);
INSERT INTO "ef_temp_Credentials" ("Id", "AliasId", "CreatedAt", "Notes", "ServiceId", "UpdatedAt", "Username")
SELECT "Id", "AliasId", "CreatedAt", "Notes", "ServiceId", "UpdatedAt", "Username"
FROM "Credentials";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "Credentials";
ALTER TABLE "ef_temp_Credentials" RENAME TO "Credentials";
COMMIT;
PRAGMA foreign_keys = 1;
BEGIN TRANSACTION;
CREATE INDEX "IX_Credentials_AliasId" ON "Credentials" ("AliasId");
CREATE INDEX "IX_Credentials_ServiceId" ON "Credentials" ("ServiceId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240812141727_1.3.1-MakeUsernameOptional', '9.0.4');`,
7: `\uFEFFBEGIN TRANSACTION;
ALTER TABLE "Settings" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Services" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Passwords" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "EncryptionKeys" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Credentials" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Attachment" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Aliases" ADD "IsDeleted" INTEGER NOT NULL DEFAULT 0;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240916105320_1.4.0-AddSyncSupport', '9.0.4');
COMMIT;`,
8: `\uFEFFBEGIN TRANSACTION;
ALTER TABLE "Attachment" RENAME TO "Attachments";
CREATE TABLE "ef_temp_Attachments" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Attachments" PRIMARY KEY,
"Blob" BLOB NOT NULL,
"CreatedAt" TEXT NOT NULL,
"CredentialId" TEXT NOT NULL,
"Filename" TEXT NOT NULL,
"IsDeleted" INTEGER NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_Attachments_Credentials_CredentialId" FOREIGN KEY ("CredentialId") REFERENCES "Credentials" ("Id") ON DELETE CASCADE
);
INSERT INTO "ef_temp_Attachments" ("Id", "Blob", "CreatedAt", "CredentialId", "Filename", "IsDeleted", "UpdatedAt")
SELECT "Id", "Blob", "CreatedAt", "CredentialId", "Filename", "IsDeleted", "UpdatedAt"
FROM "Attachments";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "Attachments";
ALTER TABLE "ef_temp_Attachments" RENAME TO "Attachments";
COMMIT;
PRAGMA foreign_keys = 1;
BEGIN TRANSACTION;
CREATE INDEX "IX_Attachments_CredentialId" ON "Attachments" ("CredentialId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240917191243_1.4.1-RenameAttachmentsPlural', '9.0.4');`,
9: `\uFEFFBEGIN TRANSACTION;
CREATE TABLE "TotpCodes" (
"Id" TEXT NOT NULL CONSTRAINT "PK_TotpCodes" PRIMARY KEY,
"Name" TEXT NOT NULL,
"SecretKey" TEXT NOT NULL,
"CredentialId" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
"IsDeleted" INTEGER NOT NULL,
CONSTRAINT "FK_TotpCodes_Credentials_CredentialId" FOREIGN KEY ("CredentialId") REFERENCES "Credentials" ("Id") ON DELETE CASCADE
);
CREATE INDEX "IX_TotpCodes_CredentialId" ON "TotpCodes" ("CredentialId");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250310131554_1.5.0-AddTotpCodes', '9.0.4');
COMMIT;`
};
// src/sql/VaultVersions.ts
var VAULT_VERSIONS = [
{
revision: 1,
version: "1.0.0",
description: "Initial Migration",
releaseVersion: "0.1.0"
},
{
revision: 2,
version: "1.0.1",
description: "Empty Test Migration",
releaseVersion: "0.2.0"
},
{
revision: 3,
version: "1.0.2",
description: "Change Email Column",
releaseVersion: "0.3.0"
},
{
revision: 4,
version: "1.1.0",
description: "Add Pki Tables",
releaseVersion: "0.4.0"
},
{
revision: 5,
version: "1.2.0",
description: "Add Settings Table",
releaseVersion: "0.4.0"
},
{
revision: 6,
version: "1.3.0",
description: "Update Identity Structure",
releaseVersion: "0.5.0"
},
{
revision: 7,
version: "1.3.1",
description: "Make Username Optional",
releaseVersion: "0.5.0"
},
{
revision: 8,
version: "1.4.0",
description: "Add Sync Support",
releaseVersion: "0.6.0"
},
{
revision: 9,
version: "1.4.1",
description: "Rename Attachments Plural",
releaseVersion: "0.6.0"
},
{
revision: 10,
version: "1.5.0",
description: "Add 2FA Tokens to credentials",
releaseVersion: "0.14.0"
}
];
// src/sql/VaultSqlGenerator.ts
var VaultSqlGenerator = class {
/**
* Get SQL commands to create a new vault with the latest schema
*/
getCreateVaultSql() {
try {
const sqlCommands = [
COMPLETE_SCHEMA_SQL
];
return {
success: true,
sqlCommands,
version: VAULT_VERSIONS[VAULT_VERSIONS.length - 1].version,
migrationNumber: VAULT_VERSIONS[VAULT_VERSIONS.length - 1].revision
};
} catch (error) {
return {
success: false,
sqlCommands: [],
version: "0.0.0",
migrationNumber: 0,
error: error instanceof Error ? error.message : "Unknown error creating vault SQL"
};
}
}
/**
* Get SQL commands to upgrade vault from current version to target version
*/
getUpgradeVaultSql(currentMigrationNumber, targetMigrationNumber) {
try {
const targetMigration = targetMigrationNumber ?? VAULT_VERSIONS[VAULT_VERSIONS.length - 1].revision;
const targetVersionInfo = VAULT_VERSIONS.find((v) => v.revision === targetMigration);
if (!targetVersionInfo) {
return {
success: false,
sqlCommands: [],
version: "0.0.0",
migrationNumber: 0,
error: `Target migration number ${targetMigration} not found`
};
}
if (currentMigrationNumber >= targetMigration) {
return {
success: true,
sqlCommands: [],
version: targetVersionInfo.version,
migrationNumber: targetMigration
};
}
const migrationsToApply = VAULT_VERSIONS.filter(
(v) => v.revision > currentMigrationNumber && v.revision <= targetMigration
);
const sqlCommands = [];
for (const migration of migrationsToApply) {
const migrationKey = migration.revision - 1;
const migrationSql = MIGRATION_SCRIPTS[migrationKey];
if (migrationSql) {
sqlCommands.push(migrationSql);
}
}
return {
success: true,
sqlCommands,
version: targetVersionInfo.version,
migrationNumber: targetMigration
};
} catch (error) {
return {
success: false,
sqlCommands: [],
version: "0.0.0",
migrationNumber: 0,
error: error instanceof Error ? error.message : "Unknown error generating upgrade SQL"
};
}
}
/**
* Get SQL commands to upgrade vault to latest version
*/
getUpgradeToLatestSql(currentMigrationNumber) {
return this.getUpgradeVaultSql(currentMigrationNumber);
}
/**
* Get SQL commands to upgrade vault to a specific version
*/
getUpgradeToVersionSql(currentMigrationNumber, targetVersion) {
const targetVersionInfo = VAULT_VERSIONS.find((v) => v.version === targetVersion);
if (!targetVersionInfo) {
return {
success: false,
sqlCommands: [],
version: "0.0.0",
migrationNumber: 0,
error: `Target version ${targetVersion} not found`
};
}
return this.getUpgradeVaultSql(currentMigrationNumber, targetVersionInfo.revision);
}
/**
* Get SQL commands to check current vault version
*/
getVersionCheckSql() {
return [
// Check if Settings table exists
"SELECT name FROM sqlite_master WHERE type='table' AND name='Settings';",
// Get vault version
"SELECT Value FROM Settings WHERE Key = 'vault_version' AND IsDeleted = 0 LIMIT 1;",
// Get migration number
"SELECT Value FROM Settings WHERE Key = 'vault_migration_number' AND IsDeleted = 0 LIMIT 1;"
];
}
/**
* Get SQL command to validate vault structure
*/
getVaultValidationSql() {
return `SELECT name FROM sqlite_master WHERE type='table' AND name IN
('Aliases', 'Services', 'Credentials', 'Passwords', 'Attachments', 'EncryptionKeys', 'Settings', 'TotpCodes');`;
}
/**
* Parse vault version information from query results
*/
parseVaultVersionInfo(settingsTableExists, versionResult, migrationResult) {
let currentVersion = "0.0.0";
let currentMigrationNumber = 0;
if (settingsTableExists) {
if (versionResult) {
currentVersion = versionResult;
} else {
currentVersion = "1.0.0";
currentMigrationNumber = 1;
}
if (migrationResult) {
currentMigrationNumber = parseInt(migrationResult, 10);
}
}
const latestVersion = VAULT_VERSIONS[VAULT_VERSIONS.length - 1];
const needsUpgrade = currentMigrationNumber < latestVersion.revision;
const availableUpgrades = VAULT_VERSIONS.filter((v) => v.revision > currentMigrationNumber);
return {
currentVersion,
currentMigrationNumber,
targetVersion: latestVersion.version,
targetMigrationNumber: latestVersion.revision,
needsUpgrade,
availableUpgrades
};
}
/**
* Validate vault structure from table names
*/
validateVaultStructure(tableNames) {
const requiredTables = ["Aliases", "Services", "Credentials", "Passwords", "Attachments", "EncryptionKeys", "Settings", "TotpCodes"];
const foundTables = tableNames.filter((name) => requiredTables.includes(name));
return foundTables.length >= 5;
}
/**
* Get all available vault versions
*/
getAllVersions() {
return [...VAULT_VERSIONS];
}
/**
* Get current/latest vault version info
*/
getLatestVersion() {
return VAULT_VERSIONS[VAULT_VERSIONS.length - 1];
}
/**
* Get specific migration SQL by migration number
*/
getMigrationSql(migrationNumber) {
return MIGRATION_SCRIPTS[migrationNumber];
}
/**
* Get complete schema SQL for creating new vault
*/
getCompleteSchemaSql() {
return COMPLETE_SCHEMA_SQL;
}
};
// src/factories/VaultSqlGeneratorFactory.ts
var CreateVaultSqlGenerator = () => {
return new VaultSqlGenerator();
};
export {
COMPLETE_SCHEMA_SQL,
CreateVaultSqlGenerator,
MIGRATION_SCRIPTS,
VAULT_VERSIONS,
VaultSqlGenerator
};
//# sourceMappingURL=index.mjs.map

View File

@@ -0,0 +1,8 @@
export type IdentitySettingsResponse = {
success: boolean,
error?: string,
settings?: {
language: string,
gender: string
}
};

View File

@@ -6,7 +6,7 @@ export default defineConfig({
manifest: {
name: "AliasVault",
description: "AliasVault Browser AutoFill Extension. Keeping your personal information private.",
version: "0.19.0",
version: "0.20.0",
content_security_policy: {
extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
},

View File

@@ -1,50 +1,24 @@
# Welcome to your Expo app 👋
# Mobile App
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
This folder contains the source code for the mobile app for AliasVault.
## Get started
The mobile app is built using React Native and Expo:
- [React Native](https://reactnative.dev/) is a framework for building native apps using React.
- [Expo](https://expo.dev/) is a platform for React Native that provides tools and services.
1. Install dependencies
```bash
npm install
```
2. Start the app
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
To build and run the mobile app, run the following commands in this directory:
### Install dependencies
```bash
npm run reset-project
npm install
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
### Start the development server
```bash
npx expo start
```
## Learn more
To learn more about developing your project with Expo, look at the following resources:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
## Join the community
Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
This will open the Expo development tools where you can run the app on:
- iOS Simulator
- Android Emulator
- Physical device using Expo Go app

View File

@@ -93,8 +93,8 @@ android {
applicationId 'net.aliasvault.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 5
versionName "0.19.0"
versionCode 10
versionName "0.20.0"
}
signingConfigs {
debug {

View File

@@ -47,37 +47,70 @@ class AutofillService : AutofillService() {
cancellationSignal: CancellationSignal,
callback: FillCallback,
) {
Log.d(TAG, "onFillRequest called")
var callbackCalled = false
// Check if request was cancelled
if (cancellationSignal.isCanceled) {
return
fun safeCallback(response: FillResponse? = null) {
if (!callbackCalled) {
callbackCalled = true
callback.onSuccess(response)
}
}
// Get the autofill contexts for this request
val contexts = request.fillContexts
val context = contexts.last()
val structure = context.structure
try {
Log.d(TAG, "onFillRequest called")
// Find any autofillable fields in the form
val fieldFinder = FieldFinder(structure)
fieldFinder.parseStructure()
// Check if request was cancelled
if (cancellationSignal.isCanceled) {
return
}
// If no password field was found, return an empty response
if (!fieldFinder.foundPasswordField && !fieldFinder.foundUsernameField) {
Log.d(TAG, "No password or username field found, skipping autofill")
callback.onSuccess(null)
return
// Get the autofill contexts for this request
val contexts = request.fillContexts
val context = contexts.last()
val structure = context.structure
// Find any autofillable fields in the form
val fieldFinder = FieldFinder(structure)
fieldFinder.parseStructure()
// If no password field was found, return an empty response
if (!fieldFinder.foundPasswordField && !fieldFinder.foundUsernameField) {
Log.d(TAG, "No password or username field found, skipping autofill")
safeCallback()
return
}
launchActivityForAutofill(fieldFinder) { response -> safeCallback(response) }
} catch (e: Exception) {
Log.e(TAG, "Unexpected error in onFillRequest", e)
// Provide a simple fallback response to prevent white flash
try {
val responseBuilder = FillResponse.Builder()
val presentation = RemoteViews(packageName, R.layout.autofill_dataset_item_logo)
presentation.setTextViewText(R.id.text, "Failed to retrieve, open app")
val dataSetBuilder = Dataset.Builder(presentation)
// Add a click listener to open AliasVault app
val intent = Intent(this@AutofillService, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra("OPEN_CREDENTIALS", true)
}
val pendingIntent = PendingIntent.getActivity(
this@AutofillService,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
dataSetBuilder.setAuthentication(pendingIntent.intentSender)
responseBuilder.addDataset(dataSetBuilder.build())
safeCallback(responseBuilder.build())
} catch (fallbackError: Exception) {
Log.e(TAG, "Error creating fallback response", fallbackError)
safeCallback()
}
}
// If we found a password field but no username field, and we have a last field,
// assume it's the username field
/*if (!fieldFinder.foundUsernameField && fieldFinder.lastField != null) {
fieldFinder.autofillableFields.add(Pair(fieldFinder.lastField!!, FieldType.USERNAME))
Log.d(TAG, "Using last field as username field: ${fieldFinder.lastField}")
}*/
launchActivityForAutofill(fieldFinder, callback)
}
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
@@ -90,7 +123,7 @@ class AutofillService : AutofillService() {
callback.onSuccess()
}
private fun launchActivityForAutofill(fieldFinder: FieldFinder, callback: FillCallback) {
private fun launchActivityForAutofill(fieldFinder: FieldFinder, callback: (FillResponse?) -> Unit) {
Log.d(TAG, "Launching activity for autofill authentication")
// Get the app/website information from assist structure.
@@ -100,7 +133,7 @@ class AutofillService : AutofillService() {
// Ignore requests from our own unlock page as this would cause a loop
if (appInfo == "net.aliasvault.app") {
Log.d(TAG, "Skipping autofill request from AliasVault app itself")
callback.onSuccess(null)
callback(null)
return
}
@@ -116,7 +149,7 @@ class AutofillService : AutofillService() {
if (result.isEmpty()) {
// No credentials available
Log.d(TAG, "No credentials available")
callback.onSuccess(null)
callback(null)
return
}
@@ -153,16 +186,22 @@ class AutofillService : AutofillService() {
responseBuilder.addDataset(createOpenAppDataset(fieldFinder))
}
callback.onSuccess(responseBuilder.build())
callback(responseBuilder.build())
} catch (e: Exception) {
Log.e(TAG, "Error parsing credentials", e)
callback.onSuccess(null)
// Show "Failed to retrieve, open app" option instead of failing
val responseBuilder = FillResponse.Builder()
responseBuilder.addDataset(createFailedToRetrieveDataset(fieldFinder))
callback(responseBuilder.build())
}
}
override fun onError(e: Exception) {
Log.e(TAG, "Error getting credentials", e)
callback.onSuccess(null)
// Show "Failed to retrieve, open app" option instead of failing
val responseBuilder = FillResponse.Builder()
responseBuilder.addDataset(createFailedToRetrieveDataset(fieldFinder))
callback(responseBuilder.build())
}
})
) {
@@ -178,7 +217,7 @@ class AutofillService : AutofillService() {
val responseBuilder = FillResponse.Builder()
responseBuilder.addDataset(createVaultLockedDataset(fieldFinder))
callback.onSuccess(responseBuilder.build())
callback(responseBuilder.build())
}
/**
@@ -424,4 +463,45 @@ class AutofillService : AutofillService() {
return dataSetBuilder.build()
}
/**
* Create a dataset for the "failed to retrieve" option.
* @param fieldFinder The field finder
* @return The dataset
*/
private fun createFailedToRetrieveDataset(fieldFinder: FieldFinder): Dataset {
// Create presentation for the "failed to retrieve" option
val presentation = RemoteViews(packageName, R.layout.autofill_dataset_item_logo)
presentation.setTextViewText(
R.id.text,
"Failed to retrieve, open app",
)
val dataSetBuilder = Dataset.Builder(presentation)
// Create deep link URL
val deepLinkUrl = "net.aliasvault.app://reinitialize"
// Add a click listener to open AliasVault app with deep link
val intent = Intent(Intent.ACTION_VIEW).apply {
data = android.net.Uri.parse(deepLinkUrl)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val pendingIntent = PendingIntent.getActivity(
this@AutofillService,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
dataSetBuilder.setAuthentication(pendingIntent.intentSender)
// Add a placeholder value to both username and password fields to satisfy the requirement that at least one value must be set
if (fieldFinder.autofillableFields.isNotEmpty()) {
for (field in fieldFinder.autofillableFields) {
dataSetBuilder.setValue(field.first, AutofillValue.forText(""))
}
}
return dataSetBuilder.build()
}
}

View File

@@ -383,6 +383,22 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
}
}
/**
* Execute a raw SQL query on the vault without parameters.
* @param query The raw SQL query
* @param promise The promise to resolve
*/
@ReactMethod
override fun executeRaw(query: String, promise: Promise) {
try {
vaultStore.executeRaw(query)
promise.resolve(null)
} catch (e: Exception) {
Log.e(TAG, "Error executing raw query", e)
promise.reject("ERR_EXECUTE_RAW", "Failed to execute raw query: ${e.message}", e)
}
}
/**
* Begin a transaction on the vault.
* @param promise The promise to resolve

View File

@@ -380,6 +380,33 @@ class VaultStore(
return 0
}
/**
* Execute a raw SQL command on the vault without parameters (for DDL operations like CREATE TABLE).
* @param query The SQL query
*/
fun executeRaw(query: String) {
dbConnection?.let { db ->
// Split the query by semicolons to handle multiple statements
val statements = query.split(";")
for (statement in statements) {
// Remove problematic invisible characters from string
val trimmedStatement = statement.smartTrim()
// Skip empty statements and transaction control statements (handled externally)
if (trimmedStatement.isEmpty() ||
trimmedStatement.uppercase().startsWith("BEGIN") ||
trimmedStatement.uppercase().startsWith("COMMIT") ||
trimmedStatement.uppercase().startsWith("ROLLBACK")
) {
continue
}
db.execSQL(trimmedStatement)
}
}
}
/**
* Begin a SQL transaction on the vault.
*/
@@ -950,4 +977,13 @@ class VaultStore(
Log.e(TAG, "Error parsing date: $dateString")
return null
}
/**
* Remove problematic invisible characters from string.
* @return The trimmed string
*/
private fun String.smartTrim(): String {
val invisible = "[\\uFEFF\\u200B\\u00A0\\u202A-\\u202E\\u2060\\u180E]"
return this.replace(Regex("^($invisible)+|($invisible)+$"), "").trim()
}
}

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "AliasVault",
"slug": "AliasVault",
"version": "0.19.0",
"version": "0.20.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "net.aliasvault.app",

View File

@@ -132,9 +132,9 @@ export default function CredentialDetailsScreen() : React.ReactNode {
</ThemedView>
<EmailPreview email={credential.Alias.Email} />
<TotpSection credential={credential} />
<NotesSection credential={credential} />
<LoginCredentials credential={credential} />
<AliasDetails credential={credential} />
<NotesSection credential={credential} />
</ThemedScrollView>
</ThemedContainer>
);

View File

@@ -157,7 +157,12 @@ export default function AddEditCredentialScreen() : React.ReactNode {
const generateRandomAlias = useCallback(async (): Promise<void> => {
const { identityGenerator, passwordGenerator } = await initializeGenerators();
const identity = identityGenerator.generateRandomIdentity();
// Get gender preference from database
const genderPreference = await dbContext.sqliteClient!.getDefaultIdentityGender();
// Generate identity with gender preference
const identity = identityGenerator.generateRandomIdentity(genderPreference);
const password = passwordGenerator.generateRandomPassword();
const defaultEmailDomain = await dbContext.sqliteClient!.getDefaultEmailDomain();
const email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix;
@@ -316,7 +321,14 @@ export default function AddEditCredentialScreen() : React.ReactNode {
const generateRandomUsername = async () : Promise<void> => {
try {
const { identityGenerator } = await initializeGenerators();
const identity = identityGenerator.generateRandomIdentity();
// Get gender preference from database
const genderPreference = await dbContext.sqliteClient!.getDefaultIdentityGender();
// Generate identity with gender preference
const identity = identityGenerator.generateRandomIdentity(genderPreference);
// Set the username to the identity's nickname
setValue('Username', identity.nickName);
} catch (error) {
console.error('Error generating random username:', error);

View File

@@ -175,6 +175,12 @@ export default function CredentialsScreen() : React.ReactNode {
// Logout user
await webApi.logout(error);
},
/**
* On upgrade required.
*/
onUpgradeRequired: () : void => {
router.replace('/upgrade');
},
});
} catch (err) {
console.error('Error refreshing credentials:', err);
@@ -186,7 +192,7 @@ export default function CredentialsScreen() : React.ReactNode {
text2: err instanceof Error ? err.message : 'Unknown error',
});
}
}, [syncVault, loadCredentials, setIsLoadingCredentials, setRefreshing, webApi, authContext]);
}, [syncVault, loadCredentials, setIsLoadingCredentials, setRefreshing, webApi, authContext, router]);
useEffect(() => {
if (!isAuthenticated || !isDatabaseAvailable) {

View File

@@ -56,6 +56,13 @@ export default function SettingsLayout(): React.ReactNode {
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="identity-generator"
options={{
title: 'Identity Generator',
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="security/index"
options={{

View File

@@ -0,0 +1,189 @@
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from 'expo-router';
import { useState, useCallback } from 'react';
import { StyleSheet, View, Alert, TouchableOpacity } from 'react-native';
import { useColors } from '@/hooks/useColorScheme';
import { useVaultMutate } from '@/hooks/useVaultMutate';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
import { ThemedText } from '@/components/themed/ThemedText';
import { useDb } from '@/context/DbContext';
const LANGUAGE_OPTIONS = [
{ label: 'English', value: 'en' },
{ label: 'Dutch', value: 'nl' }
];
const GENDER_OPTIONS = [
{ label: 'Random', value: 'random' },
{ label: 'Male', value: 'male' },
{ label: 'Female', value: 'female' }
];
/**
* Identity Generator Settings screen.
*/
export default function IdentityGeneratorSettingsScreen(): React.ReactNode {
const colors = useColors();
const dbContext = useDb();
const { executeVaultMutation } = useVaultMutate();
const [language, setLanguage] = useState<string>('en');
const [gender, setGender] = useState<string>('random');
useFocusEffect(
useCallback(() => {
/**
* Load the identity generator settings.
*/
const loadSettings = async (): Promise<void> => {
try {
const [currentLanguage, currentGender] = await Promise.all([
dbContext.sqliteClient!.getDefaultIdentityLanguage(),
dbContext.sqliteClient!.getDefaultIdentityGender()
]);
setLanguage(currentLanguage);
setGender(currentGender);
} catch (error) {
console.error('Error loading identity generator settings:', error);
Alert.alert('Error', 'Failed to load identity generator settings.');
}
};
loadSettings();
}, [dbContext.sqliteClient])
);
/**
* Handle language change.
*/
const handleLanguageChange = useCallback(async (newLanguage: string): Promise<void> => {
try {
executeVaultMutation(async () => {
// Update the default language setting
await dbContext.sqliteClient!.updateSetting('DefaultIdentityLanguage', newLanguage);
});
setLanguage(newLanguage);
} catch (error) {
console.error('Error updating language setting:', error);
Alert.alert('Error', 'Failed to update language setting.');
}
}, [executeVaultMutation, dbContext.sqliteClient]);
/**
* Handle gender change.
*/
const handleGenderChange = useCallback(async (newGender: string): Promise<void> => {
try {
executeVaultMutation(async () => {
await dbContext.sqliteClient!.updateSetting('DefaultIdentityGender', newGender);
});
setGender(newGender);
} catch (error) {
console.error('Error updating gender setting:', error);
Alert.alert('Error', 'Failed to update gender setting.');
}
}, [executeVaultMutation, dbContext.sqliteClient]);
const styles = StyleSheet.create({
descriptionText: {
color: colors.textMuted,
fontSize: 14,
lineHeight: 20,
},
headerText: {
color: colors.textMuted,
fontSize: 13,
marginBottom: 8,
},
option: {
alignItems: 'center',
borderBottomColor: colors.accentBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
flexDirection: 'row',
paddingHorizontal: 16,
paddingVertical: 14,
},
optionContainer: {
backgroundColor: colors.accentBackground,
borderRadius: 10,
marginTop: 8,
},
optionLast: {
borderBottomWidth: 0,
},
optionText: {
color: colors.text,
flex: 1,
fontSize: 16,
},
sectionTitle: {
color: colors.text,
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
marginTop: 16,
},
selectedIcon: {
color: colors.primary,
marginLeft: 8,
},
});
return (
<ThemedContainer>
<ThemedScrollView>
<ThemedText style={styles.headerText}>
Configure the default language and gender preference for generating new identities.
</ThemedText>
<ThemedText style={styles.sectionTitle}>Language</ThemedText>
<ThemedText style={styles.descriptionText}>
Set the language that will be used when generating new identities.
</ThemedText>
<View style={styles.optionContainer}>
{LANGUAGE_OPTIONS.map((option, index) => {
const isLast = index === LANGUAGE_OPTIONS.length - 1;
return (
<TouchableOpacity
key={option.value}
style={[styles.option, isLast && styles.optionLast]}
onPress={() => handleLanguageChange(option.value)}
>
<ThemedText style={styles.optionText}>{option.label}</ThemedText>
{language === option.value && (
<Ionicons name="checkmark" size={20} style={styles.selectedIcon} />
)}
</TouchableOpacity>
);
})}
</View>
<ThemedText style={styles.sectionTitle}>Gender</ThemedText>
<ThemedText style={styles.descriptionText}>
Set the gender preference for generating new identities.
</ThemedText>
<View style={styles.optionContainer}>
{GENDER_OPTIONS.map((option, index) => {
const isLast = index === GENDER_OPTIONS.length - 1;
return (
<TouchableOpacity
key={option.value}
style={[styles.option, isLast && styles.optionLast]}
onPress={() => handleGenderChange(option.value)}
>
<ThemedText style={styles.optionText}>{option.label}</ThemedText>
{gender === option.value && (
<Ionicons name="checkmark" size={20} style={styles.selectedIcon} />
)}
</TouchableOpacity>
);
})}
</View>
</ThemedScrollView>
</ThemedContainer>
);
}

View File

@@ -3,6 +3,7 @@ import { router, useFocusEffect } from 'expo-router';
import { useRef, useState, useCallback } from 'react';
import { StyleSheet, View, ScrollView, TouchableOpacity, Animated, Platform, Alert } from 'react-native';
import { useApiUrl } from '@/utils/ApiUrlUtility';
import { AppInfo } from '@/utils/AppInfo';
import { useColors } from '@/hooks/useColorScheme';
@@ -25,6 +26,7 @@ export default function SettingsScreen() : React.ReactNode {
const colors = useColors();
const { getAuthMethodDisplay, shouldShowAutofillReminder } = useAuth();
const { getAutoLockTimeout } = useAuth();
const { loadApiUrl, getDisplayUrl } = useApiUrl();
const scrollY = useRef(new Animated.Value(0)).current;
const scrollViewRef = useRef<ScrollView>(null);
const [autoLockDisplay, setAutoLockDisplay] = useState<string>('');
@@ -73,12 +75,12 @@ export default function SettingsScreen() : React.ReactNode {
* Load all settings data.
*/
const loadData = async () : Promise<void> => {
await Promise.all([loadAutoLockDisplay(), loadAuthMethodDisplay()]);
await Promise.all([loadAutoLockDisplay(), loadAuthMethodDisplay(), loadApiUrl()]);
setIsFirstLoad(false);
};
loadData();
}, [getAutoLockTimeout, getAuthMethodDisplay, setIsFirstLoad])
}, [getAutoLockTimeout, getAuthMethodDisplay, setIsFirstLoad, loadApiUrl])
);
/**
@@ -132,6 +134,13 @@ export default function SettingsScreen() : React.ReactNode {
router.push('/(tabs)/settings/android-autofill');
};
/**
* Handle the identity generator settings press.
*/
const handleIdentityGeneratorPress = () : void => {
router.push('/(tabs)/settings/identity-generator');
};
const styles = StyleSheet.create({
scrollContent: {
paddingBottom: 40,
@@ -315,6 +324,19 @@ export default function SettingsScreen() : React.ReactNode {
</View>
<View style={styles.section}>
<TouchableOpacity
style={styles.settingItem}
onPress={handleIdentityGeneratorPress}
>
<View style={styles.settingItemIcon}>
<Ionicons name="person-outline" size={20} color={colors.text} />
</View>
<View style={styles.settingItemContent}>
<ThemedText style={styles.settingItemText}>Identity Generator</ThemedText>
<Ionicons name="chevron-forward" size={20} color={colors.textMuted} />
</View>
</TouchableOpacity>
<View style={styles.separator} />
<TouchableOpacity
style={styles.settingItem}
onPress={() => router.push('/(tabs)/settings/security')}
@@ -323,7 +345,7 @@ export default function SettingsScreen() : React.ReactNode {
<Ionicons name="shield-checkmark" size={20} color={colors.text} />
</View>
<View style={styles.settingItemContent}>
<ThemedText style={styles.settingItemText}>Security Settings</ThemedText>
<ThemedText style={styles.settingItemText}>Security</ThemedText>
<Ionicons name="chevron-forward" size={20} color={colors.textMuted} />
</View>
</TouchableOpacity>
@@ -344,7 +366,7 @@ export default function SettingsScreen() : React.ReactNode {
</View>
<View style={styles.versionContainer}>
<ThemedText style={styles.versionText}>App version {AppInfo.VERSION}</ThemedText>
<ThemedText style={styles.versionText}>App version {AppInfo.VERSION} ({getDisplayUrl()})</ThemedText>
</View>
</Animated.ScrollView>
</ThemedContainer>

View File

@@ -3,7 +3,7 @@ import { useFonts } from 'expo-font';
import { Href, Stack, useRouter } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect, useRef, useState } from 'react';
import { Linking, StyleSheet, Alert } from 'react-native';
import { Linking, StyleSheet, Alert, Platform } from 'react-native';
import 'react-native-reanimated';
import 'react-native-get-random-values';
import { install } from 'react-native-quick-crypto';
@@ -71,6 +71,14 @@ function RootLayoutNav() : React.ReactNode {
await new Promise(resolve => setTimeout(resolve, 750));
setStatus('Decrypting vault');
await new Promise(resolve => setTimeout(resolve, 750));
// Check if the vault is up to date, if not, redirect to the upgrade page.
if (await dbContext.hasPendingMigrations()) {
setRedirectTarget('/upgrade');
setBootComplete(true);
return;
}
setBootComplete(true);
return;
}
@@ -160,7 +168,14 @@ function RootLayoutNav() : React.ReactNode {
await webApi.logout(error);
setRedirectTarget('/login');
setBootComplete(true);
}
},
/**
* On upgrade required.
*/
onUpgradeRequired: () : void => {
setRedirectTarget('/upgrade');
setBootComplete(true);
},
});
};
@@ -247,7 +262,7 @@ function RootLayoutNav() : React.ReactNode {
screenOptions={{
headerShown: true,
animation: 'none',
headerTransparent: true,
headerTransparent: Platform.OS === 'ios',
headerStyle: {
backgroundColor: colors.accentBackground,
},
@@ -262,6 +277,7 @@ function RootLayoutNav() : React.ReactNode {
<Stack.Screen name="login-settings" options={{ title: 'Login Settings' }} />
<Stack.Screen name="reinitialize" options={{ headerShown: false }} />
<Stack.Screen name="unlock" options={{ headerShown: false }} />
<Stack.Screen name="upgrade" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" options={{ title: 'Not Found' }} />
</Stack>

View File

@@ -1,9 +1,10 @@
import { Redirect } from 'expo-router';
/**
* App index which is the entry point of the app and redirects to the sync screen, which will
* redirect to the login screen if the user is not logged in or to the main tabs screen if the user is logged in.
* App index which is the entry point of the app and redirects to the credentials screen.
* If user is not logged in, they will automatically be redirected to the login screen instead
* by global navigation handlers.
*/
export default function AppIndex() : React.ReactNode {
return <Redirect href={'/credentials'} />
return <Redirect href={'/credentials'} />;
}

View File

@@ -1,11 +1,13 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useState, useEffect } from 'react';
import { StyleSheet, View, Text, SafeAreaView, TextInput, TouchableOpacity, ActivityIndicator } from 'react-native';
import { StyleSheet, View, Text, TextInput, TouchableOpacity, ActivityIndicator } from 'react-native';
import { AppInfo } from '@/utils/AppInfo';
import { useColors } from '@/hooks/useColorScheme';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
import { ThemedView } from '@/components/themed/ThemedView';
type ApiOption = {
@@ -72,13 +74,8 @@ export default function SettingsScreen() : React.ReactNode {
};
const styles = StyleSheet.create({
container: {
backgroundColor: colors.background,
flex: 1,
},
content: {
flex: 1,
padding: 16,
},
formContainer: {
gap: 16,
@@ -138,58 +135,61 @@ export default function SettingsScreen() : React.ReactNode {
if (isLoading) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
<ThemedContainer>
<ThemedScrollView>
<View style={styles.content}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</ThemedScrollView>
</ThemedContainer>
);
}
return (
<SafeAreaView style={styles.container}>
<ThemedView style={styles.content}>
<View style={styles.titleContainer}>
<Text style={styles.title}>API Connection</Text>
</View>
<ThemedContainer>
<ThemedScrollView>
<ThemedView style={styles.content}>
<View style={styles.titleContainer}>
<Text style={styles.title}>API Connection</Text>
</View>
<View style={styles.formContainer}>
{DEFAULT_OPTIONS.map(option => (
<TouchableOpacity
key={option.value}
style={[
styles.optionButton,
selectedOption === option.value && styles.optionButtonSelected
]}
onPress={() => handleOptionChange(option.value)}
>
<Text style={[
styles.optionButtonText,
selectedOption === option.value && styles.optionButtonTextSelected
]}>
{option.label}
</Text>
</TouchableOpacity>
))}
<View style={styles.formContainer}>
{DEFAULT_OPTIONS.map(option => (
<TouchableOpacity
key={option.value}
style={[
styles.optionButton,
selectedOption === option.value && styles.optionButtonSelected
]}
onPress={() => handleOptionChange(option.value)}
>
<Text style={[
styles.optionButtonText,
selectedOption === option.value && styles.optionButtonTextSelected
]}>
{option.label}
</Text>
</TouchableOpacity>
))}
{selectedOption === 'custom' && (
<View>
<Text style={styles.label}>Custom API URL</Text>
<TextInput
style={styles.input}
value={customUrl}
onChangeText={handleCustomUrlChange}
placeholder="https://my-aliasvault-instance.com/api"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
)}
</View>
<Text style={styles.versionText}>Version: {AppInfo.VERSION}</Text>
</ThemedView>
</SafeAreaView>
{selectedOption === 'custom' && (
<View>
<Text style={styles.label}>Custom API URL</Text>
<TextInput
style={styles.input}
value={customUrl}
onChangeText={handleCustomUrlChange}
placeholder="https://my-aliasvault-instance.com/api"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
)}
</View>
<Text style={styles.versionText}>Version: {AppInfo.VERSION}</Text>
</ThemedView>
</ThemedScrollView>
</ThemedContainer>
);
}

View File

@@ -1,14 +1,13 @@
import { Buffer } from 'buffer';
import { MaterialIcons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useFocusEffect } from '@react-navigation/native';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useState, useEffect } from 'react';
import { StyleSheet, View, Text, SafeAreaView, TextInput, TouchableOpacity, ActivityIndicator, Animated, ScrollView, KeyboardAvoidingView, Platform, Dimensions, Alert } from 'react-native';
import { AppInfo } from '@/utils/AppInfo';
import { useApiUrl } from '@/utils/ApiUrlUtility';
import ConversionUtility from '@/utils/ConversionUtility';
import type { EncryptionKeyDerivationParams } from '@/utils/dist/shared/models/metadata';
import type { LoginResponse, VaultResponse } from '@/utils/dist/shared/models/webapi';
@@ -33,19 +32,7 @@ import { useWebApi } from '@/context/WebApiContext';
export default function LoginScreen() : React.ReactNode {
const colors = useColors();
const [fadeAnim] = useState(new Animated.Value(0));
const [apiUrl, setApiUrl] = useState<string>(AppInfo.DEFAULT_API_URL);
/**
* Load the API URL.
*/
const loadApiUrl = async () : Promise<void> => {
const storedUrl = await AsyncStorage.getItem('apiUrl');
if (storedUrl && storedUrl.length > 0) {
setApiUrl(storedUrl);
} else {
setApiUrl(AppInfo.DEFAULT_API_URL);
}
};
const { loadApiUrl, getDisplayUrl } = useApiUrl();
useEffect(() => {
Animated.timing(fadeAnim, {
@@ -54,26 +41,17 @@ export default function LoginScreen() : React.ReactNode {
useNativeDriver: true,
}).start();
loadApiUrl();
}, [fadeAnim]);
}, [fadeAnim, loadApiUrl]);
// Update URL when returning from settings
useFocusEffect(() => {
loadApiUrl();
});
/**
* Get the display URL.
*/
const getDisplayUrl = () : string => {
const cleanUrl = apiUrl.replace('https://', '').replace('/api', '');
return cleanUrl === 'app.aliasvault.net' ? 'aliasvault.net' : cleanUrl;
};
const [credentials, setCredentials] = useState({
username: '',
password: '',
});
const [rememberMe, setRememberMe] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [twoFactorRequired, setTwoFactorRequired] = useState(false);
@@ -192,6 +170,7 @@ export default function LoginScreen() : React.ReactNode {
await dbContext.storeEncryptionKeyDerivationParams(encryptionKeyDerivationParams);
await dbContext.initializeDatabase(vaultResponseJson);
let checkSuccess = true;
/**
* After setting auth tokens, execute a server status check immediately
* which takes care of certain sanity checks such as ensuring client/server
@@ -203,12 +182,33 @@ export default function LoginScreen() : React.ReactNode {
* Handle the status update.
*/
onError: (message) => {
checkSuccess = false;
// Show modal with error message
Alert.alert('Error', message);
webApi.logout(message);
}
setIsLoading(false);
},
/**
* On upgrade required.
*/
onUpgradeRequired: async () : Promise<void> => {
checkSuccess = false;
// Still login to ensure the user is logged in.
await authContext.login();
// But after login, redirect to upgrade screen immediately.
router.replace('/upgrade');
return;
},
});
if (!checkSuccess) {
// If the syncvault checks have failed, we can't continue with the login process.
return;
}
await authContext.login();
authContext.setOfflineMode(false);
@@ -259,7 +259,7 @@ export default function LoginScreen() : React.ReactNode {
const validationResponse = await srpUtil.validateLogin(
ConversionUtility.normalizeUsername(credentials.username),
passwordHashString,
rememberMe,
true,
initiateLoginResponse
);
@@ -334,7 +334,7 @@ export default function LoginScreen() : React.ReactNode {
const validationResponse = await srpUtil.validateLogin2Fa(
ConversionUtility.normalizeUsername(credentials.username),
passwordHashString,
rememberMe,
true,
initiateLoginResponse,
parseInt(twoFactorCode)
);
@@ -506,15 +506,7 @@ export default function LoginScreen() : React.ReactNode {
},
primaryButton: {
backgroundColor: colors.primary,
},
rememberMeContainer: {
alignItems: 'center',
flexDirection: 'row',
gap: 8,
},
rememberMeText: {
color: colors.text,
fontSize: 14,
marginTop: 16,
},
scrollContent: {
flexGrow: 1,
@@ -665,15 +657,6 @@ export default function LoginScreen() : React.ReactNode {
autoCapitalize="none"
/>
</View>
<View style={styles.rememberMeContainer}>
<TouchableOpacity
style={styles.checkbox}
onPress={() => setRememberMe(!rememberMe)}
>
<View style={[styles.checkboxInner, rememberMe && styles.checkboxChecked]} />
</TouchableOpacity>
<Text style={styles.rememberMeText}>Remember me</Text>
</View>
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={handleSubmit}

View File

@@ -95,6 +95,13 @@ export default function ReinitializeScreen() : React.ReactNode {
await new Promise(resolve => setTimeout(resolve, 1000));
setStatus('Decrypting vault');
await new Promise(resolve => setTimeout(resolve, 1000));
// Check if the vault is up to date, if not, redirect to the upgrade page.
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
}
redirectToReturnUrl();
return;
}
@@ -118,6 +125,12 @@ export default function ReinitializeScreen() : React.ReactNode {
return;
}
// If we already have an unlocked vault, we can skip the sync and go straight to the credentials screen
if (await NativeVaultManager.isVaultUnlocked()) {
router.replace('/(tabs)/credentials');
return;
}
// First perform vault sync
await syncVault({
initialSync: true,
@@ -163,7 +176,13 @@ export default function ReinitializeScreen() : React.ReactNode {
}
]
);
}
},
/**
* On upgrade required.
*/
onUpgradeRequired: () : void => {
router.replace('/upgrade');
},
});
};

View File

@@ -23,7 +23,7 @@ import NativeVaultManager from '@/specs/NativeVaultManager';
*/
export default function UnlockScreen() : React.ReactNode {
const { isLoggedIn, username, isBiometricsEnabled } = useAuth();
const { testDatabaseConnection } = useDb();
const dbContext = useDb();
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isBiometricsAvailable, setIsBiometricsAvailable] = useState(false);
@@ -100,7 +100,13 @@ export default function UnlockScreen() : React.ReactNode {
const passwordHashBase64 = Buffer.from(passwordHash).toString('base64');
// Initialize the database with the vault response and password
if (await testDatabaseConnection(passwordHashBase64)) {
if (await dbContext.testDatabaseConnection(passwordHashBase64)) {
// Check if the vault is up to date, if not, redirect to the upgrade page.
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
}
// Navigate to credentials
router.replace('/(tabs)/credentials');
} else {

View File

@@ -0,0 +1,459 @@
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import { useState, useEffect, useCallback } from 'react';
import { StyleSheet, View, TouchableOpacity, Alert, KeyboardAvoidingView, Platform, ScrollView, Dimensions, TouchableWithoutFeedback, Keyboard, Text } from 'react-native';
import type { VaultVersion } from '@/utils/dist/shared/vault-sql';
import { VaultSqlGenerator } from '@/utils/dist/shared/vault-sql';
import { useColors } from '@/hooks/useColorScheme';
import { useVaultMutate } from '@/hooks/useVaultMutate';
import { useVaultSync } from '@/hooks/useVaultSync';
import Logo from '@/assets/images/logo.svg';
import LoadingIndicator from '@/components/LoadingIndicator';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { Avatar } from '@/components/ui/Avatar';
import { useAuth } from '@/context/AuthContext';
import { useDb } from '@/context/DbContext';
import { useWebApi } from '@/context/WebApiContext';
import NativeVaultManager from '@/specs/NativeVaultManager';
/**
* Upgrade screen.
*/
export default function UpgradeScreen() : React.ReactNode {
const { username } = useAuth();
const { sqliteClient } = useDb();
const [isLoading, setIsLoading] = useState(false);
const [currentVersion, setCurrentVersion] = useState<VaultVersion | null>(null);
const [latestVersion, setLatestVersion] = useState<VaultVersion | null>(null);
const [upgradeStatus, setUpgradeStatus] = useState('Preparing upgrade...');
const colors = useColors();
const webApi = useWebApi();
const { executeVaultMutation, isLoading: isVaultMutationLoading, syncStatus } = useVaultMutate();
const { syncVault } = useVaultSync();
/**
* Load version information from the database.
*/
const loadVersionInfo = useCallback(async () => {
try {
if (sqliteClient) {
const current = await sqliteClient.getDatabaseVersion();
const latest = await sqliteClient.getLatestDatabaseVersion();
setCurrentVersion(current);
setLatestVersion(latest);
}
} catch (error) {
console.error('Failed to load version information:', error);
}
}, [sqliteClient]);
useEffect(() => {
loadVersionInfo();
}, [loadVersionInfo]);
/**
* Handle the vault upgrade.
*/
const handleUpgrade = async (): Promise<void> => {
if (!sqliteClient || !currentVersion || !latestVersion) {
Alert.alert('Error', '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()) {
Alert.alert(
'Self-Hosted Server',
"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.",
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Continue Upgrade',
style: 'default',
/**
* Continue upgrade.
*/
onPress: async () : Promise<void> => {
await performUpgrade();
}
}
]
);
} else {
await performUpgrade();
}
};
/**
* Perform the actual vault upgrade.
*/
const performUpgrade = async (): Promise<void> => {
if (!sqliteClient || !currentVersion || !latestVersion) {
Alert.alert('Error', 'Unable to get version information. Please try again.');
return;
}
setIsLoading(true);
setUpgradeStatus('Preparing upgrade...');
try {
// Get upgrade SQL commands from vault-sql shared library
setUpgradeStatus('Generating upgrade SQL...');
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
setUpgradeStatus('Vault is already up to date');
await new Promise(resolve => setTimeout(resolve, 1000));
await handleUpgradeSuccess();
return;
}
// Use the useVaultMutate hook to handle the upgrade and vault upload
await executeVaultMutation(async () => {
// Begin transaction
setUpgradeStatus('Starting database transaction...');
await NativeVaultManager.beginTransaction();
// Execute each SQL command
setUpgradeStatus('Applying database migrations...');
for (let i = 0; i < upgradeResult.sqlCommands.length; i++) {
const sqlCommand = upgradeResult.sqlCommands[i];
setUpgradeStatus(`Applying migration ${i + 1} of ${upgradeResult.sqlCommands.length}...`);
try {
await NativeVaultManager.executeRaw(sqlCommand);
} catch (error) {
console.error(`Error executing SQL command ${i + 1}:`, sqlCommand, error);
await NativeVaultManager.rollbackTransaction();
throw new Error(`Failed to apply migration (${i + 1} of ${upgradeResult.sqlCommands.length})`);
}
}
// Commit transaction
setUpgradeStatus('Committing changes...');
await NativeVaultManager.commitTransaction();
}, {
skipSyncCheck: true, // Skip sync check during upgrade to prevent loop
/**
* Handle successful upgrade completion.
*/
onSuccess: () => {
void handleUpgradeSuccess();
},
/**
* Handle upgrade error.
*/
onError: (error: Error) => {
console.error('Upgrade failed:', error);
Alert.alert('Upgrade Failed', error.message);
}
});
} catch (error) {
console.error('Upgrade failed:', error);
Alert.alert('Upgrade Failed', error instanceof Error ? error.message : 'An unknown error occurred during the upgrade. Please try again.');
} finally {
setIsLoading(false);
setUpgradeStatus('Preparing upgrade...');
}
};
/**
* Handle successful upgrade completion.
*/
const handleUpgradeSuccess = async () : Promise<void> => {
try {
// Sync vault to ensure we have the latest data
await syncVault({
/**
* Handle the status update.
*/
onStatus: (message) => setUpgradeStatus(message),
/**
* Handle successful vault sync and navigate to credentials.
*/
onSuccess: () => {
// Navigate to credentials index
router.replace('/(tabs)/credentials');
},
/**
* Handle sync error and still navigate to credentials.
*/
onError: (error) => {
console.error('Sync error after upgrade:', error);
// Still navigate to credentials even if sync fails
router.replace('/(tabs)/credentials');
}
});
} catch (error) {
console.error('Error during post-upgrade sync:', error);
// Navigate to credentials even if sync fails
router.replace('/(tabs)/credentials');
}
};
/**
* Handle the logout.
*/
const handleLogout = async () : Promise<void> => {
/*
* Clear any stored tokens or session data
* This will be handled by the auth context
*/
await webApi.logout();
router.replace('/login');
};
/**
* Show native dialog with version description.
*/
const showVersionDialog = (): void => {
Alert.alert(
"What's New",
`An upgrade is required to support the following changes:\n\n${latestVersion?.description ?? 'No description available for this version.'}`,
[
{ text: 'Okay', style: 'default' }
]
);
};
const styles = StyleSheet.create({
appName: {
color: colors.text,
fontSize: 32,
fontWeight: 'bold',
textAlign: 'center',
},
avatarContainer: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
marginBottom: 16,
},
button: {
alignItems: 'center',
backgroundColor: colors.primary,
borderRadius: 8,
height: 50,
justifyContent: 'center',
marginBottom: 16,
width: '100%',
},
buttonText: {
color: colors.primarySurfaceText,
fontSize: 16,
fontWeight: '600',
},
container: {
flex: 1,
},
content: {
backgroundColor: colors.accentBackground,
borderRadius: 10,
padding: 20,
width: '100%',
},
currentVersionValue: {
color: colors.primary,
},
gradientContainer: {
height: Dimensions.get('window').height * 0.4,
left: 0,
position: 'absolute',
right: 0,
top: 0,
},
headerSection: {
paddingBottom: 24,
paddingHorizontal: 16,
paddingTop: 24,
},
helpButton: {
alignItems: 'center',
backgroundColor: colors.accentBackground,
borderRadius: 20,
height: 24,
justifyContent: 'center',
marginLeft: 8,
width: 24,
},
helpButtonText: {
color: colors.text,
fontSize: 14,
fontWeight: 'bold',
},
keyboardAvoidingView: {
flex: 1,
},
latestVersionValue: {
color: colors.greenBackground,
},
loadingContainer: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
logoContainer: {
alignItems: 'center',
marginBottom: 8,
},
logoutButton: {
alignSelf: 'center',
justifyContent: 'center',
marginTop: 16,
},
logoutButtonText: {
color: colors.red,
fontSize: 16,
},
mainContent: {
flex: 1,
justifyContent: 'center',
paddingBottom: 40,
paddingHorizontal: 20,
},
scrollContent: {
flexGrow: 1,
},
subtitle: {
color: colors.text,
fontSize: 14,
marginBottom: 24,
opacity: 0.7,
textAlign: 'center',
},
username: {
color: colors.text,
fontSize: 18,
opacity: 0.8,
textAlign: 'center',
},
versionContainer: {
backgroundColor: colors.background,
borderRadius: 8,
marginBottom: 16,
padding: 16,
},
versionHeader: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
marginBottom: 12,
},
versionLabel: {
color: colors.text,
fontSize: 14,
fontWeight: '500',
opacity: 0.7,
},
versionRow: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
},
versionTitle: {
color: colors.text,
fontSize: 18,
fontWeight: '600',
textAlign: 'center',
},
versionValue: {
color: colors.text,
fontSize: 16,
fontWeight: 'bold',
},
});
return (
<ThemedView style={styles.container}>
{(isLoading || isVaultMutationLoading) ? (
<View style={styles.loadingContainer}>
<LoadingIndicator status={syncStatus || upgradeStatus} />
</View>
) : (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardAvoidingView}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<LinearGradient
colors={[colors.loginHeader, colors.background]}
style={styles.gradientContainer}
/>
<View style={styles.mainContent}>
<View style={styles.headerSection}>
<View style={styles.logoContainer}>
<Logo width={80} height={80} />
<Text style={styles.appName}>Upgrade Vault</Text>
</View>
</View>
<View style={styles.content}>
<View style={styles.avatarContainer}>
<Avatar />
<ThemedText style={styles.username}>{username}</ThemedText>
</View>
<ThemedText style={styles.subtitle}>AliasVault has updated and your vault needs to be upgraded. This should only take a few seconds.</ThemedText>
<View style={styles.versionContainer}>
<View style={styles.versionHeader}>
<ThemedText style={styles.versionTitle}>Version Information</ThemedText>
<TouchableOpacity
style={styles.helpButton}
onPress={showVersionDialog}
>
<ThemedText style={styles.helpButtonText}>?</ThemedText>
</TouchableOpacity>
</View>
<View style={styles.versionRow}>
<ThemedText style={styles.versionLabel}>Your vault:</ThemedText>
<ThemedText style={[styles.versionValue, styles.currentVersionValue]}>
{currentVersion?.releaseVersion ?? '...'}
</ThemedText>
</View>
<View style={styles.versionRow}>
<ThemedText style={styles.versionLabel}>New version:</ThemedText>
<ThemedText style={[styles.versionValue, styles.latestVersionValue]}>
{latestVersion?.releaseVersion ?? '...'}
</ThemedText>
</View>
</View>
<TouchableOpacity
style={styles.button}
onPress={handleUpgrade}
disabled={isLoading || isVaultMutationLoading}
>
<ThemedText style={styles.buttonText}>
{isLoading || isVaultMutationLoading ? (syncStatus || 'Upgrading...') : 'Upgrade'}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={styles.logoutButton}
onPress={handleLogout}
>
<ThemedText style={styles.logoutButtonText}>Logout</ThemedText>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
)}
</ThemedView>
);
}

View File

@@ -30,6 +30,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) : React.Rea
const [isSpamOk, setIsSpamOk] = useState(false);
const [isComponentVisible, setIsComponentVisible] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isSupportedDomain, setIsSupportedDomain] = useState(false);
const webApi = useWebApi();
const dbContext = useDb();
const authContext = useAuth();
@@ -48,6 +49,19 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) : React.Rea
return metadata.publicEmailDomains.includes(emailAddress.split('@')[1]);
}, [dbContext]);
/**
* Check if the email is a private domain.
*/
const isPrivateDomain = useCallback(async (emailAddress: string): Promise<boolean> => {
// Get private domains from stored metadata
const metadata = await dbContext?.sqliteClient?.getVaultMetadata();
if (!metadata) {
return false;
}
return metadata.privateEmailDomains.includes(emailAddress.split('@')[1]);
}, [dbContext]);
// Handle app state changes
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextAppState): void => {
@@ -86,7 +100,15 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) : React.Rea
}
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
@@ -116,7 +138,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) : React.Rea
}
setEmails(latestMails);
} else {
} else if (isPrivate) {
// For private domains, use existing encrypted email logic
if (!dbContext?.sqliteClient) {
return;
@@ -186,7 +208,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) : React.Rea
clearInterval(interval);
}
};
}, [email, loading, webApi, dbContext, isPublicDomain, authContext.isOffline, isComponentVisible]);
}, [email, loading, webApi, dbContext, isPublicDomain, isPrivateDomain, authContext.isOffline, isComponentVisible]);
const styles = StyleSheet.create({
date: {
@@ -244,6 +266,11 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) : React.Rea
return null;
}
// Don't render anything if the domain is not supported
if (!isSupportedDomain) {
return null;
}
if (error) {
return (
<ThemedView style={styles.section}>

View File

@@ -13,6 +13,7 @@ type DbContextType = {
storeEncryptionKey: (derivedKey: string) => Promise<void>;
storeEncryptionKeyDerivationParams: (keyDerivationParams: EncryptionKeyDerivationParams) => Promise<void>;
initializeDatabase: (vaultResponse: VaultResponse) => Promise<void>;
hasPendingMigrations: () => Promise<boolean>;
clearDatabase: () => void;
getVaultMetadata: () => Promise<VaultMetadata | null>;
testDatabaseConnection: (derivedKey: string) => Promise<boolean>;
@@ -97,6 +98,17 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
setDbAvailable(true);
}, [sqliteClient, unlockVault]);
/**
* Check if there are any pending migrations. This method also checks if the current vault version is known to the client.
* If the current vault version is not known to the client, the method will throw an exception which causes the app to logout.
*/
const hasPendingMigrations = useCallback(async () => {
const currentVersion = await sqliteClient.getDatabaseVersion();
const latestVersion = await sqliteClient.getLatestDatabaseVersion();
return currentVersion.revision < latestVersion.revision;
}, [sqliteClient]);
const checkStoredVault = useCallback(async () => {
try {
const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase();
@@ -166,7 +178,7 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
// Try to get the database version as a simple test query
const version = await sqliteClient.getDatabaseVersion();
if (version && version.length > 0) {
if (version && version.version && version.version.length > 0) {
return true;
}
@@ -182,13 +194,14 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
dbInitialized,
dbAvailable,
initializeDatabase,
hasPendingMigrations,
clearDatabase,
getVaultMetadata,
testDatabaseConnection,
unlockVault,
storeEncryptionKey,
storeEncryptionKeyDerivationParams,
}), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, clearDatabase, getVaultMetadata, testDatabaseConnection, unlockVault, storeEncryptionKey, storeEncryptionKeyDerivationParams]);
}), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, hasPendingMigrations, clearDatabase, getVaultMetadata, testDatabaseConnection, unlockVault, storeEncryptionKey, storeEncryptionKeyDerivationParams]);
return (
<DbContext.Provider value={contextValue}>

View File

@@ -21,6 +21,7 @@ type VaultPostResponse = {
type VaultMutationOptions = {
onSuccess?: () => void;
onError?: (error: Error) => void;
skipSyncCheck?: boolean;
}
/**
@@ -85,7 +86,7 @@ export function useVaultMutate() : {
client: '', // Empty on purpose, API will not use this for vault updates
updatedAt: new Date().toISOString(),
username: username,
version: await dbContext.sqliteClient!.getDatabaseVersion() ?? '0.0.0'
version: (await dbContext.sqliteClient!.getDatabaseVersion())?.version ?? '0.0.0'
};
}, [dbContext, authContext]);
@@ -115,9 +116,12 @@ export function useVaultMutate() : {
await NativeVaultManager.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');
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
@@ -245,6 +249,13 @@ export function useVaultMutate() : {
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;
}
// If we're in offline mode, try to sync once to see if we can get back online
if (authContext.isOffline) {
await syncVault({

View File

@@ -6,6 +6,7 @@ import type { VaultResponse } from '@/utils/dist/shared/models/webapi';
import { useAuth } from '@/context/AuthContext';
import { useDb } from '@/context/DbContext';
import { useWebApi } from '@/context/WebApiContext';
import NativeVaultManager from '@/specs/NativeVaultManager';
/**
* Utility function to ensure a minimum time has elapsed for an operation
@@ -37,6 +38,7 @@ type VaultSyncOptions = {
onError?: (error: string) => void;
onStatus?: (message: string) => void;
onOffline?: () => void;
onUpgradeRequired?: () => void;
}
/**
@@ -50,7 +52,7 @@ export const useVaultSync = () : {
const webApi = useWebApi();
const syncVault = useCallback(async (options: VaultSyncOptions = {}) => {
const { initialSync = false, onSuccess, onError, onStatus, onOffline } = options;
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;
@@ -112,14 +114,27 @@ export const useVaultSync = () : {
try {
await dbContext.initializeDatabase(vaultResponseJson as VaultResponse);
// 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 NativeVaultManager.isVaultUnlocked() && await dbContext.hasPendingMigrations()) {
onUpgradeRequired?.();
return false;
}
onSuccess?.(true);
return true;
} catch {
// Vault could not be decrypted, throw an error
throw new Error('Vault could not be decrypted, if problem persists please logout and login again.');
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 NativeVaultManager.isVaultUnlocked() && await dbContext.hasPendingMigrations()) {
onUpgradeRequired?.();
return false;
}
await withMinimumDelay(() => Promise.resolve(onSuccess?.(false)), 300, enableDelay);
return false;
} catch (err) {

View File

@@ -1041,7 +1041,7 @@
CODE_SIGN_ENTITLEMENTS = AliasVault/AliasVault.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
@@ -1056,7 +1056,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.19.0;
MARKETING_VERSION = 0.20.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -1081,7 +1081,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = AliasVault/AliasVault.entitlements;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
INFOPLIST_FILE = AliasVault/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = AliasVault;
@@ -1091,7 +1091,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.19.0;
MARKETING_VERSION = 0.20.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -1235,7 +1235,7 @@
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1288,7 +1288,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 8PHW4HN3F7;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1337,7 +1337,7 @@
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1372,7 +1372,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1405,7 +1405,7 @@
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1458,7 +1458,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 8PHW4HN3F7;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1507,7 +1507,7 @@
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1559,7 +1559,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 8PHW4HN3F7;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1610,7 +1610,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = autofill/autofill.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1626,7 +1626,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 0.19.0;
MARKETING_VERSION = 0.20.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
@@ -1655,7 +1655,7 @@
CODE_SIGN_ENTITLEMENTS = autofill/autofill.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1671,7 +1671,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 0.19.0;
MARKETING_VERSION = 0.20.0;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = net.aliasvault.app.autofill;

View File

@@ -47,11 +47,29 @@ public class CredentialProviderViewController: ASCredentialProviderViewControlle
// Only set up the view if we haven't already
if hostingController == nil {
setupView(vaultStore: vaultStore)
do {
try setupView(vaultStore: vaultStore)
} catch {
print("Failed to setup view: \(error)")
let alert = UIAlertController(
title: "Loading Error",
message: "Loading credentials went wrong. Please open the AliasVault app to check for updates.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in
self?.extensionContext.cancelRequest(withError: NSError(
domain: ASExtensionErrorDomain,
code: ASExtensionError.failed.rawValue,
userInfo: [NSLocalizedDescriptionKey: "Failed to load credentials"]
))
})
present(alert, animated: true)
return
}
}
}
private func setupView(vaultStore: VaultStore) {
private func setupView(vaultStore: VaultStore) throws {
// Create the ViewModel with injected behaviors
let viewModel = CredentialProviderViewModel(
loader: {

View File

@@ -45,6 +45,10 @@
[vaultManager executeUpdate:query params:params resolver:resolve rejecter:reject];
}
- (void)executeRaw:(NSString *)query resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
[vaultManager executeRaw:query resolver:resolve rejecter:reject];
}
- (void)beginTransaction:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
[vaultManager beginTransaction:resolve rejecter:reject];
}

View File

@@ -163,6 +163,19 @@ public class VaultManager: NSObject {
}
}
@objc
func executeRaw(_ query: String,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
do {
// Execute the raw query through the vault store
try vaultStore.executeRaw(query)
resolve(nil)
} catch {
reject("RAW_ERROR", "Failed to execute raw query: \(error.localizedDescription)", error)
}
}
@objc
func clearVault() {
do {

View File

@@ -0,0 +1,18 @@
import Foundation
/// This class contains common string extension methods.
extension String {
/// Trims standard and invisible characters only from the beginning and end of the string.
func smartTrim() -> String {
let invisiblePattern = #"^[\u{FEFF}\u{200B}\u{00A0}\u{202A}-\u{202E}\u{2060}\u{180E}]+|[\u{FEFF}\u{200B}\u{00A0}\u{202A}-\u{202E}\u{2060}\u{180E}]+$"#
guard let regex = try? NSRegularExpression(pattern: invisiblePattern, options: []) else {
// Fallback to trimming only whitespace if regex creation fails
return self.trimmingCharacters(in: .whitespacesAndNewlines)
}
let range = NSRange(location: 0, length: self.utf16.count)
let cleaned = regex.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: "")
return cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@@ -74,6 +74,30 @@ extension VaultStore {
return dbConnection.changes
}
/// Execute a raw SQL command on the database without parameters (for DDL operations like CREATE TABLE).
public func executeRaw(_ query: String) throws {
guard let dbConnection = self.dbConnection else {
throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"])
}
// Split the query by semicolons to handle multiple statements
let statements = query.components(separatedBy: ";")
for statement in statements {
let trimmedStatement = statement.smartTrim()
// Skip empty statements and transaction control statements (handled externally)
if trimmedStatement.isEmpty ||
trimmedStatement.uppercased().hasPrefix("BEGIN TRANSACTION") ||
trimmedStatement.uppercased().hasPrefix("COMMIT") ||
trimmedStatement.uppercased().hasPrefix("ROLLBACK") {
continue
}
try dbConnection.execute(trimmedStatement)
}
}
/// Begin a transaction on the database. This is required for all database operations that modify the database.
public func beginTransaction() throws {
guard let dbConnection = self.dbConnection else {

View File

@@ -5,10 +5,17 @@ import VaultModels
public struct CredentialCard: View {
let credential: Credential
let action: () -> Void
let onCopy: () -> Void
@Environment(\.colorScheme) private var colorScheme
@State private var showCopyToast = false
@State private var copyToastMessage = ""
public init(credential: Credential, action: @escaping () -> Void, onCopy: @escaping () -> Void) {
self.credential = credential
self.action = action
self.onCopy = onCopy
}
public var body: some View {
Button(action: action) {
HStack(spacing: 16) {
@@ -42,39 +49,56 @@ public struct CredentialCard: View {
.cornerRadius(8)
}
.contextMenu(menuItems: {
Button(action: {
if let username = credential.username {
if let username = credential.username, !username.isEmpty {
Button(action: {
UIPasteboard.general.string = username
copyToastMessage = "Username copied"
showCopyToast = true
}
}, label: {
Label("Copy Username", systemImage: "person")
})
Button(action: {
if let password = credential.password?.value {
// Delay for 1 second before calling onCopy which dismisses the view
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
onCopy()
}
}, label: {
Label("Copy Username", systemImage: "person")
})
}
if let password = credential.password?.value, !password.isEmpty {
Button(action: {
UIPasteboard.general.string = password
copyToastMessage = "Password copied"
showCopyToast = true
}
}, label: {
Label("Copy Password", systemImage: "key")
})
// Delay for 1 second before calling onCopy which dismisses the view
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
onCopy()
}
}, label: {
Label("Copy Password", systemImage: "key")
})
}
Button(action: {
if let email = credential.alias?.email {
if let email = credential.alias?.email, !email.isEmpty {
Button(action: {
UIPasteboard.general.string = email
copyToastMessage = "Email copied"
showCopyToast = true
}
}, label: {
Label("Copy Email", systemImage: "envelope")
})
// Delay for 1 second before calling onCopy which dismisses the view
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
onCopy()
}
}, label: {
Label("Copy Email", systemImage: "envelope")
})
}
Divider()
if (credential.username != nil && !credential.username!.isEmpty) ||
(credential.password?.value != nil && !credential.password!.value.isEmpty) ||
(credential.alias?.email != nil && !credential.alias!.email!.isEmpty) {
Divider()
}
Button(action: {
if let url = URL(string: "aliasvault://credentials/\(credential.id.uuidString)") {
if let url = URL(string: "net.aliasvault.app://credentials/\(credential.id.uuidString)") {
UIApplication.shared.open(url)
}
}, label: {
@@ -82,7 +106,7 @@ public struct CredentialCard: View {
})
Button(action: {
if let url = URL(string: "aliasvault://credentials/add-edit-page?id=\(credential.id.uuidString)") {
if let url = URL(string: "net.aliasvault.app://credentials/add-edit-page?id=\(credential.id.uuidString)") {
UIApplication.shared.open(url)
}
}, label: {
@@ -97,7 +121,7 @@ public struct CredentialCard: View {
Text(copyToastMessage)
.padding()
.background(Color.black.opacity(0.7))
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.accentBackground : ColorConstants.Light.accentBackground)
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text)
.cornerRadius(8)
.padding(.bottom, 20)
}
@@ -176,6 +200,7 @@ public func truncateText(_ text: String?, limit: Int) -> String {
updatedAt: Date(),
isDeleted: false
),
action: {}
action: {},
onCopy: {}
)
}

View File

@@ -15,6 +15,7 @@ public struct SearchBarView: View {
TextField("Search credentials...", text: $text)
.autocapitalization(.none)
.disableAutocorrection(true)
.foregroundColor(colorScheme == .dark ? ColorConstants.Dark.text : ColorConstants.Light.text)
.padding(.leading, 4)
.padding(.trailing, 28) // Space for clear button

View File

@@ -81,9 +81,11 @@ public struct CredentialProviderView: View {
} else {
LazyVStack(spacing: 8) {
ForEach(viewModel.filteredCredentials, id: \.service) { credential in
CredentialCard(credential: credential) {
CredentialCard(credential: credential, action: {
viewModel.selectCredential(credential)
}
}, onCopy: {
viewModel.cancel()
})
}
}
.padding(.horizontal)
@@ -238,7 +240,9 @@ public class CredentialProviderViewModel: ObservableObject {
filterCredentials()
isLoading = false
} catch {
handleError(error)
isLoading = false
errorMessage = "Failed to load credentials. Please open the AliasVault app to check for updates."
showError = true
}
}
@@ -375,14 +379,6 @@ public class CredentialProviderViewModel: ObservableObject {
func dismissError() {
showError = false
}
private func handleError(_ error: Error) {
DispatchQueue.main.async { [weak self] in
self?.isLoading = false
self?.errorMessage = error.localizedDescription
self?.showError = true
}
}
}
// MARK: - Preview Helpers

View File

@@ -27,6 +27,7 @@ export interface Spec extends TurboModule {
// SQL operations
executeQuery(query: string, params: (string | number | null)[]): Promise<string[]>;
executeUpdate(query: string, params:(string | number | null)[]): Promise<number>;
executeRaw(query: string): Promise<void>;
beginTransaction(): Promise<void>;
commitTransaction(): Promise<void>;
rollbackTransaction(): Promise<void>;

View File

@@ -0,0 +1,45 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useState } from 'react';
import { AppInfo } from '@/utils/AppInfo';
/**
* 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 AsyncStorage.getItem('apiUrl');
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

@@ -8,7 +8,7 @@ export class AppInfo {
/**
* The current extension version. This should be updated with each release of the extension.
*/
public static readonly VERSION = '0.19.0';
public static readonly VERSION = '0.20.0';
/**
* The minimum supported AliasVault server (API) version. If the server version is below this, the
@@ -16,11 +16,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.
@@ -54,15 +49,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

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