Compare commits

...

161 Commits

Author SHA1 Message Date
Leendert de Borst
2d59117112 Merge pull request #765 from lanedirt/764-prepare-0160-release
Bump version to 0.16.0
2025-04-07 09:36:36 +02:00
dependabot[bot]
ccb66af1ca Bump vite (#766)
Bumps the npm_and_yarn group with 1 update in the /browser-extension directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.2.4 to 6.2.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.2.5/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.2.5
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-04 21:42:36 +02:00
Leendert de Borst
f4093a9199 Fix safari extension settings open bug (#764) 2025-04-03 19:35:32 +02:00
Leendert de Borst
290601ccfb Update README.md (#764) 2025-04-03 17:35:46 +02:00
Leendert de Borst
77be2a339e Bump version to 0.16.0 (#764) 2025-04-03 16:52:05 +02:00
Leendert de Borst
c0b23c15e7 Make browser extension identity generator language aware (#761) 2025-04-03 15:25:20 +02:00
Leendert de Borst
4af158b35d Update tests (#760) 2025-04-03 13:28:22 +02:00
Leendert de Borst
abfabc2a4a Update credential terminology (#760) 2025-04-03 13:28:22 +02:00
Leendert de Borst
a0036da781 Fix search widget click outside behavior (#760) 2025-04-03 13:28:22 +02:00
Leendert de Borst
99f084558d Improve form autofill and add new test case (#758) 2025-04-03 12:54:08 +02:00
Leendert de Borst
d7be5fc308 Add enter to submit for custom alias form (#758) 2025-04-03 12:54:08 +02:00
Leendert de Borst
485e867c50 Generic refactor and UX tweaks (#758) 2025-04-03 12:54:08 +02:00
Leendert de Borst
d2e5f3c715 Add datetime empty string sanity check converter to client (#758) 2025-04-03 12:54:08 +02:00
Leendert de Borst
0cbe5fec93 Update alias email reference (#758) 2025-04-03 12:54:08 +02:00
Leendert de Borst
7f7c729e82 Update create popup UI (#758) 2025-04-03 12:54:08 +02:00
Leendert de Borst
35cc29e751 Refactor linting issues (#758) 2025-04-03 12:54:08 +02:00
Leendert de Borst
8a16a29727 Remember last used email/username input (#758) 2025-04-03 12:54:08 +02:00
Leendert de Borst
708cffc49e UI usability tweaks (#758) 2025-04-03 12:54:08 +02:00
Leendert de Borst
74c0ace2b5 Pass password to the to be created credential (#758) 2025-04-03 12:54:08 +02:00
Leendert de Borst
55175a7db6 UI tweaks (#758) 2025-04-03 12:54:08 +02:00
Leendert de Borst
7e1f33e4e1 Update form validation (#758) 2025-04-03 12:54:08 +02:00
Leendert de Borst
81362b165b Add manual credential option to create new alias popup (#758) 2025-04-03 12:54:08 +02:00
Leendert de Borst
41d6511eb2 Attach shadowroot to html immediately instead as waiting for element doesn't work (#756) 2025-04-02 17:12:50 +02:00
Leendert de Borst
60ba96cb86 Remove autocomplete=off check and attach autofill popup shadowroot to input itself (#756) 2025-04-02 17:12:50 +02:00
Leendert de Borst
fdd8c8b37e Add BadRequest handling to browser extension auth (#734) 2025-04-02 12:55:55 +02:00
Leendert de Borst
53fcb2f2e4 Exclude primary email from confirm email field search (#732) 2025-04-01 22:44:03 +02:00
Leendert de Borst
b1848320d9 Add FormDetector hidden field tests (#732) 2025-04-01 22:44:03 +02:00
Leendert de Borst
610be7e30b Improve FormDetector to ignore hidden elements and improve email detection (#732) 2025-04-01 22:44:03 +02:00
Leendert de Borst
933e458776 Fill in username in email field if no email is available (#732) 2025-04-01 22:44:03 +02:00
Leendert de Borst
b460e6ec20 Fix null issue when searching in popup (#732) 2025-04-01 22:44:03 +02:00
Leendert de Borst
80cd371ee3 Add retry to faviconextractor to bypass certain cookiewalls (#745) 2025-04-01 17:16:58 +02:00
Leendert de Borst
915e12d541 Centralize favicon render logic and make it format aware (#745) 2025-04-01 17:16:58 +02:00
Leendert de Borst
c8d78e0b02 Merge pull request #748 from lanedirt/746-bug-browser-extension-renders-credential-without-alias-full-name-field-as-null-null
Optimize display of legacy credentials that don't have alias fields
2025-04-01 13:54:55 +02:00
Leendert de Borst
199941a837 Make CheckHasAlias static (#746) 2025-04-01 13:54:34 +02:00
Leendert de Borst
1e0c586dba Merge branch '746-bug-browser-extension-renders-credential-without-alias-full-name-field-as-null-null' of https://github.com/lanedirt/AliasVault into 746-bug-browser-extension-renders-credential-without-alias-full-name-field-as-null-null
* '746-bug-browser-extension-renders-credential-without-alias-full-name-field-as-null-null' of https://github.com/lanedirt/AliasVault:
  Add birthdate minvalue filter to main client UI (#746)
2025-04-01 13:38:27 +02:00
Leendert de Borst
37e59dcd4e Update PlaywrightInputHelper.cs (#746) 2025-04-01 13:37:52 +02:00
Leendert de Borst
e665130ea7 Add birthdate minvalue filter to main client UI (#746) 2025-04-01 13:29:23 +02:00
Leendert de Borst
c0aac4ef72 Add birthdate minvalue filter to main client UI (#746) 2025-04-01 13:06:31 +02:00
Leendert de Borst
8319ddcce4 Only show fields when they have a value in main client (#746) 2025-04-01 12:58:07 +02:00
Leendert de Borst
adc6293f4b Only show credential fields that have a value in browser extension (#746) 2025-04-01 12:47:28 +02:00
Leendert de Borst
418bfed663 Add browser extension vscode build task (#746) 2025-04-01 10:41:16 +02:00
Leendert de Borst
7074113cbf Update install.md 2025-04-01 10:22:10 +02:00
Leendert de Borst
ddb610051a Fix install curl command to follow redirects 2025-04-01 10:21:38 +02:00
Leendert de Borst
188b7a4062 Update FaviconExtractor.cs (#736) 2025-04-01 00:28:12 +02:00
Leendert de Borst
989d17708f Add duplicate entry detection to import wizard 2025-04-01 00:28:12 +02:00
Leendert de Borst
77a4b4fcba Make credential view link have a http prefix (#542) 2025-04-01 00:27:54 +02:00
Leendert de Borst
0462e3522b Remove git pre-commit hook requirement 2025-03-31 23:57:28 +02:00
Leendert de Borst
f6bddf730f Make search field output full width on mobile (#736) 2025-03-31 23:26:41 +02:00
dependabot[bot]
035403e3e3 Bump vite
Bumps the npm_and_yarn group with 1 update in the /browser-extension directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.2.3 to 6.2.4
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.2.4/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.4/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-31 21:04:15 +02:00
Leendert de Borst
33ebbf0fd5 Include favicon and username in search results (#736) 2025-03-31 18:40:24 +02:00
Leendert de Borst
55c75ec094 Change loading spinners to non-blocking AliasVault style (#739) 2025-03-31 18:08:24 +02:00
Leendert de Borst
6e244e611c Refactor to reduce complexity (#735) 2025-03-31 17:53:03 +02:00
Leendert de Borst
e1dc9eb447 Add bulk favicon extraction to import (#735) 2025-03-31 17:53:03 +02:00
Leendert de Borst
7a8b31a98a Improve favicon extraction by resizing too large icons (#735) 2025-03-31 17:53:03 +02:00
Leendert de Borst
9baa70f022 Update text and CSS (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
24106475f9 Refactor (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
c50178967a Add E2E import test (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
a69a6a91e2 Update comments (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
1dca845731 Add separate ResourceReaderUtility to E2E project because of namespace(#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
9bec5a3ae5 Fix double navigation redirect bug (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
1a8dae44ec Refactor returnUrl methods in client (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
ec15c76001 Add import link to OOBE home screen (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
e0c11ba0f6 Add separate importers for KeePass, KeePassXC and Strongbox (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
a72f1139f9 Add firefox import card (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
a3a3d39664 Add firefox importer and unit test (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
014a705a5e Add chrome import card (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
6dfb922292 Add chrome importer and unit test (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
cb78d8a636 Add combined client build task and unit test task (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
a4c4a9c8ec Update todos (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
6f5ae7c17e Add 1Password importer (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
43f5e0c647 Add confirm dialog to vault export actions (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
0e5f611670 Add TOTP code sanitize to import (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
70b7ac6f9f Make AliasVault export/import work again (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
14ee466bec Add logo to modal (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
ea9c3c5683 Update importer icons (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
30b812e8a3 Add importer help text (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
27ba14ee34 UI tweaks (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
2e851701f9 Update multistep form flow and reduce boilerplate (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
a2c2caed79 Add multistep import flow (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
c00e6c6a4d Do import on submit (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
09dda0147b Update ImportExport.razor (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
ef7398b47a Fix Bitwarden CSV import (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
dc769bb5d4 Adjust UnitTests namespace, add CSV importer unit tests (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
634fc281a2 Add Bitwarden importer scaffolding (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
e93b0575ff Refactor import record to credential conversion to BaseImporter.cs (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
3f6575dfe5 Refactor CSV import logic to utility class (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
390877f8f3 Rename CsvImportExport to ImportExport utility (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
55ee3bfd4a Add sample CSV import mapping logic (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
423fe00692 Make example import flow work (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
f8e0d6a293 Refactor import base component to use Blazor childcontent (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
a20b0ed83a Add import/export page UI scaffolding (#542) 2025-03-31 14:18:29 +02:00
Leendert de Borst
ca043954ec Update roadmap 2025-03-28 17:36:37 +01:00
Leendert de Borst
4f0104e8f9 Bump version to 0.15.1 (#729) 2025-03-27 15:52:59 +01:00
Leendert de Borst
ea37c4d8c6 Make .env.example work with install.sh (#727) 2025-03-27 15:41:51 +01:00
Leendert de Borst
95be4beb13 Do env create before other env set commands (#727) 2025-03-27 15:41:51 +01:00
Leendert de Borst
716ef0b30c Update docs layout (#727) 2025-03-27 15:41:51 +01:00
Leendert de Borst
fc0eb0e7e7 Update README.md (#727) 2025-03-27 15:41:51 +01:00
Leendert de Borst
9670178aec Update manual setup instructions (#727) 2025-03-27 15:41:51 +01:00
Leendert de Borst
8503be4d52 Add documentation to .env.example (#727) 2025-03-27 15:41:51 +01:00
Leendert de Borst
9eadcaa2ed Make latest version retrieval work in latest MacOS bash (#725) 2025-03-27 10:17:54 +01:00
dependabot[bot]
e0ed8fd285 Bump vite
Bumps the npm_and_yarn group with 1 update in the /browser-extension directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.2.0 to 6.2.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.2.3/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-25 20:19:33 +01:00
Leendert de Borst
61748c3d03 Update README.md 2025-03-25 18:37:57 +01:00
Leendert de Borst
faff4844f5 Update release.yml publish paths (#722) 2025-03-25 13:32:50 +01:00
Leendert de Borst
09d931484a Update GitHub workflows (#722) 2025-03-25 13:29:26 +01:00
Leendert de Borst
1678595c13 Bump version to 0.15.0 (#722) 2025-03-25 13:13:05 +01:00
Leendert de Borst
8945b33705 Add install.sh to release artifacts (#722) 2025-03-25 13:13:05 +01:00
Leendert de Borst
4ee044ffb9 Update faviconextractor HtmlAgilityPack call (#715) 2025-03-25 11:53:04 +01:00
dependabot[bot]
5443e147b1 Bump HtmlAgilityPack from 1.11.74 to 1.12.0
Bumps [HtmlAgilityPack](https://github.com/zzzprojects/html-agility-pack) from 1.11.74 to 1.12.0.
- [Release notes](https://github.com/zzzprojects/html-agility-pack/releases)
- [Commits](https://github.com/zzzprojects/html-agility-pack/compare/v1.11.74...v1.12.0)

---
updated-dependencies:
- dependency-name: HtmlAgilityPack
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-25 11:53:04 +01:00
Leendert de Borst
05edda8b48 Show returning users count in admin dashboard (#720) 2025-03-25 10:48:55 +01:00
Leendert de Borst
179bb62604 Fix bug in search for null credential fields (#718) 2025-03-24 22:21:34 +01:00
Leendert de Borst
1f5863b066 Fix vault dismiss logic when user is not logged in (#718) 2025-03-24 22:21:34 +01:00
Leendert de Borst
ef36a08ef4 Update password autofill to improve compatibility (#718) 2025-03-24 22:21:34 +01:00
dependabot[bot]
4f7212668e Bump Swashbuckle.AspNetCore from 7.3.2 to 8.0.0
Bumps [Swashbuckle.AspNetCore](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) from 7.3.2 to 8.0.0.
- [Release notes](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/releases)
- [Commits](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/compare/v7.3.2...v8.0.0)

---
updated-dependencies:
- dependency-name: Swashbuckle.AspNetCore
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-24 17:52:47 +01:00
dependabot[bot]
41bb7ed701 Bump Microsoft.AspNetCore.Components.WebAssembly.DevServer
Bumps [Microsoft.AspNetCore.Components.WebAssembly.DevServer](https://github.com/dotnet/aspnetcore) from 9.0.2 to 9.0.3.
- [Release notes](https://github.com/dotnet/aspnetcore/releases)
- [Changelog](https://github.com/dotnet/aspnetcore/blob/main/docs/ReleasePlanning.md)
- [Commits](https://github.com/dotnet/aspnetcore/compare/v9.0.2...v9.0.3)

---
updated-dependencies:
- dependency-name: Microsoft.AspNetCore.Components.WebAssembly.DevServer
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-24 17:51:49 +01:00
dependabot[bot]
78286b1ac1 Bump nokogiri in /docs in the bundler group across 1 directory
Bumps the bundler group with 1 update in the /docs directory: [nokogiri](https://github.com/sparklemotion/nokogiri).


Updates `nokogiri` from 1.18.3 to 1.18.4
- [Release notes](https://github.com/sparklemotion/nokogiri/releases)
- [Changelog](https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sparklemotion/nokogiri/compare/v1.18.3...v1.18.4)

---
updated-dependencies:
- dependency-name: nokogiri
  dependency-type: indirect
  dependency-group: bundler
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-22 10:34:01 +01:00
Leendert de Borst
7bc8bb3fc2 Create FUNDING.yml 2025-03-21 16:36:47 +01:00
Leendert de Borst
c576062025 Fix hyperlinks absolute vs relative address (#711) 2025-03-20 13:55:32 +01:00
Leendert de Borst
1194d54e6f Add E2E test for email claim disable logic (#711) 2025-03-20 13:55:32 +01:00
Leendert de Borst
e782a6a51f Reject emails addressed to disabled email claim (#711) 2025-03-20 13:55:32 +01:00
Leendert de Borst
2071a7c4fe Add email claim enable/disable toggle to admin (#711) 2025-03-20 13:55:32 +01:00
Leendert de Borst
8c1e5a7bf8 Add email claim table disabled boolean (#711) 2025-03-20 13:55:32 +01:00
Leendert de Borst
b8f9e7fa2c Merge pull request #710 from lanedirt/641-add-statistics-graphs-to-admin
Add analytics charts to admin dashboard
2025-03-20 10:04:24 +01:00
Leendert de Borst
a0a541aff9 Update admin tests (#641) 2025-03-19 22:17:59 +01:00
Leendert de Borst
d6932f33ea Update email list page and tweak search fields (#641) 2025-03-19 22:10:13 +01:00
Leendert de Borst
9ea845b497 Add ApexChart service and integrate dark mode (#641) 2025-03-19 19:33:42 +01:00
Leendert de Borst
917d6f6bcc Add charts to admin dashboard (#641) 2025-03-19 17:49:09 +01:00
Leendert de Borst
39a263d157 Update docs (#641) 2025-03-19 15:34:35 +01:00
Leendert de Borst
c7360ee23c Add general log source context to term filter (#705) 2025-03-19 14:23:51 +01:00
Leendert de Borst
d1924f4044 Update header text (#705) 2025-03-19 14:23:51 +01:00
Leendert de Borst
4d86356990 Update users page with credential count column (#705) 2025-03-19 14:23:51 +01:00
Leendert de Borst
505a2445eb Reset page back to 1 when search term changes in admin (#705) 2025-03-19 14:23:51 +01:00
Leendert de Borst
75385c4b5d Remove WASM DevServer package from admin which caused it to not run in debug (#705) 2025-03-19 14:23:51 +01:00
Leendert de Borst
4d4053c7fb Update package-lock.json (#705) 2025-03-19 14:23:51 +01:00
Leendert de Borst
43062d0d93 Update .vscode tasks (#705) 2025-03-19 14:23:51 +01:00
Leendert de Borst
956709da54 Merge pull request #706 from lanedirt/167-allow-customizing-password-generation-options
Make password generation options customizable
2025-03-18 22:15:53 +01:00
Leendert de Borst
496e0ab754 Refactor PasswordGenerator.ts (#167) 2025-03-18 22:04:11 +01:00
Leendert de Borst
ef97aac848 Merge branch 'main' into 167-allow-customizing-password-generation-options 2025-03-18 18:22:09 +01:00
Leendert de Borst
998fa1913f Update dotnet nuget packages to 9.0.3 (#707) 2025-03-18 18:08:32 +01:00
Leendert de Borst
79cd265c3e Add browser extension password settings test (#167) 2025-03-18 17:40:31 +01:00
Leendert de Borst
ed5fd5b861 Disable autofill extension for aliasvault client by default (#167) 2025-03-18 17:12:34 +01:00
Leendert de Borst
5e2dde252d Update tests (#167) 2025-03-18 16:51:49 +01:00
Leendert de Borst
79950ab9fc Add password generator settings awareness to browser extension (#167) 2025-03-18 16:30:41 +01:00
Leendert de Borst
dffa651512 Cleanup (#167) 2025-03-18 14:37:24 +01:00
Leendert de Borst
2dc36cea11 Add password settings to general settings page (#167) 2025-03-18 14:17:49 +01:00
Leendert de Borst
ad4c2c7b41 Add modalwrapper component for keydown detection (#167) 2025-03-18 13:41:43 +01:00
Leendert de Borst
2022cdb58b Improve UX (#167) 2025-03-18 13:08:56 +01:00
Leendert de Borst
5f779ce360 Update UI style (#167) 2025-03-18 12:37:10 +01:00
Leendert de Borst
b9d981f80b Refactor (#167) 2025-03-18 11:30:36 +01:00
Leendert de Borst
65110abf4c Add range binds and sanity checks (#167) 2025-03-18 10:47:06 +01:00
Leendert de Borst
b0e939ef23 Add support for temp or global password settings persist (#167) 2025-03-18 10:19:53 +01:00
Leendert de Borst
607c0da5b4 Make password settings a separate component (#167) 2025-03-18 10:05:10 +01:00
Leendert de Borst
1de7f831b5 Fix recent email refresh duplicate calls (#167) 2025-03-17 22:19:31 +01:00
Leendert de Borst
ef328718cd Refactor password generator and make all use general settings (#167) 2025-03-17 21:28:57 +01:00
Leendert de Borst
465c4cc730 Update username and password button style (#167) 2025-03-17 20:37:26 +01:00
Leendert de Borst
0dceeeffa4 Update docs to include Windows instructions (#703) 2025-03-17 17:56:21 +01:00
Leendert de Borst
af24464a8d Convert install.sh line endings so it works on Windows out of the box (#703) 2025-03-17 17:56:21 +01:00
Leendert de Borst
5aa82d8149 Update username and password edit field GUI (#167) 2025-03-17 15:06:15 +01:00
Leendert de Borst
e848e05cce Cleanup and simplify install.sh (#690) 2025-03-16 15:35:58 +01:00
Leendert de Borst
323be10d03 Tweak password edit component UI (#167) 2025-03-15 18:24:35 +01:00
Leendert de Borst
51b382a739 Add password generation settings GUI scaffolding (#167) 2025-03-15 18:03:45 +01:00
Leendert de Borst
7954104dfc Update README.md 2025-03-14 17:54:51 +01:00
249 changed files with 13112 additions and 2706 deletions

View File

@@ -1,10 +1,106 @@
# ----------------------------------------------------------------------------
# AliasVault configuration file.
#
# Note: we recommend using the provided install.sh script to install and
# configure AliasVault, as this will automatically set all of the following
# variables for you and allow you to easily change them later via the CLI.
# It also allows for easily updating AliasVault to a newer version in the
# future.
#
# However if you still wish to manually install or configure AliasVault,
# you can do so below.
#
# After changing settings here, make sure to restart all AliasVault
# 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.
HTTP_PORT=80
HTTPS_PORT=443
SMTP_PORT=25
SMTP_TLS_PORT=587
# Set the hostname that your AliasVault will be accessible at.
# E.g. `aliasvault.mydomain.com` or if you're running it on your local machine, choose `localhost`.
HOSTNAME=
# Set a random 32 character string for the JWT key.
# This can be generated using the following command:
# $ openssl rand -base64 32
JWT_KEY=
# Set the password for the data protection certificate.
# This can be generated using the following command:
# $ openssl rand -base64 32
DATA_PROTECTION_CERT_PASS=
ADMIN_PASSWORD_HASH=
ADMIN_PASSWORD_GENERATED=2024-01-01T00:00:00Z
PRIVATE_EMAIL_DOMAINS=
SMTP_TLS_ENABLED=false
LETSENCRYPT_ENABLED=false
# ----------------------------------------------------------------------------
# Database configuration
# ----------------------------------------------------------------------------
# These are the credentials that are used by the PostgreSQL container
# on startup to create the database and user, and for the application to
# connect to the database.
POSTGRES_DB=aliasvault
POSTGRES_USER=aliasvault
# Set the password for the database user.
# This can be generated using the following command:
# $ openssl rand -base64 32
POSTGRES_PASSWORD=
# Note: in order to change the password for an existing installation
# refer to https://docs.aliasvault.net/misc/dev/database-operations.html
# ----------------------------------------------------------------------------
# Admin user configuration
# ----------------------------------------------------------------------------
# Set the password for the admin user. This is an encrypted hash that needs
# to be generated using the `aliasvault-cli` tool. This allows you to login
# to the admin panel at https://your-hostname/admin.
#
# For example:
# docker run --rm ghcr.io/lanedirt/aliasvault-installcli:latest hash-password "my-password"
#
# Then copy the output and paste it into the ADMIN_PASSWORD_HASH variable below.
# When changing the hash, update the ADMIN_PASSWORD_GENERATED variable to the current date and time
# and then restart the AliasVault docker containers to apply the changes.
ADMIN_PASSWORD_HASH=
# Set the date and time the admin password was last generated. When changing the
# admin password hash manually, make sure to increase this value so the system
# knows that the password has been changed and should be overwritten with the new hash.
ADMIN_PASSWORD_GENERATED=2024-01-01T00:00:00Z
# ----------------------------------------------------------------------------
# Email server configuration for email aliases
# ----------------------------------------------------------------------------
# In order to use AliasVault's private email domains feature, you need to configure
# your DNS. Please refer to the full documentation for more instructions on DNS:
# https://docs.aliasvault.net/installation/install.html#3-email-server-setup
#
# Set the private email domains below that are allowed to be used (comma separated values).
# Example: PRIVATE_EMAIL_DOMAINS=example.com,example2.org
# To disable the private email domains feature, set this to "DISABLED.TLD"
PRIVATE_EMAIL_DOMAINS=DISABLED.TLD
# Set whether TLS is enabled for SMTP.
SMTP_TLS_ENABLED=false
# ----------------------------------------------------------------------------
# Let's Encrypt configuration
# ----------------------------------------------------------------------------
# Set whether Let's Encrypt is enabled. This is only supported through
# the install.sh script.
LETSENCRYPT_ENABLED=false
# ----------------------------------------------------------------------------
# Optional configuration settings
# ----------------------------------------------------------------------------
PUBLIC_REGISTRATION_ENABLED=true
IP_LOGGING_ENABLED=true
# Set the support email address which is shown to users in the main web app.
# Keep this blank if you don't want to show a support email.
SUPPORT_EMAIL=

31
.gitattributes vendored
View File

@@ -1,2 +1,31 @@
# Auto detect text files and perform LF normalization
# Set default behavior to automatically normalize line endings
* text=auto
# Common files should always use LF (Unix-style) line endings
*.sh text eol=lf
*.cs text eol=lf
*.razor text eol=lf
*.css text eol=lf
*.html text eol=lf
*.js text eol=lf
*.json text eol=lf
*.xml text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
# Docker files should use LF
Dockerfile text eol=lf
docker-compose*.yml text eol=lf
# Config files should use LF
*.conf text eol=lf
*.config text eol=lf
.env* text eol=lf
# Batch scripts should always use CRLF (Windows-style) line endings
*.bat text eol=crlf
*.cmd text eol=crlf
# Documentation should be normalized
*.md text
*.txt text

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
# These are supported funding model platforms
buy_me_a_coffee: lanedirt

View File

@@ -1,10 +0,0 @@
#!/bin/bash
# Commit-msg hook to check commit messages for issue number in format "(#123)"
commit_message=$(cat "$1")
if ! grep -q "(\#[0-9]\+)" <<< "$commit_message"; then
echo "Error: Commit message must contain an issue number in the format \"(#123)\""
exit 1
fi

View File

@@ -5,8 +5,6 @@ on:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
release:
types: [published]
workflow_dispatch:
jobs:
@@ -164,76 +162,3 @@ jobs:
outputs:
sha_short: ${{ steps.vars.outputs.sha_short }}
upload-chrome-release-assets:
runs-on: ubuntu-latest
needs: [build-chrome-extension]
if: github.event_name == 'release' && github.event.action == 'published'
steps:
- name: Download built artifact
uses: actions/download-artifact@v4
with:
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', needs.build-chrome-extension.outputs.sha_short) || needs.build-chrome-extension.outputs.sha_short) }}-chrome
path: browser-extension/dist/chrome-unpacked
- name: Zip Chrome Extension for release
run: |
cd browser-extension/dist
zip -r aliasvault-browser-extension-${{ github.ref_name }}-chrome.zip chrome-unpacked/*
- name: Upload Chrome Extension ZIP to Release
uses: softprops/action-gh-release@v2
with:
files: browser-extension/dist/aliasvault-browser-extension-*-chrome.zip
token: ${{ secrets.GITHUB_TOKEN }}
upload-firefox-release-assets:
runs-on: ubuntu-latest
needs: [build-firefox-extension]
if: github.event_name == 'release' && github.event.action == 'published'
steps:
- name: Download built artifact Firefox
uses: actions/download-artifact@v4
with:
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', needs.build-firefox-extension.outputs.sha_short) || needs.build-firefox-extension.outputs.sha_short) }}-firefox
path: browser-extension/dist/firefox-unpacked
- name: Download built artifact Firefox sources
uses: actions/download-artifact@v4
with:
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', needs.build-firefox-extension.outputs.sha_short) || needs.build-firefox-extension.outputs.sha_short) }}-sources
path: browser-extension/dist/sources-unpacked
- name: Zip Firefox Extensions for release
run: |
cd browser-extension/dist
zip -r aliasvault-browser-extension-${{ github.ref_name }}-firefox.zip firefox-unpacked/*
zip -r aliasvault-browser-extension-${{ github.ref_name }}-sources.zip sources-unpacked/*
- name: Upload Firefox Extension ZIP to Release
uses: softprops/action-gh-release@v2
with:
files: browser-extension/dist/aliasvault-browser-extension-*{-firefox,-sources}.zip
token: ${{ secrets.GITHUB_TOKEN }}
upload-edge-release-assets:
runs-on: ubuntu-latest
needs: [build-edge-extension]
if: github.event_name == 'release' && github.event.action == 'published'
steps:
- name: Download built artifact
uses: actions/download-artifact@v4
with:
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', needs.build-edge-extension.outputs.sha_short) || needs.build-edge-extension.outputs.sha_short) }}-edge
path: browser-extension/dist/edge-unpacked
- name: Zip Edge Extension for release
run: |
cd browser-extension/dist
zip -r aliasvault-browser-extension-${{ github.ref_name }}-edge.zip edge-unpacked/*
- name: Upload Edge Extension ZIP to Release
uses: softprops/action-gh-release@v2
with:
files: browser-extension/dist/aliasvault-browser-extension-*-edge.zip
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -92,9 +92,9 @@ jobs:
exit 1
fi
- name: Test install.sh reset-password output
- name: Test install.sh reset-admin-password output
run: |
output=$(./install.sh reset-password)
output=$(./install.sh reset-admin-password)
if ! echo "$output" | grep -E '.*New admin password: [A-Za-z0-9+/=]{8,}.*'; then
echo "Password reset output format is incorrect"
echo "Expected: 'New admin password: <at least 8 base64 chars>'"

View File

@@ -43,44 +43,54 @@ jobs:
- name: Set permissions and run install.sh
id: install_script
continue-on-error: true
run: |
chmod +x install.sh
./install.sh install --verbose
- name: Check if failure was due to version mismatch
if: steps.install_script.outcome == 'failure'
run: |
if grep -q "Install script needs updating to match version" <<< "$(./install.sh install --verbose 2>&1)"; then
echo "Test skipped: Install script version is newer than latest release version. This is expected behavior if the install script is run on a branch that is ahead of the latest release."
exit 0
else
echo "Test failed due to an unexpected error"
exit 1
fi
{
./install.sh install --verbose
exit_code=$?
if [ $exit_code -eq 2 ]; then
echo "Test skipped: Install script version is newer than latest release version. This is expected behavior if the install script is run on a branch that is ahead of the latest release."
echo "skip_remaining=true" >> $GITHUB_OUTPUT
true # Force success exit code
elif [ $exit_code -ne 0 ]; then
false # Propagate failure
fi
} || {
if [ $exit_code -eq 2 ]; then
echo "skip_remaining=true" >> $GITHUB_OUTPUT
true # Version mismatch is okay
else
exit $exit_code # Propagate other failures
fi
}
- name: Set up Docker Compose
if: ${{ !steps.install_script.outputs.skip_remaining }}
run: docker compose -f docker-compose.yml up -d
- name: Wait for services to be up
if: ${{ !steps.install_script.outputs.skip_remaining }}
run: |
# Wait for a few seconds
sleep 10
- name: Test if localhost:443 (WASM app) responds
uses: nick-fields/retry@v3
with:
timeout_minutes: 2
max_attempts: 3
command: |
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" https://localhost:443)
if [ "$http_code" -ne 200 ]; then
echo "Service did not respond with 200 OK. Check if client app and/or nginx is configured correctly."
exit 1
else
echo "Service responded with 200 OK"
fi
- name: Test if localhost:443 (WASM app) responds
if: ${{ !steps.install_script.outputs.skip_remaining }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 2
max_attempts: 3
command: |
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" https://localhost:443)
if [ "$http_code" -ne 200 ]; then
echo "Service did not respond with 200 OK. Check if client app and/or nginx is configured correctly."
exit 1
else
echo "Service responded with 200 OK"
fi
- name: Test if localhost:443/api (WebApi) responds
if: ${{ !steps.install_script.outputs.skip_remaining }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 2
@@ -95,6 +105,7 @@ jobs:
fi
- name: Test if localhost:443/admin (Admin) responds
if: ${{ !steps.install_script.outputs.skip_remaining }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 2
@@ -109,6 +120,7 @@ jobs:
fi
- name: Test if localhost:2525 (SmtpService) responds
if: ${{ !steps.install_script.outputs.skip_remaining }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 2
@@ -121,9 +133,10 @@ jobs:
echo "SmtpService responded on port 2525"
fi
- name: Test install.sh reset-password output
- name: Test install.sh reset-admin-password output
if: ${{ !steps.install_script.outputs.skip_remaining }}
run: |
output=$(./install.sh reset-password)
output=$(./install.sh reset-admin-password)
if ! echo "$output" | grep -E '.*New admin password: [A-Za-z0-9+/=]{8,}.*'; then
echo "Password reset output format is incorrect. Expected format: 'New admin password: <at least 8 base64 chars>'"
echo "Actual output: $output"

View File

@@ -1,5 +1,4 @@
# This workflow will publish new Docker images to the GitHub Container Registry when a new release is published.
name: Publish Docker Images
name: Release
on:
release:
@@ -11,7 +10,56 @@ env:
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
upload-install-script:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Upload install.sh to release
uses: softprops/action-gh-release@v2
with:
files: install.sh
token: ${{ secrets.GITHUB_TOKEN }}
package-browser-extensions:
runs-on: ubuntu-latest
defaults:
run:
working-directory: browser-extension
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: browser-extension/package-lock.json
- name: Install dependencies
run: npm ci
- name: Zip extensions
run: |
npm run zip:chrome
npm run zip:firefox
npm run zip:edge
- name: Upload extensions to release
uses: softprops/action-gh-release@v2
with:
files: |
browser-extension/dist/aliasvault-browser-extension-*-chrome.zip
browser-extension/dist/aliasvault-browser-extension-*-firefox.zip
browser-extension/dist/aliasvault-browser-extension-*-edge.zip
browser-extension/dist/aliasvault-browser-extension-*-sources.zip
token: ${{ secrets.GITHUB_TOKEN }}
build-and-push-docker:
needs: [upload-install-script, package-browser-extensions]
runs-on: ubuntu-latest
permissions:
contents: read
@@ -114,4 +162,4 @@ jobs:
file: src/Utilities/AliasVault.InstallCli/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-installcli:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-installcli:${{ github.ref_name }}
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-installcli:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-installcli:${{ github.ref_name }}

4
.vscode/launch.json vendored
View File

@@ -2,10 +2,10 @@
"version": "0.2.0",
"configurations": [
{
"name": "C#: AliasVault.WebApp [http]",
"name": "C#: AliasVault.Client [http]",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/AliasVault.WebApp/AliasVault.WebApp.csproj",
"projectPath": "${workspaceFolder}/src/AliasVault.Client/AliasVault.Client.csproj",
"launchConfigurationId": "TargetFramework=;http"
},
{

116
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,116 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Build and watch API",
"type": "shell",
"command": "dotnet watch",
"args": [],
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
},
"options": {
"cwd": "${workspaceFolder}/src/AliasVault.Api"
}
},
{
"label": "Build and watch Client",
"type": "shell",
"command": "dotnet watch",
"args": [],
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
},
"options": {
"cwd": "${workspaceFolder}/src/AliasVault.Client"
}
},
{
"label": "Build and watch Admin",
"type": "shell",
"command": "dotnet watch",
"args": [],
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
},
"options": {
"cwd": "${workspaceFolder}/src/AliasVault.Admin"
}
},
{
"label": "Build and watch Client CSS",
"type": "shell",
"command": "npm",
"args": ["run", "build:client-css"],
"problemMatcher": [],
"options": {
"cwd": "${workspaceFolder}/src/AliasVault.Client"
},
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "Build and watch Admin CSS",
"type": "shell",
"command": "npm",
"args": ["run", "build:admin-css"],
"problemMatcher": [],
"options": {
"cwd": "${workspaceFolder}/src/AliasVault.Admin"
},
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "Build and watch Client (API + Client + CSS)",
"dependsOn": [
"Build and watch API",
"Build and watch Client",
"Build and watch Client CSS"
],
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "Run Unit Tests",
"type": "shell",
"command": "dotnet",
"args": ["test"],
"problemMatcher": "$msCompile",
"options": {
"cwd": "${workspaceFolder}/src/Tests/AliasVault.UnitTests"
},
"group": {
"kind": "test",
"isDefault": true
}
},
{
"label": "Run Browser Extension (Chrome Dev)",
"type": "shell",
"command": "npm",
"args": ["run", "dev:chrome"],
"problemMatcher": [],
"options": {
"cwd": "${workspaceFolder}/browser-extension"
},
"group": {
"kind": "build",
"isDefault": true
}
}
]
}

View File

@@ -1,7 +1,7 @@
# 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:
https://docs.aliasvault.net/misc/dev/contributing.html
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.

View File

@@ -9,14 +9,17 @@
> AliasVault is an end-to-end encrypted password and (email) alias manager that protects your privacy by creating alternative identities, passwords and email addresses for every website you use. Use the official supported cloud version or self-host AliasVault on your own server with Docker.
## Quick links
- <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> - <a href="https://aliasvault.net/plugins?utm_source=gh-readme">Browser Extensions 🔌</a>
### What makes AliasVault unique:
- **Zero-knowledge architecture**: All data is end-to-end encrypted on the client and stored in encrypted state on the server. Your master password never leaves your device and the server never has access to your data.
- **Built-in email server**: AliasVault includes its own email server that allows you to generate real working email addresses for each alias. Emails sent to these addresses are instantly visible in the AliasVault app and browser extension.
- **Alias generation**: Generate aliases and assign them to a website, allowing you to use different email addresses and usernames for each website. Keeping your online identities separate and secure, making it harder for bad actors to link your accounts.
- **Open-source**: The source code is available on GitHub and AliasVault can be self-hosted on your own server via an easy install script.
- **Zero-knowledge architecture**:
- All data is end-to-end encrypted on the client and stored in encrypted state on the server. Your master password never leaves your device and the server never has access to your data.
- **Built-in email server**:
- AliasVault includes its own email server that allows you to generate real working email addresses for each alias. Emails sent to these addresses are instantly visible in the AliasVault app and browser extension.
- **Alias generation**:
- Generate aliases and assign them to a website, allowing you to use different email addresses and usernames for each website. Keeping your online identities separate and secure, making it harder for bad actors to link your accounts.
- **Open-source & Self-hostable**:
- The source code is available on GitHub and AliasVault can be self-hosted on your own server via an easy install script.
## Screenshots
@@ -67,7 +70,7 @@ This method uses pre-built Docker images and works on minimal hardware specifica
```bash
# Download install script from latest stable release
curl -o install.sh https://raw.githubusercontent.com/lanedirt/AliasVault/0.14.0/install.sh
curl -L -o install.sh https://github.com/lanedirt/AliasVault/releases/latest/download/install.sh
# Make install script executable and run it. This will create the .env file, pull the Docker images, and start the AliasVault containers.
chmod +x install.sh
@@ -81,7 +84,7 @@ The install script will output the URL where the app is available. By default th
> Note: If you want to change the default AliasVault ports you can do so in the `.env` file.
## Documentation
For more detailed information about the installation process and other topics, please see the official documentation website:
For more information about the installation process, manual setup instructions and other topics, please see the official documentation website:
- [Documentation website (docs.aliasvault.net) 📚](https://docs.aliasvault.net)
## Security Architecture
@@ -98,50 +101,34 @@ For detailed information about our encryption implementation and security archit
- [Security Architecture Diagram](https://docs.aliasvault.net/architecture)
## Roadmap
AliasVault is under active development with new features being added regularly. We believe in transparency and want to share our vision for the future of the platform. Here's what we've accomplished and what we're working on next:
AliasVault is under active development, with a strong focus on usability, security, and cross-platform support.
The main focus is on ensuring robust usability for everyday tasks, including comprehensive autofill capabilities across all platforms.
🛠️ Incremental releases are published every 23 weeks, with a strong emphasis on real-world testing and user feedback.
During this phase, AliasVault can safely be used in production as it maintains strict data integrity and automatic migration guarantees.
Core features that are being worked on:
- [x] Core password & alias management
- [x] End-to-end encryption
- [x] Full end-to-end encryption
- [x] Built-in email server for aliases
- [x] Single-command Docker-based installation
- [x] Chrome browser extension
- [x] Firefox and MS Edge browser extension
- [x] Safari and Brave browser extension
- [x] Add and associate TOTP MFA tokens to credentials
- [ ] Import passwords from existing password managers (https://github.com/lanedirt/AliasVault/issues/542)
- [ ] Add support for connecting custom user domains to cloud hosted version (https://github.com/lanedirt/AliasVault/issues/485)
- [x] Easy self-hosted installer
- [x] Browser extensions with autofill feature (Chrome, Firefox, Edge, Safari, Brave)
- [x] Built-in TOTP authenticator
- [x] Import passwords from traditional password managers
- [ ] iOS and Android native apps
- [ ] Data model improvements to support reusable identities in combination with aliases
- [ ] Support for FIDO2/WebAuthn hardware keys and passkeys
- [ ] Adding support for family/team sharing (organization features)
### Future Plans
- [ ] Mobile apps (iOS, Android)
- [ ] Team / organization features (sharing passwords/aliases)
- [ ] Disposable phone number service
👉 [View the full AliasVault roadmap here](https://github.com/lanedirt/AliasVault/issues/731)
Want to suggest a feature? Join our [Discord](https://discord.gg/DsaXMTEtpF) or create an issue on GitHub.
### Got feedback or ideas?
Feel free to open an issue or join our [Discord](https://discord.gg/DsaXMTEtpF)! Contributions are warmly welcomed—whether in feature development, testing, or spreading the word. Get in touch on Discord if you're interested in contributing.
### 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>
## Tech Stack & Security
AliasVault is built with a modern, secure, and scalable technology stack, ensuring robust encryption and privacy protection.
### Core Technologies
- **C# & ASP.NET Core** Reliable, high-performance backend for Web API.
- **Blazor WASM** Secure, interactive web UI.
- **PostgreSQL & SQLite** Database solutions, with SQLite powering encrypted user vaults.
- **Docker** Containerized deployment for scalability.
- **Next.JS & React & Typescript** - Powering the AliasVault website and browser extensions
### Security & Cryptography
- **Argon2id (Konscious.Security.Cryptography)** Industry-leading password hashing.
- **SRP** Secure Remote Password (SRP-6a) protocol for authentication.
- **MimeKit & SmtpServer** Secure email processing and virtual addresses.
### Additional Tools
- **Tailwind CSS & Flowbite** Modern UI design.
- **Playwright** Automated end-to-end testing.
- **SonarCloud** Continuous code quality monitoring.
AliasVault prioritizes security, performance, and user privacy with a technology stack trusted by the industry.

View File

@@ -29,7 +29,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.E2ETests.Client.
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Server", "Server", "{607945F3-9896-4544-99EC-F3496CF4D36B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.CsvImportExport", "src\Utilities\AliasVault.CsvImportExport\AliasVault.CsvImportExport.csproj", "{A9C9A606-C87E-4298-AB32-09B1884D7487}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.ImportExport", "src\Utilities\AliasVault.ImportExport\AliasVault.ImportExport.csproj", "{A9C9A606-C87E-4298-AB32-09B1884D7487}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{8A477241-B96C-4174-968D-D40CB77F1ECD}"
EndProject
@@ -59,8 +59,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Cryptography.Cli
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Generators", "Generators", "{03D55CA4-20B3-4FEA-9ADD-3C7B5B10E0FE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Generators.Password", "src\Generators\AliasVault.Generators.Password\AliasVault.Generators.Password.csproj", "{47F47A1B-49E0-406A-81C8-31FF2E4C339B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Generators.Identity", "src\Generators\AliasVault.Generators.Identity\AliasVault.Generators.Identity.csproj", "{80E74FBC-4EC8-45FB-B210-473337C484B5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{DD359F0A-0180-4F8F-9E48-46213386BA4D}"
@@ -161,10 +159,6 @@ Global
{542C7B7D-C2B4-4AE3-9B2C-C62FCF4DFF8E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{542C7B7D-C2B4-4AE3-9B2C-C62FCF4DFF8E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{542C7B7D-C2B4-4AE3-9B2C-C62FCF4DFF8E}.Release|Any CPU.Build.0 = Release|Any CPU
{47F47A1B-49E0-406A-81C8-31FF2E4C339B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{47F47A1B-49E0-406A-81C8-31FF2E4C339B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{47F47A1B-49E0-406A-81C8-31FF2E4C339B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{47F47A1B-49E0-406A-81C8-31FF2E4C339B}.Release|Any CPU.Build.0 = Release|Any CPU
{80E74FBC-4EC8-45FB-B210-473337C484B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{80E74FBC-4EC8-45FB-B210-473337C484B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{80E74FBC-4EC8-45FB-B210-473337C484B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -188,6 +182,7 @@ Global
GlobalSection(NestedProjects) = preSolution
{ED328644-A152-403D-86EB-81201AA07744} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
{8E6A418A-B305-465D-857D-49953605C18E} = {29DE523D-EEF2-41E9-AC12-F20D8D02BEBB}
{15EFE0D0-F41B-47D7-86B7-8F840335CB82} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}
{AF013D08-1BF6-4E23-87D2-37F614BE7952} = {29DE523D-EEF2-41E9-AC12-F20D8D02BEBB}
{1277105D-50CD-4CE0-9C2C-549F46867E54} = {5F7417F6-4388-49CC-9511-ED63C4A6488A}
{FE10F294-817F-477E-A24F-8597A15AF0B5} = {5F7417F6-4388-49CC-9511-ED63C4A6488A}
@@ -198,16 +193,14 @@ Global
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509} = {29DE523D-EEF2-41E9-AC12-F20D8D02BEBB}
{857BCD0E-753F-437A-AF75-B995B4D9A5FE} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
{FF0B0E64-1AE2-415C-A404-0EB78010821A} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}
{951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
{E8D9C551-67D2-4651-8EDF-4262DF7375CE} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
{DA175274-0FF7-4436-9266-742F96C2D1ED} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
{BB7E701E-B1C6-453E-800A-E12CE256318D} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
{341EC443-0B6B-4E8C-AF46-D6156573CEA5} = {BB7E701E-B1C6-453E-800A-E12CE256318D}
{542C7B7D-C2B4-4AE3-9B2C-C62FCF4DFF8E} = {BB7E701E-B1C6-453E-800A-E12CE256318D}
{47F47A1B-49E0-406A-81C8-31FF2E4C339B} = {03D55CA4-20B3-4FEA-9ADD-3C7B5B10E0FE}
{80E74FBC-4EC8-45FB-B210-473337C484B5} = {03D55CA4-20B3-4FEA-9ADD-3C7B5B10E0FE}
{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}
{15EFE0D0-F41B-47D7-86B7-8F840335CB82} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}
{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}
{D631A936-DD1C-40CC-B735-BD0A5D4F46A1} = {8A477241-B96C-4174-968D-D40CB77F1ECD}
{34FADEB6-4B56-463B-B359-F844B43D76D9} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}

View File

@@ -12449,9 +12449,9 @@
}
},
"node_modules/vite": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz",
"integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==",
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",

View File

@@ -497,7 +497,7 @@
"-framework",
SafariServices,
);
PRODUCT_BUNDLE_IDENTIFIER = net.aliasvault.safari.Extension;
PRODUCT_BUNDLE_IDENTIFIER = net.aliasvault.safari.extension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -515,7 +515,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 12;
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.14.0;
MARKETING_VERSION = 0.16.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 = 6;
CURRENT_PROJECT_VERSION = 12;
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.14.0;
MARKETING_VERSION = 0.16.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,

View File

@@ -2,7 +2,7 @@ import { browser } from "wxt/browser";
import { defineBackground } from 'wxt/sandbox';
import { onMessage } from "webext-bridge/background";
import { setupContextMenus, handleContextMenuClick } from './background/ContextMenu';
import { handleCheckAuthStatus, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDerivedKey, handleGetVault, handleStoreVault, handleSyncVault } from './background/VaultMessageHandler';
import { handleCheckAuthStatus, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDefaultIdentityLanguage, handleGetDerivedKey, handleGetPasswordSettings, handleGetVault, handleStoreVault, handleSyncVault } from './background/VaultMessageHandler';
import { handleOpenPopup, handlePopupWithCredential } from './background/PopupMessageHandler';
export default defineBackground({
@@ -12,7 +12,7 @@ export default defineBackground({
main() {
// Set up context menus
setupContextMenus();
browser.contextMenus.onClicked.addListener((info: browser.menus.OnClickData, tab?: browser.tabs.Tab) =>
browser.contextMenus.onClicked.addListener((info: browser.contextMenus.OnClickData, tab?: browser.tabs.Tab) =>
handleContextMenuClick(info, tab)
);
@@ -25,6 +25,8 @@ 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_PASSWORD_SETTINGS', () => handleGetPasswordSettings());
onMessage('GET_DERIVED_KEY', () => handleGetDerivedKey());
onMessage('OPEN_POPUP', () => handleOpenPopup());
onMessage('OPEN_POPUP_WITH_CREDENTIAL', ({ data }) => handlePopupWithCredential(data));

View File

@@ -9,7 +9,8 @@ import { storage } from 'wxt/storage';
import { BoolResponse as messageBoolResponse } from '../../utils/types/messaging/BoolResponse';
import { VaultResponse as messageVaultResponse } from '../../utils/types/messaging/VaultResponse';
import { CredentialsResponse as messageCredentialsResponse } from '../../utils/types/messaging/CredentialsResponse';
import { DefaultEmailDomainResponse as messageDefaultEmailDomainResponse } from '../../utils/types/messaging/DefaultEmailDomainResponse';
import { StringResponse as stringResponse } from '../../utils/types/messaging/StringResponse';
import { PasswordSettingsResponse as messagePasswordSettingsResponse } from '../../utils/types/messaging/PasswordSettingsResponse';
/**
* Check if the user is logged in and if the vault is locked.
@@ -20,7 +21,7 @@ export async function handleCheckAuthStatus() : Promise<{ isLoggedIn: boolean, i
const vaultData = await storage.getItem('session:encryptedVault');
const isLoggedIn = username !== null && accessToken !== null;
const isVaultLocked = isLoggedIn && vaultData !== null;
const isVaultLocked = isLoggedIn && vaultData === null;
return {
isLoggedIn,
@@ -196,13 +197,13 @@ export async function getEmailAddressesForVault(
const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[];
const emailAddresses = credentials
.filter(cred => cred.Email != null)
.map(cred => cred.Email)
.filter(cred => cred.Alias?.Email != null)
.map(cred => cred.Alias.Email ?? '')
.filter((email, index, self) => self.indexOf(email) === index);
return emailAddresses.filter(email => {
const domain = email.split('@')[1];
return privateEmailDomains.includes(domain);
const domain = email?.split('@')[1];
return domain && privateEmailDomains.includes(domain);
});
}
@@ -210,8 +211,8 @@ export async function getEmailAddressesForVault(
* Get default email domain for a vault.
*/
export function handleGetDefaultEmailDomain(
) : Promise<messageDefaultEmailDomainResponse> {
return (async () : Promise<messageDefaultEmailDomainResponse> => {
) : Promise<stringResponse> {
return (async () : Promise<stringResponse> => {
try {
const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[];
const publicEmailDomains = await storage.getItem('session:publicEmailDomains') as string[];
@@ -232,21 +233,21 @@ export function handleGetDefaultEmailDomain(
// First check if the default domain that is configured in the vault is still valid.
if (defaultEmailDomain && isValidDomain(defaultEmailDomain)) {
return { success: true, domain: defaultEmailDomain };
return { success: true, value: defaultEmailDomain };
}
// If default domain is not valid, fall back to first available private domain.
const firstPrivate = privateEmailDomains.find(isValidDomain);
if (firstPrivate) {
return { success: true, domain: firstPrivate };
return { success: true, value: firstPrivate };
}
// Return first valid public domain if no private domains are available.
const firstPublic = publicEmailDomains.find(isValidDomain);
if (firstPublic) {
return { success: true, domain: firstPublic };
return { success: true, value: firstPublic };
}
// Return null if no valid domains are found
@@ -258,6 +259,38 @@ export function handleGetDefaultEmailDomain(
})();
}
/**
* Get the default identity language.
*/
export async function handleGetDefaultIdentityLanguage(
) : Promise<stringResponse> {
try {
const sqliteClient = await createVaultSqliteClient();
const settingValue = sqliteClient.getDefaultIdentityLanguage();
return { success: true, value: settingValue };
} catch (error) {
console.error('Error getting default identity language:', error);
return { success: false, error: 'Failed to get default identity language' };
}
}
/**
* Get the password settings.
*/
export async function handleGetPasswordSettings(
) : Promise<messagePasswordSettingsResponse> {
try {
const sqliteClient = await createVaultSqliteClient();
const passwordSettings = sqliteClient.getPasswordSettings();
return { success: true, settings: passwordSettings };
} catch (error) {
console.error('Error getting password settings:', error);
return { success: false, error: 'Failed to get password settings' };
}
}
/**
* Get the derived key for the encrypted vault.
*/

View File

@@ -26,7 +26,7 @@ export default defineContentScript({
const ui = await createShadowRootUi(ctx, {
name: 'aliasvault-ui',
position: 'inline',
anchor: 'body',
anchor: 'html',
/**
* Handle mount.
*/
@@ -39,6 +39,12 @@ export default defineContentScript({
return;
}
// Check if element itself, html or body has av-disable attribute like av-disable="true"
const avDisable = (e.target as HTMLElement).getAttribute('av-disable') ?? document.body?.getAttribute('av-disable') ?? document.documentElement.getAttribute('av-disable');
if (avDisable === 'true') {
return;
}
const target = e.target as HTMLInputElement;
const textInputTypes = ['text', 'email', 'tel', 'password', 'search', 'url'];
@@ -86,7 +92,7 @@ export default defineContentScript({
const formDetector = new FormDetector(document, target);
if (!formDetector.containsLoginForm(true)) {
if (!formDetector.containsLoginForm()) {
return { success: false, error: 'No form found' };
}

View File

@@ -27,7 +27,7 @@ export function filterCredentials(credentials: Credential[], currentUrl: string,
const credDomainParts = credUrlObject.hostname.toLowerCase().split('.');
const currentDomainParts = currentUrlObject.hostname.toLowerCase().split('.');
// Get root domain (last two parts, e.g., 'aliasvaul.net')
// Get root domain (last two parts, e.g., 'aliasvault.net')
const credRootDomain = credDomainParts.slice(-2).join('.');
const currentRootDomain = currentDomainParts.slice(-2).join('.');

View File

@@ -36,8 +36,8 @@ export function hidePopupFor(ms: number) : void {
* @param input - The input element that triggered the popup. Required when filling credentials to know which form to fill.
*/
export function fillCredential(credential: Credential, input: HTMLInputElement) : void {
// Set debounce time to 800ms to prevent the popup from being shown again within 800ms because of autofill events.
hidePopupFor(800);
// Set debounce time to 300ms to prevent the popup from being shown again within 300ms because of autofill events.
hidePopupFor(300);
const formDetector = new FormDetector(document, input);
const form = formDetector.getForm();
@@ -161,51 +161,53 @@ export function injectIcon(input: HTMLInputElement, container: HTMLElement): voi
* Trigger input events for an element to trigger form validation
* which some websites require before the "continue" button is enabled.
*/
function triggerInputEvents(element: HTMLInputElement | HTMLSelectElement) : void {
// Create an overlay div that will show the highlight effect
const overlay = document.createElement('div');
function triggerInputEvents(element: HTMLInputElement | HTMLSelectElement, animate: boolean = true) : void {
// Add keyframe animation if animation is requested
if (animate) {
// Create an overlay div that will show the highlight effect
const overlay = document.createElement('div');
/**
* Update position of the overlay.
*/
const updatePosition = () : void => {
const rect = element.getBoundingClientRect();
overlay.style.cssText = `
position: fixed;
z-index: 999999991;
pointer-events: none;
top: ${rect.top}px;
left: ${rect.left}px;
width: ${rect.width}px;
height: ${rect.height}px;
background-color: rgba(244, 149, 65, 0.3);
border-radius: ${getComputedStyle(element).borderRadius};
animation: fadeOut 1.4s ease-out forwards;
/**
* Update position of the overlay.
*/
const updatePosition = () : void => {
const rect = element.getBoundingClientRect();
overlay.style.cssText = `
position: fixed;
z-index: 999999991;
pointer-events: none;
top: ${rect.top}px;
left: ${rect.left}px;
width: ${rect.width}px;
height: ${rect.height}px;
background-color: rgba(244, 149, 65, 0.3);
border-radius: ${getComputedStyle(element).borderRadius};
animation: fadeOut 1.4s ease-out forwards;
`;
};
updatePosition();
// Add scroll event listener
window.addEventListener('scroll', updatePosition);
const style = document.createElement('style');
style.textContent = `
@keyframes fadeOut {
0% { opacity: 1; transform: scale(1.02); }
100% { opacity: 0; transform: scale(1); }
}
`;
};
document.head.appendChild(style);
document.body.appendChild(overlay);
updatePosition();
// Add scroll event listener
window.addEventListener('scroll', updatePosition);
// Add keyframe animation
const style = document.createElement('style');
style.textContent = `
@keyframes fadeOut {
0% { opacity: 1; transform: scale(1.02); }
100% { opacity: 0; transform: scale(1); }
}
`;
document.head.appendChild(style);
document.body.appendChild(overlay);
// Remove overlay and cleanup after animation
setTimeout(() => {
window.removeEventListener('scroll', updatePosition);
overlay.remove();
style.remove();
}, 1400);
// Remove overlay and cleanup after animation
setTimeout(() => {
window.removeEventListener('scroll', updatePosition);
overlay.remove();
style.remove();
}, 1400);
}
// Trigger events
element.dispatchEvent(new Event('input', { bubbles: true }));

View File

File diff suppressed because it is too large Load Diff

View File

@@ -26,16 +26,22 @@ body {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
padding: 16px;
gap: 8px;
}
.av-loading-spinner {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 2;
width: 16px;
height: 16px;
border: 2px solid #e5e7eb;
border-radius: 50%;
border-top-color: transparent;
animation: av-loading-spin 1s linear infinite;
}
@keyframes av-loading-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.av-loading-text {
@@ -302,7 +308,7 @@ body {
max-width: 90vw;
transform: scale(0.95);
opacity: 0;
padding: 24px;
padding: 16px 24px;
transition: transform 0.2s ease, opacity 0.2s ease;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
@@ -313,17 +319,83 @@ body {
}
.av-create-popup-title {
margin: 0 0 16px 0;
margin: 0;
font-size: 18px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-weight: 600;
color: #f8f9fa;
}
.av-create-popup-help-text {
margin: 4px 0 0;
font-size: 13px;
color: #9ca3af;
text-align: center;
line-height: 1.4;
padding: 0 16px;
}
.av-create-popup-modes {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 10px;
}
.av-create-popup-mode-btn {
display: flex;
align-items: center;
gap: 16px;
padding: 8px;
background: #374151;
border: 1px solid #4b5563;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
width: 100%;
}
.av-create-popup-mode-btn:hover {
background: #4b5563;
transform: translateY(-1px);
}
.av-create-popup-mode-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: #1f2937;
border-radius: 8px;
color: #d68338;
}
.av-create-popup-mode-icon .av-icon {
width: 24px;
height: 24px;
}
.av-create-popup-mode-content {
flex: 1;
}
.av-create-popup-mode-content h4 {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
color: #f8f9fa;
}
.av-create-popup-mode-content p {
margin: 0;
font-size: 14px;
color: #9ca3af;
}
.av-create-popup-input {
width: 100%;
padding: 8px 12px;
margin-bottom: 24px;
border: 1px solid #374151;
border-radius: 6px;
background: #374151;
@@ -338,10 +410,127 @@ body {
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.av-create-popup-input-default {
color: #737373;
}
/* Custom Credential UI Styles */
.av-create-popup-custom-toggle {
margin: 16px 0;
padding: 0 16px;
}
.av-create-popup-toggle-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.av-create-popup-toggle-text {
font-size: 14px;
color: #4b5563;
}
.av-create-popup-custom-fields {
margin: 16px 0;
padding: 0 16px;
}
.av-create-popup-field-group {
margin-bottom: 24px;
}
.av-create-popup-field-group label {
display: block;
margin-bottom: 0.5rem;
color: #eee;
font-size: 0.875rem;
font-weight: 500;
}
.av-create-popup-input-error {
border-color: #ef4444 !important;
box-shadow: 0 0 0 1px #ef4444 !important;
}
.av-create-popup-error-text {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
margin-left: 5px;
}
.av-create-popup-password-preview {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.av-create-popup-password-preview input {
flex: 1;
width: 100%;
}
.av-create-popup-regenerate-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 9px;
background: #374151;
border: none;
border-radius: 4px;
cursor: pointer;
color: #e5e7eb;
transition: background-color 0.2s ease;
flex-shrink: 0;
}
.av-create-popup-regenerate-btn:hover {
background-color: #4b5563;
}
.av-create-popup-regenerate-btn .av-icon {
width: 16px;
height: 16px;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.av-create-popup-error {
margin-top: 16px;
padding: 8px 12px;
background-color: #fee2e2;
color: #dc2626;
border-radius: 4px;
font-size: 14px;
animation: fadeIn 0.2s ease-in-out;
}
.av-create-popup-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.av-create-popup-back {
padding: 8px 16px;
border-radius: 6px;
border: 1px solid #374151;
background: transparent;
color: #f8f9fa;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
}
.av-create-popup-back:hover {
background: #374151;
}
.av-create-popup-cancel {
@@ -418,7 +607,162 @@ body {
transition: opacity 0.2s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeOut {
0% { opacity: 1; transform: scale(1.02); }
100% { opacity: 0; transform: scale(1); }
}
/* Create Popup Styles */
.av-create-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
.av-create-popup-mode {
margin-top: 20px;
}
.av-create-popup-title-container {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
justify-content: center;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.av-create-popup-title-wrapper {
display: flex;
align-items: center;
gap: 8px;
color: #d68338;
}
.av-create-popup-title-wrapper .av-icon {
width: 20px;
height: 20px;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.av-create-popup-title-wrapper .av-create-popup-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #f8f9fa;
}
.av-create-popup-title-container:hover {
background-color: #374151;
}
.av-create-popup-mode-dropdown {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: #9ca3af;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
}
.av-create-popup-mode-dropdown:hover {
background-color: #4b5563;
}
.av-create-popup-mode-dropdown .av-icon {
width: 16px;
height: 16px;
}
.av-create-popup-mode-dropdown-menu {
position: absolute;
left: 50%;
transform: translateX(-50%);
background: #1f2937;
border: 1px solid #374151;
border-radius: 8px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
z-index: 1000;
min-width: 280px;
}
.av-create-popup-mode-dropdown-menu::before {
content: '';
position: absolute;
top: -6px;
left: 50%;
width: 12px;
height: 12px;
background: #1f2937;
border-left: 1px solid #374151;
border-top: 1px solid #374151;
transform: translateX(-50%) rotate(45deg);
}
.av-create-popup-mode-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
width: 100%;
border: none;
background: none;
cursor: pointer;
text-align: left;
transition: background-color 0.2s;
position: relative;
z-index: 100;
}
.av-create-popup-mode-option:hover {
background-color: #374151;
}
.av-create-popup-mode-option .av-create-popup-mode-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background-color: #374151;
border-radius: 8px;
color: #d68338;
}
.av-create-popup-mode-option .av-create-popup-mode-content {
flex: 1;
}
.av-create-popup-mode-option .av-create-popup-mode-content h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #f8f9fa;
}
.av-create-popup-mode-option .av-create-popup-mode-content p {
margin: 4px 0 0;
font-size: 12px;
color: #9ca3af;
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
type ButtonProps = {
onClick: () => void;
onClick?: () => void;
children: React.ReactNode;
type?: 'button' | 'submit' | 'reset';
variant?: 'primary' | 'secondary';

View File

@@ -1,12 +1,213 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useDb } from '../context/DbContext';
import { Credential } from '../../../utils/types/Credential';
import { Buffer } from 'buffer';
import { FormInputCopyToClipboard } from '../components/FormInputCopyToClipboard';
import { EmailPreview } from '../components/EmailPreview';
import { TotpViewer } from '../components/TotpViewer';
import { useLoading } from '../context/LoadingContext';
import SqliteClient from '../../../utils/SqliteClient';
type BlockProps = {
children: React.ReactNode;
className?: string;
}
/**
* Render a block.
*/
const Block: React.FC<BlockProps> = ({ children, className = '' }) => (
<div className={`space-y-4 ${className}`}>
{children}
</div>
);
/**
* Render the header block.
*/
const HeaderBlock: React.FC<{ credential: Credential; onOpenNewPopup: () => void }> = ({ credential, onOpenNewPopup }) => (
<Block className="mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center">
<img
src={SqliteClient.imgSrcFromBytes(credential.Logo)}
alt={credential.ServiceName}
className="w-12 h-12 rounded-lg mr-4"
/>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{credential.ServiceName}</h1>
{credential.ServiceUrl && (
<a
href={credential.ServiceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
{credential.ServiceUrl}
</a>
)}
</div>
</div>
<button
onClick={onOpenNewPopup}
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
title="Open in new window"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
</button>
</div>
</Block>
);
/**
* Render the email block.
*/
const EmailBlock: React.FC<{ email: string; isSupported: boolean }> = ({ email, isSupported }) => (
<Block>
{isSupported && <EmailPreview email={email} />}
</Block>
);
/**
* Render the TOTP viewer block.
*/
const TotpBlock: React.FC<{ credentialId: string }> = ({ credentialId }) => (
<Block>
<TotpViewer credentialId={credentialId} />
</Block>
);
/**
* Render the login credentials block.
*/
const LoginCredentialsBlock: React.FC<{ credential: Credential }> = ({ credential }) => {
const email = credential.Alias?.Email?.trim();
const username = credential.Username?.trim();
const password = credential.Password?.trim();
if (!email && !username && !password) {
return null;
}
return (
<Block>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Login credentials</h2>
{email && (
<FormInputCopyToClipboard
id="email"
label="Email"
value={email}
/>
)}
{username && (
<FormInputCopyToClipboard
id="username"
label="Username"
value={username}
/>
)}
{password && (
<FormInputCopyToClipboard
id="password"
label="Password"
value={password}
type="password"
/>
)}
</Block>
);
};
/**
* Render the alias block.
*/
const AliasBlock: React.FC<{ credential: Credential; isValidDate: (date: string | null | undefined) => boolean }> = ({
credential,
isValidDate
}) => {
const hasFirstName = Boolean(credential.Alias?.FirstName?.trim());
const hasLastName = Boolean(credential.Alias?.LastName?.trim());
const hasNickName = Boolean(credential.Alias?.NickName?.trim());
const hasBirthDate = isValidDate(credential.Alias?.BirthDate);
if (!hasFirstName && !hasLastName && !hasNickName && !hasBirthDate) {
return null;
}
return (
<Block>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Alias</h2>
{(hasFirstName || hasLastName) && (
<FormInputCopyToClipboard
id="fullName"
label="Full Name"
value={[credential.Alias?.FirstName, credential.Alias?.LastName].filter(Boolean).join(' ')}
/>
)}
{hasFirstName && (
<FormInputCopyToClipboard
id="firstName"
label="First Name"
value={credential.Alias?.FirstName}
/>
)}
{hasLastName && (
<FormInputCopyToClipboard
id="lastName"
label="Last Name"
value={credential.Alias?.LastName}
/>
)}
{hasBirthDate && (
<FormInputCopyToClipboard
id="birthDate"
label="Birth Date"
value={new Date(credential.Alias?.BirthDate).toISOString().split('T')[0]}
/>
)}
{hasNickName && (
<FormInputCopyToClipboard
id="nickName"
label="Nickname"
value={credential.Alias?.NickName ?? ''}
/>
)}
</Block>
);
};
/**
* Render the notes block.
*/
const NotesBlock: React.FC<{ notes: string | undefined }> = ({ notes }) => {
if (!notes) {
return null;
}
return (
<Block>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Notes</h2>
<div className="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
<p className="text-gray-900 dark:text-gray-100 whitespace-pre-wrap">
{notes}
</p>
</div>
</Block>
);
};
/**
* Credential details page.
@@ -21,7 +222,7 @@ const CredentialDetails: React.FC = () => {
/**
* Check if the current page is an expanded popup.
*/
const isPopup = () : boolean => {
const isPopup = (): boolean => {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('expanded') === 'true';
};
@@ -29,7 +230,7 @@ const CredentialDetails: React.FC = () => {
/**
* Open the credential details in a new expanded popup.
*/
const openInNewPopup = () : void => {
const openInNewPopup = (): void => {
const width = 380;
const height = 600;
const left = window.screen.width / 2 - width / 2;
@@ -41,38 +242,39 @@ const CredentialDetails: React.FC = () => {
`width=${width},height=${height},left=${left},top=${top},popup=true`
);
// Close the current tab
window.close();
};
/**
* Checks if the email domain is supported for email preview.
*
* @param email The email address to check
* @returns True if the domain is supported, false otherwise
* Check if the email domain is supported.
*/
const isEmailDomainSupported = (email: string): boolean => {
// Extract domain from email
const domain = email.split('@')[1]?.toLowerCase();
if (!domain) {
return false;
}
// Check if domain is in public or private domains
const publicDomains = dbContext.publicEmailDomains ?? [];
const privateDomains = dbContext.privateEmailDomains ?? [];
// Check if the domain ends with any of the supported domains
return [...publicDomains, ...privateDomains].some(supportedDomain =>
domain === supportedDomain || domain.endsWith(`.${supportedDomain}`)
);
};
/**
* Check if a date is valid.
*/
const isValidDate = useCallback((date: string | null | undefined): boolean => {
if (!date || date === '0001-01-01 00:00:00') {
return false;
}
const dateObj = new Date(date);
return !isNaN(dateObj.getTime());
}, []);
useEffect(() => {
// For popup windows, ensure we have proper history state for navigation
if (isPopup()) {
// Clear existing history and create fresh entries
window.history.replaceState({}, '', `popup.html#/credentials`);
window.history.pushState({}, '', `popup.html#/credentials/${id}`);
}
@@ -100,127 +302,26 @@ const CredentialDetails: React.FC = () => {
}
return (
<div className="">
<div className="space-y-4 mb-4">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center">
<img
src={credential.Logo ? `data:image/x-icon;base64,${Buffer.from(credential.Logo).toString('base64')}` : '/assets/images/service-placeholder.webp'}
alt={credential.ServiceName}
className="w-12 h-12 rounded-lg mr-4"
/>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{credential.ServiceName}</h1>
{credential.ServiceUrl && (
<a
href={credential.ServiceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
{credential.ServiceUrl}
</a>
)}
</div>
</div>
<button
onClick={openInNewPopup}
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
title="Open in new window"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
</button>
</div>
{credential.Email && (
<>
{isEmailDomainSupported(credential.Email) && (
<EmailPreview
email={credential.Email}
/>
)}
</>
)}
<TotpViewer credentialId={credential.Id} />
</div>
<div className="grid gap-6">
<div className="space-y-4 lg:col-span-2 xl:col-span-1">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Login credentials</h2>
<FormInputCopyToClipboard
id="email"
label="Email"
value={credential.Email ?? ''}
/>
<FormInputCopyToClipboard
id="username"
label="Username"
value={credential.Username}
/>
<FormInputCopyToClipboard
id="password"
label="Password"
value={credential.Password}
type="password"
/>
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Alias</h2>
<FormInputCopyToClipboard
id="fullName"
label="Full Name"
value={`${credential.Alias.FirstName} ${credential.Alias.LastName}`}
/>
<FormInputCopyToClipboard
id="firstName"
label="First Name"
value={credential.Alias.FirstName}
/>
<FormInputCopyToClipboard
id="lastName"
label="Last Name"
value={credential.Alias.LastName}
/>
<FormInputCopyToClipboard
id="birthDate"
label="Birth Date"
value={credential.Alias.BirthDate ? new Date(credential.Alias.BirthDate).toISOString().split('T')[0] : ''}
/>
{credential.Alias.NickName && (
<FormInputCopyToClipboard
id="nickName"
label="Nickname"
value={credential.Alias.NickName}
/>
)}
</div>
</div>
{credential.Notes && (
<div className="space-y-4 lg:col-span-2 xl:col-span-1">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Notes</h2>
<div className="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
<p className="text-gray-900 dark:text-gray-100 whitespace-pre-wrap">
{credential.Notes}
</p>
</div>
</div>
)}
</div>
<div className="space-y-6">
<HeaderBlock credential={credential} onOpenNewPopup={openInNewPopup} />
{credential.Alias?.Email && (
<EmailBlock
email={credential.Alias.Email}
isSupported={isEmailDomainSupported(credential.Alias.Email)}
/>
)}
<TotpBlock credentialId={credential.Id} />
<LoginCredentialsBlock credential={credential} />
<AliasBlock
credential={credential}
isValidDate={isValidDate}
/>
<NotesBlock notes={credential.Notes} />
</div>
);
};

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useDb } from '../context/DbContext';
import { Credential } from '../../../utils/types/Credential';
import { Buffer } from 'buffer';
import { useNavigate } from 'react-router-dom';
import { useLoading } from '../context/LoadingContext';
import { useWebApi } from '../context/WebApiContext';
@@ -10,7 +9,7 @@ import ReloadButton from '../components/ReloadButton';
import LoadingSpinner from '../components/LoadingSpinner';
import { useMinDurationLoading } from '../../../hooks/useMinDurationLoading';
import { sendMessage } from 'webext-bridge/popup';
import SqliteClient from '../../../utils/SqliteClient';
/**
* Credentials list page.
*/
@@ -107,11 +106,12 @@ const CredentialsList: React.FC = () => {
// Add this function to filter credentials
const filteredCredentials = credentials.filter(cred => {
const searchLower = searchTerm.toLowerCase();
return (
cred.ServiceName.toLowerCase().includes(searchLower) ||
cred.Username.toLowerCase().includes(searchLower) ||
(cred.Email?.toLowerCase().includes(searchLower))
);
const searchableFields = [
cred.ServiceName?.toLowerCase(),
cred.Username?.toLowerCase(),
cred.Alias?.Email?.toLowerCase()
];
return searchableFields.some(field => field?.includes(searchLower));
});
if (isLoading) {
@@ -163,7 +163,7 @@ const CredentialsList: React.FC = () => {
className="w-full p-2 border dark:border-gray-600 rounded flex items-center bg-white dark:bg-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<img
src={cred.Logo ? `data:image/x-icon;base64,${Buffer.from(cred.Logo).toString('base64')}` : '/assets/images/service-placeholder.webp'}
src={SqliteClient.imgSrcFromBytes(cred.Logo)}
alt={cred.ServiceName}
className="w-8 h-8 mr-2 flex-shrink-0"
onError={(e) => {

View File

@@ -12,6 +12,8 @@ import { LoginResponse } from '../../../utils/types/webapi/Login';
import LoginServerInfo from '../components/LoginServerInfo';
import { AppInfo } from '../../../utils/AppInfo';
import { storage } from 'wxt/storage';
import { ApiAuthError } from '../../../utils/types/errors/ApiAuthError';
/**
* Login page
*/
@@ -108,7 +110,7 @@ const Login: React.FC = () => {
}
// Try to get latest vault manually providing auth token.
const vaultResponseJson = await webApi.fetch<VaultResponse>('Vault', { method: 'GET', headers: {
const vaultResponseJson = await webApi.authFetch<VaultResponse>('Vault', { method: 'GET', headers: {
'Authorization': `Bearer ${validationResponse.token.token}`
} });
@@ -130,8 +132,13 @@ const Login: React.FC = () => {
// Show app.
hideLoading();
} catch {
setError('Could not reach AliasVault server. Please try again later or contact support if the problem persists.');
} catch (err) {
// Show API authentication errors as-is.
if (err instanceof ApiAuthError) {
setError(err.message);
} else {
setError('Could not reach AliasVault server. Please try again later or contact support if the problem persists.');
}
hideLoading();
}
};
@@ -143,13 +150,19 @@ const Login: React.FC = () => {
e.preventDefault();
setError(null);
if (!passwordHashString || !passwordHashBase64 || !loginResponse) {
throw new Error('Required login data not found');
}
try {
showLoading();
if (!passwordHashString || !passwordHashBase64 || !loginResponse) {
throw new Error('Required login data not found');
}
// Validate that 2FA code is a 6-digit number
const code = twoFactorCode.trim();
if (!/^\d{6}$/.test(code)) {
throw new ApiAuthError('Please enter a valid 6-digit authentication code.');
}
const validationResponse = await srpUtil.validateLogin2Fa(
credentials.username,
passwordHashString,
@@ -164,7 +177,7 @@ const Login: React.FC = () => {
}
// Try to get latest vault manually providing auth token.
const vaultResponseJson = await webApi.fetch<VaultResponse>('Vault', { method: 'GET', headers: {
const vaultResponseJson = await webApi.authFetch<VaultResponse>('Vault', { method: 'GET', headers: {
'Authorization': `Bearer ${validationResponse.token.token}`
} });
@@ -192,8 +205,13 @@ const Login: React.FC = () => {
setLoginResponse(null);
hideLoading();
} catch (err) {
setError('Invalid authentication code. Please try again.');
// Show API authentication errors as-is.
console.error('2FA error:', err);
if (err instanceof ApiAuthError) {
setError(err.message);
} else {
setError('Could not reach AliasVault server. Please try again later or contact support if the problem persists.');
}
hideLoading();
}
};

View File

@@ -2,6 +2,8 @@ import srp from 'secure-remote-password/client'
import { WebApiService } from '../../../utils/WebApiService';
import { LoginRequest, LoginResponse } from '../../../utils/types/webapi/Login';
import { ValidateLoginRequest, ValidateLoginRequest2Fa, ValidateLoginResponse } from '../../../utils/types/webapi/ValidateLogin';
import BadRequestResponse from '@/utils/types/webapi/BadRequestResponse';
import { ApiAuthError } from '../../../utils/types/errors/ApiAuthError';
/**
* Utility class for SRP authentication operations.
@@ -22,9 +24,27 @@ class SrpUtility {
* Initiate login with server.
*/
public async initiateLogin(username: string): Promise<LoginResponse> {
return this.webApiService.post<LoginRequest, LoginResponse>('Auth/login', {
username: username.toLowerCase().trim()
const model: LoginRequest = {
username: username.toLowerCase().trim(),
};
const response = await this.webApiService.rawFetch('Auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(model),
});
// Check if response is a bad request (400)
if (response.status === 400) {
const badRequestResponse = await response.json() as BadRequestResponse;
throw new ApiAuthError(badRequestResponse.title);
}
// For other responses, try to parse as LoginResponse
const loginResponse = await response.json() as LoginResponse;
return loginResponse;
}
/**
@@ -51,12 +71,30 @@ class SrpUtility {
privateKey
);
return this.webApiService.post<ValidateLoginRequest, ValidateLoginResponse>('Auth/validate', {
const model: ValidateLoginRequest = {
username: username.toLowerCase().trim(),
rememberMe: rememberMe,
clientPublicEphemeral: clientEphemeral.public,
clientSessionProof: sessionProof.proof,
};
const response = await this.webApiService.rawFetch('Auth/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(model),
});
// Check if response is a bad request (400)
if (response.status === 400) {
const badRequestResponse = await response.json() as BadRequestResponse;
throw new ApiAuthError(badRequestResponse.title);
}
// For other responses, try to parse as ValidateLoginResponse
const validateLoginResponse = await response.json() as ValidateLoginResponse;
return validateLoginResponse;
}
/**
@@ -83,14 +121,31 @@ class SrpUtility {
username,
privateKey
);
return this.webApiService.post<ValidateLoginRequest2Fa, ValidateLoginResponse>('Auth/validate-2fa', {
const model: ValidateLoginRequest2Fa = {
username: username.toLowerCase().trim(),
rememberMe: rememberMe,
rememberMe,
clientPublicEphemeral: clientEphemeral.public,
clientSessionProof: sessionProof.proof,
code2Fa: code2Fa,
code2Fa,
};
const response = await this.webApiService.rawFetch('Auth/validate-2fa', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(model),
});
// Check if response is a bad request (400)
if (response.status === 400) {
const badRequestResponse = await response.json() as BadRequestResponse;
throw new ApiAuthError(badRequestResponse.title);
}
// For other responses, try to parse as ValidateLoginResponse
const validateLoginResponse = await response.json() as ValidateLoginResponse;
return validateLoginResponse;
}
}

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.14.0';
public static readonly VERSION = '0.16.0';
/**
* The minimum supported AliasVault server (API) version. If the server version is below this, the

View File

@@ -2,6 +2,12 @@ import initSqlJs, { Database } from 'sql.js';
import { Credential } from './types/Credential';
import { EncryptionKey } from './types/EncryptionKey';
import { TotpCode } from './types/TotpCode';
import { PasswordSettings } from './types/PasswordSettings';
/**
* Placeholder base64 image for credentials without a logo.
*/
const placeholderBase64 = 'UklGRjoEAABXRUJQVlA4IC4EAAAwFwCdASqAAIAAPpFCm0olo6Ihp5IraLASCWUA0eb/0s56RrLtCnYfLPiBshdXWMx8j1Ez65f169iA4xUDBTEV6ylMQeCIj2b7RngGi7gKZ9WjKdSoy9R8JcgOmjCMlDmLG20KhNo/i/Dc/Ah5GAvGfm8kfniV3AkR6fxN6eKwjDc6xrDgSfS48G5uGV6WzQt24YAVlLSK9BMwndzfHnePK1KFchFrL7O3ulB8cGNCeomu4o+l0SrS/JKblJ4WTzj0DAD++lCUEouSfgRKdiV2TiYCD+H+l3tANKSPQFPQuzi7rbvxqGeRmXB9kDwURaoSTTpYjA9REMUi9uA6aV7PWtBNXgUzMLowYMZeos6Xvyhb34GmufswMHA5ZyYpxzjTphOak4ZjNOiz8aScO5ygiTx99SqwX/uL+HSeVOSraHw8IymrMwm+jLxqN8BS8dGcItLlm/ioulqH2j4V8glDgSut+ExkxiD7m8TGPrrjCQNJbRDzpOFsyCyfBZupvp8QjGKW2KGziSZeIWes4aTB9tRmeEBhnUrmTDZQuXcc67Fg82KHrSfaeeOEq6jjuUjQ8wUnzM4Zz3dhrwSyslVz/WvnKqYkr4V/TTXPFF5EjF4rM1bHZ8bK63EfTnK41+n3n4gEFoYP4mXkNH0hntnYcdTqiE7Gn+q0BpRRxnkpBSZlA6Wa70jpW0FGqkw5e591A5/H+OV+60WAo+4Mi+NlsKrvLZ9EiVaPnoEFZlJQx1fA777AJ2MjXJ4KSsrWDWJi1lE8yPs8V6XvcC0chDTYt8456sKXAagCZyY+fzQriFMaddXyKQdG8qBqcdYjAsiIcjzaRFBBoOK9sU+sFY7N6B6+xtrlu3c37rQKkI3O2EoiJOris54EjJ5OFuumA0M6riNUuBf/MEPFBVx1JRcUEs+upEBsCnwYski7FT3TTqHrx7v5AjgFN97xhPTkmVpu6sxRnWBi1fxIRp8eWZeFM6mUcGgVk1WeVb1yhdV9hoMo2TsNEPE0tHo/wvuSJSzbZo7wibeXM9v/rRfKcx7X93rfiXVnyQ9f/5CaAQ4lxedPp/6uzLtOS4FyL0bCNeZ6L5w+AiuyWCTDFIYaUzhwfG+/YTQpWyeZCdQIKzhV+3GeXI2cxoP0ER/DlOKymf1gm+zRU3sqf1lBVQ0y+mK/Awl9bS3uaaQmI0FUyUwHUKP7PKuXnO+LcwDv4OfPT6hph8smc1EtMe5ib/apar/qZ9dyaEaElALJ1KKxnHziuvVl8atk1fINSQh7OtXDyqbPw9o/nGIpTnv5iFmwmWJLis2oyEgPkJqyx0vYI8rjkVEzKc8eQavAJBYSpjMwM193Swt+yJyjvaGYWPnqExxKiNarpB2WSO7soCAZXhS1uEYHryrK47BH6W1dRiruqT0xpLih3MXiwU3VDwAAAA==';
/**
* Client for interacting with the SQLite database.
@@ -163,7 +169,6 @@ class SqliteClient {
Id: row.Id,
Username: row.Username,
Password: row.Password,
Email: row.Email,
ServiceName: row.ServiceName,
ServiceUrl: row.ServiceUrl,
Logo: row.Logo,
@@ -214,7 +219,6 @@ class SqliteClient {
Id: row.Id,
Username: row.Username,
Password: row.Password,
Email: row.Email,
ServiceName: row.ServiceName,
ServiceUrl: row.ServiceUrl,
Logo: row.Logo,
@@ -262,15 +266,15 @@ class SqliteClient {
/**
* Get setting from database for a given key.
* Returns empty string if setting is not found.
* Returns default value (empty string by default) if setting is not found.
*/
public getSetting(key: string): string {
public getSetting(key: string, defaultValue: string = ''): string {
const results = this.executeQuery<{ Value: string }>(`SELECT
s.Value
FROM Settings s
WHERE s.Key = ?`, [key]);
return results.length > 0 ? results[0].Value : '';
return results.length > 0 ? results[0].Value : defaultValue;
}
/**
@@ -280,6 +284,40 @@ class SqliteClient {
return this.getSetting('DefaultEmailDomain');
}
/**
* Get the default identity language from the database.
*/
public getDefaultIdentityLanguage(): string {
return this.getSetting('DefaultIdentityLanguage', 'en');
}
/**
* Get the password settings from the database.
*/
public getPasswordSettings(): PasswordSettings {
const settingsJson = this.getSetting('PasswordGenerationSettings');
// Default settings if none found or parsing fails
const defaultSettings: PasswordSettings = {
Length: 18,
UseLowercase: true,
UseUppercase: true,
UseNumbers: true,
UseSpecialChars: true,
UseNonAmbiguousChars: false
};
try {
if (settingsJson) {
return { ...defaultSettings, ...JSON.parse(settingsJson) };
}
} catch (error) {
console.warn('Failed to parse password settings:', error);
}
return defaultSettings;
}
/**
* Create a new credential with associated entities
* @param credential The credential object to insert
@@ -352,7 +390,7 @@ class SqliteClient {
const credentialId = crypto.randomUUID().toUpperCase();
this.executeUpdate(credentialQuery, [
credentialId,
credential.Username,
credential.Username ?? null,
credential.Notes ?? null,
serviceId,
aliasId,
@@ -460,6 +498,100 @@ class SqliteClient {
}
}
/**
* Convert binary data to a base64 encoded image source.
*/
public static imgSrcFromBytes(bytes: Uint8Array<ArrayBufferLike> | number[] | undefined): string {
// Handle base64 image data
if (bytes) {
try {
const logoBytes = this.toUint8Array(bytes);
const base64Logo = this.base64Encode(logoBytes);
// Detect image type from first few bytes
const mimeType = this.detectMimeType(logoBytes);
return `data:${mimeType};base64,${base64Logo}`;
} catch (error) {
console.error('Error setting logo:', error);
return `data:image/x-icon;base64,${placeholderBase64}`;
}
} else {
return `data:image/x-icon;base64,${placeholderBase64}`;
}
}
/**
* Detect MIME type from file signature (magic numbers)
*/
private static detectMimeType(bytes: Uint8Array): string {
/**
* Check if the file is an SVG file.
*/
const isSvg = () : boolean => {
const header = new TextDecoder().decode(bytes.slice(0, 5)).toLowerCase();
return header.includes('<?xml') || header.includes('<svg');
};
/**
* Check if the file is an ICO file.
*/
const isIco = () : boolean => {
return bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x01 && bytes[3] === 0x00;
};
/**
* Check if the file is an PNG file.
*/
const isPng = () : boolean => {
return bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47;
};
if (isSvg()) {
return 'image/svg+xml';
}
if (isIco()) {
return 'image/x-icon';
}
if (isPng()) {
return 'image/png';
}
return 'image/x-icon';
}
/**
* Convert various binary data formats to Uint8Array
*/
private static toUint8Array(buffer: Uint8Array | number[] | {[key: number]: number}): Uint8Array {
if (buffer instanceof Uint8Array) {
return buffer;
}
if (Array.isArray(buffer)) {
return new Uint8Array(buffer);
}
const length = Object.keys(buffer).length;
const arr = new Uint8Array(length);
for (let i = 0; i < length; i++) {
arr[i] = buffer[i];
}
return arr;
}
/**
* Base64 encode binary data.
*/
private static base64Encode(buffer: Uint8Array | number[] | {[key: number]: number}): string | null {
try {
const arr = Array.from(this.toUint8Array(buffer));
return btoa(arr.reduce((data, byte) => data + String.fromCharCode(byte), ''));
} catch (error) {
console.error('Error encoding to base64:', error);
return null;
}
}
/**
* Check if a table exists in the database
* @param tableName - The name of the table to check

View File

@@ -37,15 +37,13 @@ export class WebApiService {
}
/**
* Fetch data from the API.
* Fetch data from the API with authentication headers and access token refresh retry.
*/
public async fetch<T>(
public async authFetch<T>(
endpoint: string,
options: RequestInit = {},
parseJson: boolean = true
): Promise<T> {
const baseUrl = await this.getBaseUrl();
const url = baseUrl + endpoint;
const headers = new Headers(options.headers ?? {});
// Add authorization header if we have an access token
@@ -54,22 +52,19 @@ export class WebApiService {
headers.set('Authorization', `Bearer ${accessToken}`);
}
// Add client version header
headers.set('X-AliasVault-Client', `${AppInfo.CLIENT_NAME}-${AppInfo.VERSION}`);
const requestOptions: RequestInit = {
...options,
headers,
};
try {
const response = await fetch(url, requestOptions);
const response = await this.rawFetch(endpoint, requestOptions);
if (response.status === 401) {
const newToken = await this.refreshAccessToken();
if (newToken) {
headers.set('Authorization', `Bearer ${newToken}`);
const retryResponse = await fetch(url, {
const retryResponse = await this.rawFetch(endpoint, {
...requestOptions,
headers,
});
@@ -96,6 +91,34 @@ export class WebApiService {
}
}
/**
* Fetch data from the API without authentication headers and without access token refresh retry.
*/
public async rawFetch(
endpoint: string,
options: RequestInit = {}
): Promise<Response> {
const baseUrl = await this.getBaseUrl();
const url = baseUrl + endpoint;
const headers = new Headers(options.headers ?? {});
// Add client version header
headers.set('X-AliasVault-Client', `${AppInfo.CLIENT_NAME}-${AppInfo.VERSION}`);
const requestOptions: RequestInit = {
...options,
headers,
};
try {
const response = await fetch(url, requestOptions);
return response;
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
/**
* Refresh the access token.
*/
@@ -106,14 +129,11 @@ export class WebApiService {
}
try {
const baseUrl = await this.getBaseUrl();
const response = await fetch(`${baseUrl}Auth/refresh`, {
const response = await this.rawFetch('Auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Ignore-Failure': 'true',
'X-AliasVault-Client': `${AppInfo.CLIENT_NAME}-${AppInfo.VERSION}`,
},
body: JSON.stringify({
token: await this.getAccessToken(),
@@ -138,7 +158,7 @@ export class WebApiService {
* Issue GET request to the API.
*/
public async get<T>(endpoint: string): Promise<T> {
return this.fetch<T>(endpoint, { method: 'GET' });
return this.authFetch<T>(endpoint, { method: 'GET' });
}
/**
@@ -146,7 +166,7 @@ export class WebApiService {
*/
public async downloadBlobAndConvertToBase64(endpoint: string): Promise<string> {
try {
const response = await this.fetch<Response>(endpoint, {
const response = await this.authFetch<Response>(endpoint, {
method: 'GET',
headers: {
'Accept': 'application/octet-stream',
@@ -170,7 +190,7 @@ export class WebApiService {
data: TRequest,
parseJson: boolean = true
): Promise<TResponse> {
return this.fetch<TResponse>(endpoint, {
return this.authFetch<TResponse>(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -183,7 +203,7 @@ export class WebApiService {
* Issue PUT request to the API.
*/
public async put<TRequest, TResponse>(endpoint: string, data: TRequest): Promise<TResponse> {
return this.fetch<TResponse>(endpoint, {
return this.authFetch<TResponse>(endpoint, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -196,7 +216,7 @@ export class WebApiService {
* Issue DELETE request to the API.
*/
public async delete<T>(endpoint: string): Promise<T> {
return this.fetch<T>(endpoint, { method: 'DELETE' }, false);
return this.authFetch<T>(endpoint, { method: 'DELETE' }, false);
}
/**

View File

@@ -42,7 +42,7 @@ export const EnglishFieldPatterns: FieldPatterns = {
firstName: ['firstname', 'first-name', 'first_name', 'fname', 'name', 'given-name'],
lastName: ['lastname', 'last-name', 'last_name', 'lname', 'surname', 'family-name'],
email: ['email', 'mail', 'emailaddress'],
emailConfirm: ['confirm', 'verification', 'repeat', 'retype', 'verify'],
emailConfirm: ['confirm', 'verification', 'repeat', 'retype', 'verify', 'email2'],
password: ['password', 'pwd', 'pass'],
birthdate: ['birthdate', 'birth-date', 'dob', 'date-of-birth'],
gender: ['gender', 'sex'],

View File

@@ -7,6 +7,7 @@ import { CombinedFieldPatterns, CombinedGenderOptionPatterns } from "./FieldPatt
export class FormDetector {
private readonly document: Document;
private readonly clickedElement: HTMLElement | null;
private readonly visibilityCache: Map<HTMLElement, boolean>;
/**
* Constructor.
@@ -14,30 +15,106 @@ export class FormDetector {
public constructor(document: Document, clickedElement?: HTMLElement) {
this.document = document;
this.clickedElement = clickedElement ?? null;
this.visibilityCache = new Map();
}
/**
* Check if an element and all its parents are visible.
* This checks for display:none, visibility:hidden, and opacity:0
* Uses a cache to avoid redundant checks of the same elements.
*/
private isElementVisible(element: HTMLElement | null): boolean {
if (!element) {
return false;
}
// Check cache first
if (this.visibilityCache.has(element)) {
return this.visibilityCache.get(element)!;
}
let current: HTMLElement | null = element;
while (current) {
try {
const style = this.document.defaultView?.getComputedStyle(current);
if (!style) {
// Cache and return true for this element and all its parents
let parent: HTMLElement | null = current;
while (parent) {
this.visibilityCache.set(parent, true);
parent = parent.parentElement;
}
return true;
}
// Check for display:none
if (style.display === 'none') {
// Cache and return false for this element and all its parents
let parent: HTMLElement | null = current;
while (parent) {
this.visibilityCache.set(parent, false);
parent = parent.parentElement;
}
return false;
}
// Check for visibility:hidden
if (style.visibility === 'hidden') {
// Cache and return false for this element and all its parents
let parent: HTMLElement | null = current;
while (parent) {
this.visibilityCache.set(parent, false);
parent = parent.parentElement;
}
return false;
}
// Check for opacity:0
if (parseFloat(style.opacity) === 0) {
// Cache and return false for this element and all its parents
let parent: HTMLElement | null = current;
while (parent) {
this.visibilityCache.set(parent, false);
parent = parent.parentElement;
}
return false;
}
} catch {
// If we can't get computed style, cache and return true for this element and all its parents
let parent: HTMLElement | null = current;
while (parent) {
this.visibilityCache.set(parent, true);
parent = parent.parentElement;
}
return true;
}
current = current.parentElement;
}
// Cache and return true for the original element
this.visibilityCache.set(element, true);
return true;
}
/**
* Detect login forms on the page based on the clicked element.
*
* @param force - Force the detection of forms, skipping checks such as if the element contains autocomplete="off".
*/
public containsLoginForm(force: boolean = false): boolean {
if (this.clickedElement) {
const formWrapper = this.clickedElement.closest('form') ?? this.document.body;
public containsLoginForm(): boolean {
const formWrapper = this.clickedElement?.closest('form') ?? this.document.body;
/**
* Sanity check: if form contains more than 150 inputs, don't process as this is likely not a login form.
* This is a simple way to prevent processing large forms that are not login forms and making the browser page unresponsive.
*/
const inputCount = formWrapper.querySelectorAll('input').length;
if (inputCount > 200) {
return false;
}
/**
* Sanity check: if form contains more than 150 inputs, don't process as this is likely not a login form.
* This is a simple way to prevent processing large forms that are not login forms and making the browser page unresponsive.
*/
const inputCount = formWrapper.querySelectorAll('input').length;
if (inputCount > 200) {
return false;
}
// Check if the wrapper contains a password or likely username field before processing.
if (this.containsPasswordField(formWrapper) || this.containsLikelyUsernameOrEmailField(formWrapper, force)) {
return true;
}
// Check if the wrapper contains a password or likely username field before processing.
if (this.containsPasswordField(formWrapper) || this.containsLikelyUsernameOrEmailField(formWrapper)) {
return true;
}
return false;
@@ -45,8 +122,6 @@ export class FormDetector {
/**
* Detect login forms on the page based on the clicked element.
*
* @param force - Force the detection of forms, skipping checks such as if the element contains autocomplete="off".
*/
public getForm(): FormFields | null {
if (!this.clickedElement) {
@@ -80,12 +155,22 @@ export class FormDetector {
continue;
}
// Skip if element is not visible
if (!this.isElementVisible(input)) {
continue;
}
// Handle both input and select elements
const type = input.tagName.toLowerCase() === 'select' ? 'select' : input.type.toLowerCase();
if (!types.includes(type)) {
continue;
}
// Check for exact type match if types contains email, as that most likely is the email field.
if (types.includes('email') && input.type.toLowerCase() === 'email') {
return input;
}
// Collect all text attributes to check
const attributes = [
input.id,
@@ -103,11 +188,16 @@ export class FormDetector {
// Check for parent label and table cell structure
let currentElement = input;
for (let i = 0; i < 3; i++) {
// Check for parent label
const parentLabel = currentElement.closest('label');
if (parentLabel) {
attributes.push(parentLabel.textContent?.toLowerCase() ?? '');
for (let i = 0; i < 5; i++) {
// Stop if we have too many child elements (near body)
if (currentElement.children.length > 15) {
break;
}
// Check for label - search both parent and child elements
const childLabel = currentElement.querySelector('label');
if (childLabel) {
attributes.push(childLabel.textContent?.toLowerCase() ?? '');
break;
}
@@ -165,12 +255,16 @@ export class FormDetector {
['text', 'email']
);
// Find confirmation email field if primary exists
/*
* Find confirmation email field if primary exists
* and ensure it's not the same as the primary email field.
*/
const confirmEmail = primaryEmail
? this.findInputField(
form,
CombinedFieldPatterns.emailConfirm,
['text', 'email']
['text', 'email'],
[primaryEmail]
)
: null;
@@ -336,11 +430,11 @@ export class FormDetector {
? form.querySelectorAll<HTMLInputElement>('input[type="password"]')
: this.document.querySelectorAll<HTMLInputElement>('input[type="password"]');
const candidateArray = Array.from(candidates);
const visibleCandidates = Array.from(candidates).filter(input => this.isElementVisible(input));
return {
primary: candidateArray[0] ?? null,
confirm: candidateArray[1] ?? null
primary: visibleCandidates[0] ?? null,
confirm: visibleCandidates[1] ?? null
};
}
@@ -349,7 +443,7 @@ export class FormDetector {
*/
private containsPasswordField(wrapper: HTMLElement): boolean {
const passwordFields = this.findPasswordField(wrapper as HTMLFormElement | null);
if (passwordFields.primary) {
if (passwordFields.primary && this.isElementVisible(passwordFields.primary)) {
return true;
}
@@ -359,41 +453,29 @@ export class FormDetector {
/**
* Check if a form contains a likely username or email field.
*/
private containsLikelyUsernameOrEmailField(wrapper: HTMLElement, force: boolean = false): boolean {
private containsLikelyUsernameOrEmailField(wrapper: HTMLElement): boolean {
// Check if the form contains an email field.
const emailFields = this.findEmailField(wrapper as HTMLFormElement | null);
if (emailFields.primary) {
const isValid = force || emailFields.primary.getAttribute('autocomplete') !== 'off';
if (isValid) {
return true;
}
if (emailFields.primary && this.isElementVisible(emailFields.primary)) {
return true;
}
// Check if the form contains a username field.
const usernameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.username, ['text'], []);
if (usernameField) {
const isValid = force || usernameField.getAttribute('autocomplete') !== 'off';
if (isValid) {
return true;
}
if (usernameField && this.isElementVisible(usernameField)) {
return true;
}
// Check if the form contains a first name field.
const firstNameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.firstName, ['text'], []);
if (firstNameField) {
const isValid = force || firstNameField.getAttribute('autocomplete') !== 'off';
if (isValid) {
return true;
}
if (firstNameField && this.isElementVisible(firstNameField)) {
return true;
}
// Check if the form contains a last name field.
const lastNameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.lastName, ['text'], []);
if (lastNameField) {
const isValid = force || lastNameField.getAttribute('autocomplete') !== 'off';
if (isValid) {
return true;
}
if (lastNameField && this.isElementVisible(lastNameField)) {
return true;
}
return false;
@@ -433,16 +515,16 @@ export class FormDetector {
detectedFields.push(fullNameField);
}
const firstNameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.firstName, ['text'], detectedFields);
if (firstNameField) {
detectedFields.push(firstNameField);
}
const lastNameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.lastName, ['text'], detectedFields);
if (lastNameField) {
detectedFields.push(lastNameField);
}
const firstNameField = this.findInputField(wrapper as HTMLFormElement | null, CombinedFieldPatterns.firstName, ['text'], detectedFields);
if (firstNameField) {
detectedFields.push(firstNameField);
}
const birthdateField = this.findBirthdateFields(wrapper as HTMLFormElement | null, detectedFields);
if (birthdateField.single) {
detectedFields.push(birthdateField.single);

View File

@@ -11,8 +11,13 @@ export class FormFiller {
*/
public constructor(
private readonly form: FormFields,
private readonly triggerInputEvents: (element: HTMLInputElement | HTMLSelectElement) => void
) {}
private readonly triggerInputEvents: (element: HTMLInputElement | HTMLSelectElement, animate?: boolean) => void
) {
/**
* Trigger input events.
*/
this.triggerInputEvents = (element: HTMLInputElement | HTMLSelectElement, animate = true) : void => triggerInputEvents(element, animate);
}
/**
* Fill the fields of the form with the given credential.
@@ -29,42 +34,55 @@ export class FormFiller {
* @param credential The credential to fill the form with.
*/
private fillBasicFields(credential: Credential): void {
if (this.form.usernameField) {
if (this.form.usernameField && credential.Username) {
this.form.usernameField.value = credential.Username;
this.triggerInputEvents(this.form.usernameField);
}
if (this.form.passwordField) {
if (this.form.passwordField && credential.Password) {
this.fillPasswordField(this.form.passwordField, credential.Password);
this.triggerInputEvents(this.form.passwordField);
}
if (this.form.passwordConfirmField) {
if (this.form.passwordConfirmField && credential.Password) {
this.fillPasswordField(this.form.passwordConfirmField, credential.Password);
this.triggerInputEvents(this.form.passwordConfirmField);
}
if (this.form.emailField) {
this.form.emailField.value = credential.Email;
this.triggerInputEvents(this.form.emailField);
if (this.form.emailField && (credential.Alias?.Email !== undefined || credential.Username !== undefined)) {
if (credential.Alias?.Email) {
this.form.emailField.value = credential.Alias.Email;
this.triggerInputEvents(this.form.emailField);
} else if (credential.Username && !this.form.usernameField) {
/*
* If current form has no username field AND the credential has a username
* then we can assume the username should be used as the email.
*/
/*
* This applies to the usecase where the AliasVault credential was imported
* from a previous password manager that only had username/password fields
* or where the user manually created a credential with only a username/password.
*/
this.form.emailField.value = credential.Username;
this.triggerInputEvents(this.form.emailField);
}
}
if (this.form.emailConfirmField) {
this.form.emailConfirmField.value = credential.Email;
if (this.form.emailConfirmField && credential.Alias?.Email) {
this.form.emailConfirmField.value = credential.Alias.Email;
this.triggerInputEvents(this.form.emailConfirmField);
}
if (this.form.fullNameField) {
if (this.form.fullNameField && credential.Alias?.FirstName && credential.Alias?.LastName) {
this.form.fullNameField.value = `${credential.Alias.FirstName} ${credential.Alias.LastName}`;
this.triggerInputEvents(this.form.fullNameField);
}
if (this.form.firstNameField) {
if (this.form.firstNameField && credential.Alias?.FirstName) {
this.form.firstNameField.value = credential.Alias.FirstName;
this.triggerInputEvents(this.form.firstNameField);
}
if (this.form.lastNameField) {
if (this.form.lastNameField && credential.Alias?.LastName) {
this.form.lastNameField.value = credential.Alias.LastName;
this.triggerInputEvents(this.form.lastNameField);
}
@@ -72,7 +90,7 @@ export class FormFiller {
/**
* Fill the password field with the given password. This uses a small delay between each character to simulate human typing.
* In the past there have been issues where Microsoft 365 login forms would clear the password field when just setting the value directly.
* Simulates actual keystroke behavior by appending characters one by one.
*
* @param field The password field to fill.
* @param password The password to fill the field with.
@@ -80,14 +98,18 @@ export class FormFiller {
private async fillPasswordField(field: HTMLInputElement, password: string): Promise<void> {
// Clear the field first
field.value = '';
this.triggerInputEvents(field);
this.triggerInputEvents(field, true);
// Type each character with a small delay
for (let i = 0; i < password.length; i++) {
field.value = password.substring(0, i + 1);
for (const char of password) {
// Append the character to the current value instead of using substring
field.value += char;
// Small random delay between 5-15ms to simulate human typing
this.triggerInputEvents(field, false);
await new Promise(resolve => setTimeout(resolve, Math.random() * 10 + 5));
}
this.triggerInputEvents(field, false);
}
/**
@@ -95,7 +117,8 @@ export class FormFiller {
* @param credential The credential to fill the form with.
*/
private fillBirthdateFields(credential: Credential): void {
if (!credential.Alias.BirthDate) {
// TODO: when birth date is made optional in datamodel, we can remove this mindate check here.
if (!credential.Alias.BirthDate || credential.Alias.BirthDate === '0001-01-01 00:00:00') {
return;
}

View File

@@ -68,7 +68,12 @@ describe('FormDetector English tests', () => {
describe('English email form 1 detection', () => {
const htmlFile = 'en-email-form1.html';
// Assert that this test fails, because the autocomplete=off for the specified element.
testField(FormField.Email, 'P0-0', htmlFile);
});
describe('English login form 1 detection', () => {
const htmlFile = 'en-login-form1.html';
testField(FormField.Email, 'resolving_input', htmlFile);
});
});

View File

@@ -30,10 +30,46 @@ describe('FormDetector generic tests', () => {
});
});
describe('Form with autocomplete="off" not detected', () => {
describe('Form with autocomplete="off" still detected', () => {
const htmlFile = 'autocomplete-off.html';
it('should not detect form with autocomplete="off" on email field', () => {
it('should still detect form with autocomplete="off" on email field', () => {
const dom = createTestDom(htmlFile);
const document = dom.window.document;
const formDetector = new FormDetector(document);
const form = formDetector.containsLoginForm();
expect(form).toBe(true);
});
});
describe('Form with display:none not detected', () => {
const htmlFile = 'display-none.html';
it('should not detect form with display:none', () => {
const dom = createTestDom(htmlFile);
const document = dom.window.document;
const formDetector = new FormDetector(document);
const form = formDetector.containsLoginForm();
expect(form).toBe(false);
});
});
describe('Form with visibility:hidden not detected', () => {
const htmlFile = 'visibility-hidden.html';
it('should not detect form with visibility:hidden', () => {
const dom = createTestDom(htmlFile);
const document = dom.window.document;
const formDetector = new FormDetector(document);
const form = formDetector.containsLoginForm();
expect(form).toBe(false);
});
});
describe('Form with opacity:0 not detected', () => {
const htmlFile = 'opacity-zero.html';
it('should not detect form with opacity:0', () => {
const dom = createTestDom(htmlFile);
const document = dom.window.document;
const formDetector = new FormDetector(document);

View File

@@ -105,4 +105,14 @@ describe('FormDetector Dutch tests', () => {
testField(FormField.Password, 'user_password', htmlFile);
testField(FormField.PasswordConfirm, 'user_password_confirmation', htmlFile);
});
describe('Dutch registration form 10 detection', () => {
const htmlFile = 'nl-registration-form10.html';
testField(FormField.Email, 'tbxEmail1', htmlFile);
testField(FormField.EmailConfirm, 'tbxEmail2', htmlFile);
testField(FormField.FirstName, 'Field645', htmlFile);
testField(FormField.LastName, 'Field642', htmlFile);
testField(FormField.BirthDate, 'Field675', htmlFile);
});
});

View File

@@ -44,6 +44,17 @@ describe('FormFiller', () => {
expect(wasTriggerCalledFor(mockTriggerInputEvents, formFields.emailConfirmField)).toBe(true);
});
it('should use username as email when no email is provided and no username field exists', () => {
// Create a credential with an empty email string
const credentialWithoutEmail = { ...mockCredential, Alias: { ...mockCredential.Alias, Email: '' } };
formFields.usernameField = null;
formFiller.fillFields(credentialWithoutEmail);
expect(formFields.emailField?.value).toBe('testuser');
expect(wasTriggerCalledFor(mockTriggerInputEvents, formFields.emailField)).toBe(true);
});
it('should fill password and confirmation fields', async () => {
formFields.passwordConfirmField = document.createElement('input');

View File

@@ -55,27 +55,27 @@ export const testField = (fieldName: FormField, elementId: string, htmlFile: str
// Handle birthdate fields differently
if (fieldName === FormField.BirthDate) {
expect(result.birthdateField.single).toBe(expectedElement);
expect(result?.birthdateField.single).toBe(expectedElement);
} else if (fieldName === FormField.BirthDay) {
expect(result.birthdateField.day).toBe(expectedElement);
expect(result?.birthdateField.day).toBe(expectedElement);
} else if (fieldName === FormField.BirthMonth) {
expect(result.birthdateField.month).toBe(expectedElement);
expect(result?.birthdateField.month).toBe(expectedElement);
} else if (fieldName === FormField.BirthYear) {
expect(result.birthdateField.year).toBe(expectedElement);
expect(result?.birthdateField.year).toBe(expectedElement);
// Handle gender field differently
} else if (fieldName === FormField.Gender) {
expect(result.genderField.field).toBe(expectedElement);
expect(result?.genderField.field).toBe(expectedElement);
} else if (fieldName === FormField.GenderMale) {
expect(result.genderField.radioButtons?.male).toBe(expectedElement);
expect(result?.genderField.radioButtons?.male).toBe(expectedElement);
} else if (fieldName === FormField.GenderFemale) {
expect(result.genderField.radioButtons?.female).toBe(expectedElement);
expect(result?.genderField.radioButtons?.female).toBe(expectedElement);
} else if (fieldName === FormField.GenderOther) {
expect(result.genderField.radioButtons?.other).toBe(expectedElement);
expect(result?.genderField.radioButtons?.other).toBe(expectedElement);
// Handle default fields
} else {
const fieldKey = `${fieldName}Field` as keyof typeof result;
expect(result[fieldKey]).toBeDefined();
expect(result[fieldKey]).toBe(expectedElement);
expect(result?.[fieldKey]).toBeDefined();
expect(result?.[fieldKey]).toBe(expectedElement);
}
});
};
@@ -86,7 +86,7 @@ export const testField = (fieldName: FormField, elementId: string, htmlFile: str
export const testBirthdateFormat = (expectedFormat: string, htmlFile: string, focusedElementId: string) : void => {
it('should detect correct birthdate format', () => {
const { result } = setupFormTest(htmlFile, focusedElementId);
expect(result.birthdateField.format).toBe(expectedFormat);
expect(result?.birthdateField.format).toBe(expectedFormat);
});
};
@@ -179,13 +179,13 @@ export const createMockCredential = (): Credential => ({
Id: '123',
Username: 'testuser',
Password: 'testpass',
Email: 'test@example.com',
ServiceName: 'Test Service',
Alias: {
FirstName: 'John',
LastName: 'Doe',
BirthDate: '1991-02-03',
Gender: Gender.Male
Gender: Gender.Male,
Email: 'test@example.com',
}
});

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<title>Display None Test Form</title>
</head>
<body>
<div style="display: none;">
<form id="hidden-login-form" action="/login" method="post">
<div>
<label for="hidden-username">Username:</label>
<input type="text" id="hidden-username" name="username" />
</div>
<div>
<label for="hidden-password">Password:</label>
<input type="password" id="hidden-password" name="password" />
</div>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>

View File

File diff suppressed because it is too large Load Diff

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,21 @@
<!DOCTYPE html>
<html>
<head>
<title>Opacity Zero Test Form</title>
</head>
<body>
<div style="opacity: 0;">
<form id="hidden-login-form" action="/login" method="post">
<div>
<label for="hidden-username">Username:</label>
<input type="text" id="hidden-username" name="username" />
</div>
<div>
<label for="hidden-password">Password:</label>
<input type="password" id="hidden-password" name="password" />
</div>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<title>Visibility Hidden Test Form</title>
</head>
<body>
<div style="visibility: hidden;">
<form id="hidden-login-form" action="/login" method="post">
<div>
<label for="hidden-username">Username:</label>
<input type="text" id="hidden-username" name="username" />
</div>
<div>
<label for="hidden-password">Password:</label>
<input type="password" id="hidden-password" name="password" />
</div>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>

View File

@@ -1,3 +1,5 @@
import { PasswordSettings } from '../../types/PasswordSettings';
/**
* Generate a random password.
*/
@@ -6,12 +8,37 @@ export class PasswordGenerator {
private readonly uppercaseChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
private readonly numberChars = '0123456789';
private readonly specialChars = '!@#$%^&*()_+-=[]{}|;:,.<>?';
private readonly ambiguousChars = 'Il1O0';
private length: number = 18;
private useLowercase: boolean = true;
private useUppercase: boolean = true;
private useNumbers: boolean = true;
private useSpecial: boolean = true;
private useNonAmbiguous: boolean = false;
/**
* Create a new instance of PasswordGenerator.
* @param settings Optional password settings to initialize with.
*/
public constructor(settings?: PasswordSettings) {
if (settings) {
this.applySettings(settings);
}
}
/**
* Apply password settings to this generator.
*/
public applySettings(settings: PasswordSettings): this {
this.length = settings.Length;
this.useLowercase = settings.UseLowercase;
this.useUppercase = settings.UseUppercase;
this.useNumbers = settings.UseNumbers;
this.useSpecial = settings.UseSpecialChars;
this.useNonAmbiguous = settings.UseNonAmbiguousChars;
return this;
}
/**
* Set the length of the password.
@@ -53,11 +80,19 @@ export class PasswordGenerator {
return this;
}
/**
* Set if only non-ambiguous characters should be used.
*/
public useNonAmbiguousCharacters(use: boolean): this {
this.useNonAmbiguous = use;
return this;
}
/**
* Get a random index from the crypto module.
*/
private getUnbiasedRandomIndex(max: number): number {
// Calculate the largest multiple of max that fits within Uint32
// Calculate the largest multiple of max that fits within Uint32.
const limit = Math.floor((2 ** 32) / max) * max;
while (true) {
@@ -65,7 +100,7 @@ export class PasswordGenerator {
crypto.getRandomValues(array);
const value = array[0];
// Reject values that would introduce bias
// Reject values that would introduce bias.
if (value < limit) {
return value % max;
}
@@ -76,59 +111,149 @@ export class PasswordGenerator {
* Generate a random password.
*/
public generateRandomPassword(): string {
let chars = '';
let password = '';
// Build the character set based on settings
const chars = this.buildCharacterSet();
// Generate initial password.
let password = this.generateInitialPassword(chars);
// Ensure a character from each set is present as some websites require this.
password = this.ensureRequirements(password);
return password;
}
/**
* Build character set based on selected options.
*/
private buildCharacterSet(): string {
let chars = '';
// Build character set based on options
if (this.useLowercase) {
chars += this.lowercaseChars;
}
if (this.useUppercase) {
chars += this.uppercaseChars;
}
if (this.useNumbers) {
chars += this.numberChars;
}
if (this.useSpecial) {
chars += this.specialChars;
}
// Ensure at least one character set is selected
// Ensure at least one character set is selected, otherwise default to lowercase.
if (chars.length === 0) {
chars = this.lowercaseChars;
}
// Generate password
// Remove ambiguous characters if needed.
if (this.useNonAmbiguous) {
chars = this.removeAmbiguousCharacters(chars);
}
return chars;
}
/**
* Remove ambiguous characters from a character set.
*/
private removeAmbiguousCharacters(chars: string): string {
for (const ambChar of this.ambiguousChars) {
chars = chars.replace(ambChar, '');
}
return chars;
}
/**
* Generate initial random password.
*/
private generateInitialPassword(chars: string): string {
let password = '';
for (let i = 0; i < this.length; i++) {
password += chars[this.getUnbiasedRandomIndex(chars.length)];
}
return password;
}
// Ensure password contains at least one character from each selected set
/**
* Ensure the generated password meets all specified requirements.
*/
private ensureRequirements(password: string): string {
if (this.useLowercase && !/[a-z]/.exec(password)) {
const pos = this.getUnbiasedRandomIndex(this.length);
password = password.substring(0, pos) +
this.lowercaseChars[this.getUnbiasedRandomIndex(this.lowercaseChars.length)] +
password.substring(pos + 1);
password = this.addCharacterFromSet(
password,
this.getSafeCharacterSet(this.lowercaseChars, true)
);
}
if (this.useUppercase && !/[A-Z]/.exec(password)) {
const pos = this.getUnbiasedRandomIndex(this.length);
password = password.substring(0, pos) +
this.uppercaseChars[this.getUnbiasedRandomIndex(this.uppercaseChars.length)] +
password.substring(pos + 1);
password = this.addCharacterFromSet(
password,
this.getSafeCharacterSet(this.uppercaseChars, true)
);
}
if (this.useNumbers && !/\d/.exec(password)) {
const pos = this.getUnbiasedRandomIndex(this.length);
password = password.substring(0, pos) +
this.numberChars[this.getUnbiasedRandomIndex(this.numberChars.length)] +
password.substring(pos + 1);
password = this.addCharacterFromSet(
password,
this.getSafeCharacterSet(this.numberChars, false)
);
}
if (this.useSpecial && !/[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/.exec(password)) {
const pos = this.getUnbiasedRandomIndex(this.length);
password = password.substring(0, pos) +
this.specialChars[this.getUnbiasedRandomIndex(this.specialChars.length)] +
password.substring(pos + 1);
password = this.addCharacterFromSet(
password,
this.specialChars
);
}
return password;
}
/**
* Get a character set with ambiguous characters removed if needed.
*/
private getSafeCharacterSet(charSet: string, isAlpha: boolean): string {
// If we're not using non-ambiguous characters, just return the original set.
if (!this.useNonAmbiguous) {
return charSet;
}
let safeSet = charSet;
for (const ambChar of this.ambiguousChars) {
// For numeric sets, only process numeric ambiguous characters
if (!isAlpha && !/\d/.test(ambChar)) {
continue;
}
let charToRemove = ambChar;
// Handle case conversion for alphabetic characters.
if (isAlpha) {
if (charSet === this.lowercaseChars) {
charToRemove = ambChar.toLowerCase();
} else {
charToRemove = ambChar.toUpperCase();
}
}
safeSet = safeSet.replace(charToRemove, '');
}
return safeSet;
}
/**
* Add a character from the given set at a random position in the password.
*/
private addCharacterFromSet(password: string, charSet: string): string {
const pos = this.getUnbiasedRandomIndex(this.length);
const char = charSet[this.getUnbiasedRandomIndex(charSet.length)];
return password.substring(0, pos) + char + password.substring(pos + 1);
}
}

View File

@@ -5,16 +5,15 @@ import { Gender } from "../generators/Identity/types/Gender";
*/
export type Credential = {
Id: string;
Username: string;
Username?: string;
Password: string;
Email: string;
ServiceName: string;
ServiceUrl?: string;
Logo?: Uint8Array | number[];
Notes?: string;
Alias: {
FirstName: string;
LastName: string;
FirstName?: string;
LastName?: string;
NickName?: string;
BirthDate: string;
Gender?: Gender;

View File

@@ -0,0 +1,34 @@
/**
* Settings for password generation stored in SQLite database settings table as string.
*/
export type PasswordSettings = {
/**
* The length of the password.
*/
Length: number;
/**
* Whether to use lowercase letters.
*/
UseLowercase: boolean;
/**
* Whether to use uppercase letters.
*/
UseUppercase: boolean;
/**
* Whether to use numbers.
*/
UseNumbers: boolean;
/**
* Whether to use special characters.
*/
UseSpecialChars: boolean;
/**
* Whether to use non-ambiguous characters.
*/
UseNonAmbiguousChars: boolean;
}

View File

@@ -0,0 +1,14 @@
/**
* Custom error class for API authentication-related errors.
*/
export class ApiAuthError extends Error {
/**
* Creates a new instance of ApiAuthError.
*
* @param message - The error message.
*/
public constructor(message: string) {
super(message);
this.name = 'ApiAuthError';
}
}

View File

@@ -1,5 +0,0 @@
export type DefaultEmailDomainResponse = {
success: boolean,
error?: string,
domain?: string
};

View File

@@ -0,0 +1,7 @@
import { PasswordSettings } from "@/utils/types/PasswordSettings";
export type PasswordSettingsResponse = {
success: boolean,
error?: string,
settings?: PasswordSettings
};

View File

@@ -0,0 +1,5 @@
export type StringResponse = {
success: boolean,
error?: string,
value?: string
};

View File

@@ -0,0 +1,9 @@
type BadRequestResponse = {
type: string;
title: string;
status: number;
errors: Record<string, string[]>;
traceId: string;
};
export default BadRequestResponse;

View File

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

View File

@@ -239,9 +239,9 @@ GEM
minitest (5.25.1)
net-http (0.5.0)
uri
nokogiri (1.18.3-x86_64-linux-gnu)
nokogiri (1.18.4-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.3-x86_64-linux-musl)
nokogiri (1.18.4-x86_64-linux-musl)
racc (~> 1.4)
octokit (4.25.1)
faraday (>= 1, < 3)

View File

@@ -2,7 +2,7 @@
layout: default
title: Build from Source
parent: Advanced
nav_order: 1
nav_order: 2
---
# Build from Source
@@ -26,7 +26,7 @@ cd AliasVault
chmod +x install.sh
./install.sh build
```
> **Note:** The build process can take a while depending on your hardware (5-15 minutes).
> **Note:** The complete build process can take a while depending on your hardware (5-15 minutes).
3. After the script completes, you can access AliasVault at:
- Client: `https://localhost`

View File

@@ -2,7 +2,7 @@
layout: default
title: Database Backup
parent: Advanced
nav_order: 2
nav_order: 3
---
# Database Backup

View File

@@ -2,171 +2,109 @@
layout: default
title: Manual Setup
parent: Advanced
nav_order: 3
nav_order: 1
---
# Manual Setup
If you prefer to manually set up AliasVault, this README provides step-by-step instructions. Follow these steps if you prefer to execute all statements yourself.
If you prefer to manually set up AliasVault instead of using the `install.sh` script, this README provides step-by-step instructions.
{: .toc }
* TOC
{:toc}
---
## Prerequisites
- Docker and Docker Compose installed on your system
- Knowledge of working with direct Docker commands
- Knowledge of .env files
- OpenSSL for generating random passwords
## Steps
1. **Create required directories**
1. **Clone the git repository**
```bash
# Clone repository
git clone https://github.com/lanedirt/AliasVault.git
# Navigate to the AliasVault directory
cd AliasVault
```
2. **Create required directories**
Create the following directories in your project root:
```bash
# Create required directories
mkdir -p certificates/ssl certificates/app database/postgres
```
2. **Create .env file**
3. **Create .env file**
Copy the `.env.example` file to create a new `.env` file:
```bash
# Copy the .env.example file to create a new .env file
cp .env.example .env
```
3. **Set HOSTNAME**
4. **Set all required settings in .env**
Open the .env file in your favorite text editor and fill in all required variables
by following the instructions inside the file.
Update the .env file with your hostname (default is localhost):
```bash
HOSTNAME=localhost
# Open the .env file with your favorite editor, e.g. nano.
nano .env
```
4. **Set default ports**
5. **Start the docker containers**
Update the .env file with the ports you want to use for the AliasVault components. The values defined here are used by the docker-compose.yml file.
After you are done configuring your .env file, you can start the Docker Compose stack:
```bash
HTTP_PORT=80
HTTPS_PORT=443
SMTP_PORT=25
SMTP_TLS_PORT=587
# Start the docker compose stack
docker compose up -d
```
5. **Generate and set JWT_KEY**
Generate a random 32-char string for JWT token generation:
```bash
openssl rand -base64 32
```
Add the generated key to the .env file:
```bash
JWT_KEY=your_generated_key_here
```
6. **Generate and set DATA_PROTECTION_CERT_PASS**
Generate a random password for the data protection certificate:
```bash
openssl rand -base64 32
```
Add it to the .env file:
```bash
DATA_PROTECTION_CERT_PASS=your_generated_password_here
```
7. **Configure PostgreSQL Settings**
Set the following PostgreSQL-related variables in your .env file:
```bash
# Database name (default: aliasvault)
POSTGRES_DB=aliasvault
# Database user (default: aliasvault)
POSTGRES_USER=aliasvault
# Generate a secure password for PostgreSQL
POSTGRES_PASSWORD=$(openssl rand -base64 32)
```
8. **Set PRIVATE_EMAIL_DOMAINS**
Update the .env file with allowed email domains. Use DISABLED.TLD to disable email support:
```bash
PRIVATE_EMAIL_DOMAINS=yourdomain.com,anotherdomain.com
```
Or to disable email:
```bash
PRIVATE_EMAIL_DOMAINS=DISABLED.TLD
```
9. **Set SUPPORT_EMAIL (Optional)**
Add a support email address if desired:
```bash
SUPPORT_EMAIL=support@yourdomain.com
```
10. **Generate admin password**
Build the Docker image for password hashing:
```bash
docker build -t installcli -f src/Utilities/AliasVault.InstallCli/Dockerfile .
```
Generate the password hash:
```bash
docker run --rm installcli "your_preferred_admin_password_here"
```
Add the password hash and generation timestamp to the .env file:
```bash
ADMIN_PASSWORD_HASH=<output_from_previous_command>
ADMIN_PASSWORD_GENERATED=2024-01-01T00:00:00Z
```
11. **Optional configuration**
Enable or disable public registration of new users:
```bash
PUBLIC_REGISTRATION_ENABLED=false
```
12. **Build and start Docker containers**
Build the Docker Compose stack:
```bash
docker compose -f docker-compose.yml -f docker-compose.build.yml build
```
Start the Docker Compose stack:
```bash
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d
```
13. **Access AliasVault**
6. **Access AliasVault**
AliasVault should now be running. You can access it at:
- Admin Panel: https://localhost/admin
- Username: admin
- Password: [Use the password you set in step 9]
- Password: [Use the password you set in the .env file]
- Client Website: https://localhost/
- Create your own account from here
> Note: if you changed the default ports from 80/443 to something else in the .env file, use those ports to access AliasVault here.
7. **Configuring private email domains**
By default, the AliasVault private email domains feature is disabled. If you wish to enable this so you can use your own private domains to create email aliases with, please read the `Email Server Setup` section in the main installation guide [Basic Install](../install.md#3-email-server-setup).
For more information, read the article explaining the differences between AliasVault's [private and public domains](../../misc/private-vs-public-email.md).
## Important Notes
- Make sure to save both the admin password and PostgreSQL password in a secure location.
- If you need to reset the admin password in the future, repeat step 9 and restart the Docker containers.
- Always keep your .env file secure and do not share it, as it contains sensitive information.
- The PostgreSQL data is persisted in the `database/postgres` directory.
- The docker-compose.yml file uses the `:latest` tag for containers by default. This means it always uses the latest available AliasVault version. In order to update AliasVault to a newer version at a later time, you can pull new containers when they are available with this command:
```
docker compose pull && docker compose down && docker compose up -d
```
## Troubleshooting
If you encounter any issues during the setup:
1. Check the Docker logs:
```bash
docker compose logs
```
2. Ensure all required ports (80, 443, and 5432) are available and not being used by other services.
3. Verify that all environment variables in the .env file are set correctly.
2. Ensure all required ports (80, 443, 25, 587 and 5432) are available and not being used by other services.
3. Verify that all variables in the .env file are set correctly.
4. Check PostgreSQL container logs specifically:
```bash
docker compose logs postgres

View File

@@ -27,13 +27,16 @@ To get AliasVault up and running quickly, run the install script to pull pre-bui
### Installation steps
1. Download the install script to a directory of your choice. All AliasVault files and directories will be created in this directory.
```bash
curl -o install.sh https://raw.githubusercontent.com/lanedirt/AliasVault/main/install.sh
# Download the install script
curl -L -o install.sh https://github.com/lanedirt/AliasVault/releases/latest/download/install.sh
```
2. Make the install script executable.
```bash
chmod +x install.sh
```
3. Run the install script. This will create the .env file, pull the Docker images, and start the AliasVault containers. Follow the on-screen prompts to configure AliasVault.
3. Run the installation wizard.
```bash
./install.sh install
```
@@ -43,6 +46,8 @@ chmod +x install.sh
- Client: `https://localhost`
- Admin: `https://localhost/admin`
> Note: if you do not wish to run the `install.sh` wizard but want to use Docker commands directly, follow the [manual setup guide](advanced/manual-setup.md). We do however encourage the use of `install.sh` as it will guide you through all configuration steps and allow for easy updating your AliasVault instance later.
---
## 2. SSL configuration

View File

@@ -102,10 +102,10 @@ Refer to the [installation guide](./install.md) for more information on how to c
### 4. Forgot AliasVault Admin Password
If you have lost your admin password, you can reset it by running the install script with the `reset-password` option. This will generate a new random password and update the .env file with it. After that it will restart the AliasVault containers to apply the changes.
If you have lost your admin password, you can reset it by running the install script with the `reset-admin-password` option. This will generate a new random password and update the .env file with it. After that it will restart the AliasVault containers to apply the changes.
```bash
./install.sh reset-password
./install.sh reset-admin-password
```
---

View File

@@ -1,12 +1,12 @@
---
layout: default
title: Browser Extensions
title: Browser extensions
parent: Development
grand_parent: Miscellaneous
nav_order: 2
nav_order: 3
---
# Browser Extensions
# Browser extensions
AliasVault offers browser extensions compatible with both Chrome and Firefox. This guide explains how to build and debug the extensions locally.
## Development Setup
@@ -109,3 +109,5 @@ The following websites have been known to cause issues in the past (but should b
| https://bloshing.com/inschrijven-nieuwsbrief | Popup CSS style conflicts |
| https://gamefaqs.gamespot.com/user | Popup buttons not working |
| https://news.ycombinator.com/login?goto=news | Popup and client favicon not showing due to SVG format |
| https://vault.bitwarden.com/#/login | Autofill password not detected (input not long enough), manually typing in works |
| https://login.microsoftonline.com/ | Password gets reset after autofill |

View File

@@ -1,114 +0,0 @@
---
layout: default
title: Contributing
parent: Development
grand_parent: Miscellaneous
nav_order: 1
---
# Contributing
This document is a work-in-progress and will be expanded as time goes on. If you have any questions feel free to open a issue on GitHub.
Note: all instructions below are based on MacOS. If you are using a different operating system, you may need to adjust the commands accordingly.
## Getting Started
In order to contribute to this project follow these instructions to setup your local environment:
### 1. Clone the repository
```bash
git clone https://github.com/lanedirt/AliasVault.git
cd AliasVault
```
### 2. Copy pre-commit hook script to .git/hooks directory
{: .note }
All commits in this repo are required to contain a reference to a GitHub issue in the format of "your commit message (#123)" where "123" references the GitHub issue number.
The pre-commit hook script below will check the commit message before allowing the commit to proceed. If the commit message is invalid, the commit will be aborted.
```bash
# Copy the commit-msg hook script to the .git/hooks directory
cp .github/hooks/commit-msg .git/hooks/commit-msg
# Make the script executable
chmod +x .git/hooks/commit-msg
```
### 3. Install the latest version of .NET SDK 9
```bash
# Install .NET SDK 9
# On MacOS via brew:
brew install --cask dotnet-sdk
# On Windows via winget
winget install Microsoft.DotNet.SDK.9
```
### 4. Install dotnet CLI EF Tools
```bash
# Install dotnet EF tools globally
dotnet tool install --global dotnet-ef
# Include dotnet tools in your PATH
nano ~/.zshrc
# Add the following line to your .zshrc file
export PATH="$PATH:$HOME/.dotnet/tools"
# Start a new terminal and test that this command works:
dotnet ef
```
### 5. Install dev database
AliasVault uses PostgreSQL as its database. In order to run the project locally from Visual Studio / Rider you will need to install the dev database. You can do this by running the following command. This will start a separate PostgreSQL instance on port 5433 accessible via the `localhost:5433` address.
```bash
./install.sh configure-dev-db
```
After the database is running you can start the project from Visual Studio / Rider in run or debug mode and it should be able to connect to the dev database.
### 6. Run Tailwind CSS compiler when changing HTML files to update compiled CSS
```bash
# For Admin project (in the admin project directory)
npm run build:admin-css
# For Client project (in the client project directory)
npm run build:client-css
```
### 7. Install Playwright in order to locally run NUnit E2E (end-to-end) tests
```bash
# First install PowerShell for Mac (if you don't have it already)
brew install powershell/tap/powershell
# Install Playwright
dotnet tool install --global Microsoft.Playwright.CLI
# Run Playwright install script to download local browsers
# Note: make sure the E2E test project has been built at least once so the bin dir exists.
pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net9.0/playwright.ps1 install
```
### 8. Create AliasVault.Client appsettings.Development.json
The WASM client app supports a development specific appsettings.json file. This appsettings file is optional but can override various options to make debugging easier.
1. Copy `wwwroot/appsettings.json` to `wwwroot/appsettings.Development.json`
Here is an example file with the various options explained:
```json
{
"ApiUrl": "http://localhost:5092",
"PrivateEmailDomains": ["example.tld"],
"SupportEmail": "support@example.tld",
"UseDebugEncryptionKey": "true",
"CryptographyOverrideType" : "Argon2Id",
"CryptographyOverrideSettings" : "{\"DegreeOfParallelism\":1,\"MemorySize\":1024,\"Iterations\":1}"
}
```
- **UseDebugEncryptionKey**
- This setting will use a static encryption key so that if you login as a user you can refresh the page without needing to unlock the database again. This speeds up development when changing things in the WebApp WASM project. Note: the project needs to be run in "Development" mode for this setting to be used.
- **CryptographyOverrideType**
- This setting allows overriding the default encryption type (Argon2id) with a different encryption type. This is useful for testing different encryption types without having to change code.
- **CryptographyOverrideSettings**
- This setting allows overriding the default encryption settings (Argon2id) with different settings. This is useful for testing different encryption settings without having to change code. The default Argon2id settings are defined in the project as `Utilities/Cryptography/Cryptography.Client/Defaults.cs`. These default settings are focused on security but NOT performance. Normally for key derivation purposes the slower/heavier the algorithm the better protection against attackers. For production builds this is what we want, however in case of automated testing or debugging extra performance can be gained by tweaking (lowering) these settings.
```

View File

@@ -1,21 +1,51 @@
---
layout: default
title: PostgreSQL Commands
title: Database operations
parent: Development
grand_parent: Miscellaneous
nav_order: 2
nav_order: 3
---
# PostgreSQL Commands
# Database operations
This article contains tips for how to work with the AliasVault PostgreSQL database in both production and development environments.
## Backup database to file
## Using install.sh helper methods (recommended)
The `install.sh` script contains helper methods that makes it easy to export and import databases with a simple single command.
### Export database
```bash
# Export from normal database container (port 5432, production)
./install.sh db-export > aliasvault-db-export.sql.gz
# Export from dev database container (port 5433, development)
./install.sh db-export --dev > aliasvault-db-export.sql.gz
```
### Import database
```bash
# Import to normal database container (port 5432, production)
./install.sh db-import < aliasvault-db-export.sql.gz
# Import to dev database container (port 5433, development)
./install.sh db-import --dev < aliasvault-db-export.sql.gz
```
> Tip: you can also use the optional parameters `--yes` (to skip confirmation prompt) and `--verbose` (to get more output on what the operation is doing).
---
## Using docker commands
Instead of using the `install.sh script, you can also use manual Docker commands.
### Backup database to file
To backup the database to a file, you can use the following command:
```bash
docker compose exec postgres pg_dump -U aliasvault aliasvault | gzip > aliasvault.sql.gz
```
## Import database from file
### Import database from file
To drop the existing database and restore the database from a file, you can use the following command:
{: .warning }
@@ -27,7 +57,7 @@ docker compose exec postgres psql -U aliasvault postgres -c "CREATE DATABASE ali
gunzip < aliasvault.sql.gz | docker compose exec -iT postgres psql -U aliasvault aliasvault
```
## Change master password
### Change master password
By default during initial installation the PostgreSQL master password is set to a random string that is
stored in the `.env` file with the `POSTGRES_PASSWORD` variable.

View File

@@ -2,5 +2,23 @@
layout: default
title: Development
parent: Miscellaneous
nav_order: 2
nav_order: 1
---
# Development Guide
Choose your platform to get started with AliasVault development:
## Platform-Specific Dev Guides
- [Linux/MacOS Development Setup](linux-macos-development.md)
- [Windows Development Setup](windows-development.md)
## Common Development Topics
- [Browser extensions](browser-extensions.md)
- [Database operations](database-operations.md)
- [Running GitHub Actions Locally](run-github-actions-locally.md)
- [Upgrading EF Server Model](upgrade-ef-server-model.md)
- [Upgrading EF Client Model](upgrade-ef-client-model.md)
- [Enabling WebAuthn PFR in Chrome](enable-webauthn-pfr-chrome.md)

View File

@@ -0,0 +1,147 @@
---
layout: default
title: Linux/MacOS development
parent: Development
grand_parent: Miscellaneous
nav_order: 1
---
# Setting Up AliasVault Development Environment on Linux/MacOS
This guide will help you set up AliasVault for development on Linux or MacOS systems.
## Prerequisites
1. **Install .NET 9 SDK**
```bash
# On MacOS via brew:
brew install --cask dotnet-sdk
# On Linux:
# Follow instructions at https://dotnet.microsoft.com/download/dotnet/9.0
```
2. **Install Docker**
- Follow instructions at [Docker Desktop](https://www.docker.com/products/docker-desktop)
- For Linux, you can also use the native Docker daemon
## Setup Steps
1. **Clone the Repository**
```bash
git clone https://github.com/lanedirt/AliasVault.git
cd AliasVault
```
2. **Install dotnet CLI EF Tools**
```bash
# Install dotnet EF tools globally
dotnet tool install --global dotnet-ef
# Add to your shell's PATH (if not already done)
# For bash/zsh, add to ~/.bashrc or ~/.zshrc:
export PATH="$PATH:$HOME/.dotnet/tools"
# Verify installation
dotnet ef
```
3. **Install dev database**
```bash
./install.sh configure-dev-db
```
4. **Run Tailwind CSS compiler**
```bash
# For Admin project
cd src/AliasVault.Admin
npm run build:admin-css
# For Client project
cd src/AliasVault.Client
npm run build:client-css
```
5. **Install Playwright for E2E tests**
```bash
# Install Playwright CLI
dotnet tool install --global Microsoft.Playwright.CLI
# Install browsers
pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net9.0/playwright.ps1 install
```
6. **Configure Development Settings**
Create `wwwroot/appsettings.Development.json` in the Client project:
```json
{
"ApiUrl": "http://localhost:5092",
"PrivateEmailDomains": ["example.tld"],
"SupportEmail": "support@example.tld",
"UseDebugEncryptionKey": "true",
"CryptographyOverrideType": "Argon2Id",
"CryptographyOverrideSettings": "{\"DegreeOfParallelism\":1,\"MemorySize\":1024,\"Iterations\":1}"
}
```
## Running the Application
1. **Start the Development Database**
```bash
./install.sh configure-dev-db
```
2. **Run the Application**
```bash
# Using dotnet CLI
cd src/AliasVault.Api
dotnet run
# Or using your preferred IDE (VS Code, Rider, etc.)
```
## Troubleshooting
### Database Issues
If you encounter database connection issues:
1. **Check Database Status**
```bash
docker ps | grep postgres-dev
```
2. **Check Logs**
```bash
docker logs aliasvault-dev-postgres-dev-1
```
3. **Restart Database**
```bash
./install.sh configure-dev-db
```
### Common Issues
1. **Permission Issues**
```bash
# Fix script permissions
chmod +x install.sh
```
2. **Port Conflicts**
- Check if port 5433 is available for the development database
- Check if port 5092 is available for the API
## Additional Notes
- Keep your .NET SDK and Docker up to date
- The development database runs on port 5433 to avoid conflicts
- Use the debug encryption key in development for easier testing
- Store sensitive data in environment variables or user secrets
## Support
If you encounter any issues not covered in this guide, please:
1. Check the [GitHub Issues](https://github.com/lanedirt/AliasVault/issues)
2. Search for existing solutions
3. Create a new issue if needed

View File

@@ -1,6 +1,6 @@
---
layout: default
title: Run GitHub Actions Locally
title: Run GitHub actions locally
parent: Development
grand_parent: Miscellaneous
nav_order: 9

View File

@@ -0,0 +1,141 @@
---
layout: default
title: Windows development
parent: Development
grand_parent: Miscellaneous
nav_order: 2
---
# Setting Up AliasVault Development Environment on Windows
This guide will help you set up AliasVault for development on Windows using WSL (Windows Subsystem for Linux).
## Prerequisites
1. **Install WSL**
- Open PowerShell as Administrator and run:
```powershell
wsl --install
```
- This will install Ubuntu by default
- Restart your computer after installation
2. **Install Visual Studio 2022**
- Download from [Visual Studio Downloads](https://visualstudio.microsoft.com/downloads/)
- Required Workloads:
- ASP.NET and web development
- .NET WebAssembly development tools
- .NET cross-platform development
3. **Install .NET 9 SDK**
- Download from [.NET Downloads](https://dotnet.microsoft.com/download/dotnet/9.0)
- Install both Windows and Linux versions (you'll need both)
## Setup Steps
1. **Clone the Repository**
```bash
git clone https://github.com/lanedirt/AliasVault.git
cd AliasVault
```
2. **Configure WSL**
- Open WSL terminal
- Edit WSL configuration:
```bash
sudo nano /etc/wsl.conf
```
- Add the following configuration:
```ini
[automount]
enabled = true
options = "metadata,umask=22,fmask=11"
mountFsTab = false
[boot]
systemd=true
```
- Save the file (Ctrl+X, then Y)
- Restart WSL from PowerShell:
```powershell
wsl --shutdown
```
3. **Setup Development Database**
- Open a new WSL terminal in the AliasVault directory
- Run the development database setup:
```bash
./install.sh configure-dev-db
```
- Select option 1 to start the development database
- Verify the database is running:
```bash
docker ps | grep postgres-dev
```
4. **Run the Application**
- Open the solution in Visual Studio 2022
- Set WebApi as the startup project
- Press F5 to run in debug mode
## Troubleshooting
### Database Connection Issues
If the WebApi fails to start due to database connection issues:
1. **Check Database Status**
```bash
docker ps | grep postgres-dev
```
2. **Check Database Logs**
```bash
docker logs aliasvault-dev-postgres-dev-1
```
3. **Permission Issues**
If you see permission errors, try:
```bash
sudo mkdir -p ./database/postgres
sudo chown -R 999:999 ./database/postgres
sudo chmod -R 700 ./database/postgres
```
4. **Restart Development Database**
```bash
./install.sh configure-dev-db
# Select option 2 to stop, then option 1 to start again
```
### WSL Issues
If you experience WSL-related issues:
1. Make sure you have the latest WSL version:
```powershell
wsl --update
```
2. Verify WSL is running correctly:
```powershell
wsl --status
```
3. If problems persist, try resetting WSL:
```powershell
wsl --shutdown
wsl
```
## Additional Notes
- Always run the development database before starting the WebApi project
- Make sure you're using the correct .NET SDK version in both Windows and WSL
- If you modify the WSL configuration, always restart WSL afterward
- For best performance, store the project files in the Linux filesystem rather than the Windows filesystem
## Support
If you encounter any issues not covered in this guide, please:
1. Check the [GitHub Issues](https://github.com/lanedirt/AliasVault/issues)
2. Search for existing solutions
3. Create a new issue if needed

View File

@@ -14,7 +14,6 @@ Follow the steps in the checklist below to prepare a new release.
- [ ] Update ./src/Shared/AliasVault.Shared.Core/AppInfo.cs with the minimum supported client versions.
- In case API output breaks earlier client versions and/or this version of the client/API will upgrade the client vault model to a new major version.
- [ ] Update ./install.sh `@version` in header if the install script has changed. This allows the install script to self-update when running the `./install.sh update` command on default installations.
- [ ] Update README.md install.sh download link to point to the new release version
## Versioning browser extension
- [ ] Update ./browser-extension/wxt.config.ts with the new version for the extension. This will be shown in the browser extension web stores. This version should be equal to the git release tag.
@@ -25,7 +24,7 @@ Follow the steps in the checklist below to prepare a new release.
## Docker Images
If docker containers have been added or removed:
- [ ] Verify that `.github/workflows/publish-docker-images.yml` contains references to all docker images that need to be published.
- [ ] Verify that `.github/workflows/release.yml` contains references to all docker images that need to be published.
- [ ] Update `install.sh` and verify that the `images=()` array that takes care of pulling the images from the GitHub Container Registry is updated.
## Manual Testing (since v0.10.0+)
@@ -57,4 +56,7 @@ The GitHub Actions workflow `Browser Extension Build` will build the browser ext
2. Upload the Chrome archive to the Chrome Web Store.
3. Upload the Firefox archive (normal + sources) to the Firefox Add-ons page.
4. Upload the Edge archive to the Microsoft Edge Add-ons page.
5. Submit the Safari extension to Apple for review by opening the `browser-extension/safari-xcode/AliasVault/AliasVault.xcodeproj` project in Xcode and submitting the extension via the "Distribute App" option.
5. Submit the Safari extension to Apple for review:
1. Navigate to the `browser-extension` directory.
2. Build the safari extension locally via `npm run build:safari`, which will output the build files to `dist/safari-mv2`. **Note: it's important to always rebuild, as otherwise stale build files from a previous build might get included in the Safari binary by accident!**
3. Open the `browser-extension/safari-xcode/AliasVault/AliasVault.xcodeproj` project in Xcode and submitting the extension via the "Archive" and then "Distribute App" option.

View File

@@ -1,5 +1,5 @@
#!/bin/bash
# @version 0.12.2
# @version 0.15.1
# Repository information used for downloading files and images from GitHub
REPO_OWNER="lanedirt"
@@ -38,26 +38,27 @@ show_usage() {
printf "\n"
printf "Commands:\n"
printf " install Install AliasVault by pulling pre-built images from GitHub Container Registry (recommended)\n"
printf " uninstall Uninstall AliasVault\n"
printf " update Update AliasVault to the latest version\n"
printf " update-installer Check and update install.sh script if newer version available\n"
printf " build Build AliasVault containers locally from source (takes longer and requires sufficient specs)\n"
printf " start Start AliasVault containers\n"
printf " restart Restart AliasVault containers\n"
printf " stop Stop AliasVault containers\n"
printf "\n"
printf " configure-hostname Configure the hostname where AliasVault can be accessed from\n"
printf " configure-ssl Configure SSL certificates (Let's Encrypt or self-signed)\n"
printf " configure-email Configure email domains for receiving emails\n"
printf " configure-registration Configure new account registration (enable or disable)\n"
printf " configure-ip-logging Configure IP address logging (enable or disable)\n"
printf " start Start AliasVault containers using remote images\n"
printf " stop Stop AliasVault containers using remote images\n"
printf " restart Restart AliasVault containers using remote images\n"
printf " reset-password Reset admin password\n"
printf " build [operation] Build AliasVault from source (takes longer and requires sufficient specs)\n"
printf " Optional operations: start|stop|restart (uses locally built images)\n"
printf " reset-admin-password Reset admin password\n"
printf " uninstall Uninstall AliasVault\n"
printf "\n"
printf " update Update AliasVault including install.sh script to the latest version\n"
printf " update-installer Update install.sh script if newer version is available\n"
printf "\n"
printf " db-export Export database to file\n"
printf " db-import Import database from file\n"
printf "\n"
printf " configure-dev-db Enable/disable development database (for local development only)\n"
printf " migrate-db Migrate data from SQLite to PostgreSQL when upgrading from a version prior to 0.10.0\n"
printf " migrate-db Migrate data from SQLite to PostgreSQL (only when upgrading from a version prior to 0.10.0)\n"
printf "\n"
printf "Options:\n"
printf " --verbose Show detailed output\n"
@@ -115,7 +116,7 @@ parse_args() {
shift
;;
reset-password|reset-admin-password|rp)
COMMAND="reset-password"
COMMAND="reset-admin-password"
shift
;;
configure-hostname|hostname)
@@ -235,7 +236,7 @@ main() {
"uninstall")
handle_uninstall
;;
"reset-password")
"reset-admin-password")
generate_admin_password
if [ $? -eq 0 ]; then
printf "${CYAN}> Restarting admin container...${NC}\n"
@@ -293,6 +294,16 @@ main() {
esac
}
# Function to get the latest release version from GitHub
get_latest_version() {
local latest_version=$(curl -s "https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest" | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)
if [ -z "$latest_version" ]; then
printf "${RED}> Failed to get latest version from GitHub.${NC}\n" >&2
return 1
fi
echo "$latest_version"
}
# Function to create required directories
create_directories() {
printf "${CYAN}> Checking workspace...${NC}\n"
@@ -410,13 +421,26 @@ print_logo() {
create_env_file() {
printf "${CYAN}> Checking .env file...${NC}\n"
if [ ! -f "$ENV_FILE" ]; then
if [ -f "$ENV_EXAMPLE_FILE" ]; then
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
printf " ${GREEN}> New.env file created from .env.example.${NC}\n"
else
touch "$ENV_FILE"
printf " ${YELLOW}> New blank .env file created.${NC}\n"
if [ ! -f "$ENV_EXAMPLE_FILE" ]; then
# Get latest release version
local latest_version=$(get_latest_version) || {
printf " ${YELLOW}> Failed to check latest version. Creating blank .env file.${NC}\n"
touch "$ENV_FILE"
return 0
}
printf " ${CYAN}> Downloading .env.example...${NC}"
if curl -sSf "${GITHUB_RAW_URL_REPO}/${latest_version}/.env.example" -o "$ENV_EXAMPLE_FILE" > /dev/null 2>&1; then
printf "\n ${GREEN}> .env.example downloaded successfully.${NC}\n"
else
printf "\n ${YELLOW}> Failed to download .env.example. Creating blank .env file.${NC}\n"
touch "$ENV_FILE"
return 0
fi
fi
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
printf " ${GREEN}> New .env file created from .env.example.${NC}\n"
else
printf " ${GREEN}> .env file already exists.${NC}\n"
fi
@@ -615,10 +639,19 @@ update_env_var() {
local value=$2
if [ -f "$ENV_FILE" ]; then
sed -i.bak "/^${key}=/d" "$ENV_FILE" && rm -f "$ENV_FILE.bak"
# Check if key exists
if grep -q "^${key}=" "$ENV_FILE"; then
# Update existing key inline
sed -i.bak "s|^${key}=.*|${key}=${value}|" "$ENV_FILE" && rm -f "$ENV_FILE.bak"
else
# Key doesn't exist, append it
echo "$key=$value" >> "$ENV_FILE"
fi
else
# File doesn't exist, create it with the key-value pair
echo "$key=$value" > "$ENV_FILE"
fi
echo "$key=$value" >> "$ENV_FILE"
printf " ${GREEN}> $key has been set in $ENV_FILE.${NC}\n"
}
@@ -651,7 +684,7 @@ print_success_message() {
else
printf "Admin Panel: https://localhost/admin\n"
printf "Username: admin\n"
printf "Password: (Previously set. Use ./install.sh reset-password to generate new one.)\n"
printf "Password: (Previously set. Use ./install.sh reset-admin-password to generate new one.)\n"
fi
printf "\n"
printf "${CYAN}===========================${NC}\n"
@@ -814,6 +847,8 @@ handle_install() {
# Function to handle build
handle_build() {
printf "${YELLOW}+++ Building AliasVault from source +++${NC}\n"
create_env_file || { printf "${RED}> Failed to create .env file${NC}\n"; exit 1; }
# Set deployment mode to build to ensure container lifecycle uses build configuration
set_deployment_mode "build"
printf "\n"
@@ -837,7 +872,6 @@ handle_build() {
fi
# Initialize environment with proper error handling
create_env_file || { printf "${RED}> Failed to create .env file${NC}\n"; exit 1; }
set_support_email || { printf "${RED}> Failed to set support email${NC}\n"; exit 1; }
populate_jwt_key || { printf "${RED}> Failed to set JWT key${NC}\n"; exit 1; }
populate_data_protection_cert_pass || { printf "${RED}> Failed to set certificate password${NC}\n"; exit 1; }
@@ -1330,7 +1364,10 @@ handle_update() {
fi
current_version=$(grep "^ALIASVAULT_VERSION=" "$ENV_FILE" | cut -d '=' -f2)
latest_version=$(curl -s "https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
latest_version=$(get_latest_version) || {
printf "${RED}> Failed to check for updates. Please try again later.${NC}\n"
exit 1
}
if [ -z "$latest_version" ]; then
printf "${RED}> Failed to check for updates. Please try again later.${NC}\n"
@@ -1412,7 +1449,10 @@ check_install_script_update() {
printf "${CYAN}> Checking for install script updates...${NC}\n"
# Get latest release version
local latest_version=$(curl -s "https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
local latest_version=$(get_latest_version) || {
printf "${RED}> Failed to check for install script updates. Continuing with current version.${NC}\n"
return 1
}
if [ -z "$latest_version" ]; then
printf "${RED}> Failed to check for install script updates. Continuing with current version.${NC}\n"
@@ -1505,13 +1545,18 @@ handle_install_version() {
# If latest, get actual version number from GitHub API
if [ "$target_version" = "latest" ]; then
local actual_version=$(curl -s "https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
local actual_version=$(get_latest_version) || {
printf "${RED}> Failed to get latest version. Please try again later.${NC}\n"
exit 1
}
if [ -n "$actual_version" ]; then
target_version="$actual_version"
fi
fi
printf "${YELLOW}+++ Installing AliasVault ${target_version} +++${NC}\n"
create_env_file || { printf "${RED}> Failed to create .env file${NC}\n"; exit 1; }
# Set deployment mode to install to ensure container lifecycle uses install configuration
set_deployment_mode "install"
printf "\n"
@@ -1544,7 +1589,7 @@ handle_install_version() {
printf "${GREEN}> Install script updated successfully.${NC}\n"
printf "${GREEN}> Backup of previous version saved as install.sh.backup${NC}\n"
printf "${YELLOW}> Please run the same install command again to continue with the installation.${NC}\n"
exit 0
exit 2
else
printf "${RED}> Failed to update install script. Continuing with current version.${NC}\n"
mv "install.sh.backup" "install.sh"
@@ -1557,7 +1602,6 @@ handle_install_version() {
handle_docker_compose "$target_version"
# Initialize environment
create_env_file || { printf "${RED}> Failed to create .env file${NC}\n"; exit 1; }
set_support_email || { printf "${RED}> Failed to set support email${NC}\n"; exit 1; }
populate_jwt_key || { printf "${RED}> Failed to set JWT key${NC}\n"; exit 1; }
populate_data_protection_cert_pass || { printf "${RED}> Failed to set certificate password${NC}\n"; exit 1; }
@@ -2032,25 +2076,27 @@ handle_hostname_configuration() {
# Get current hostname
CURRENT_HOSTNAME=$(grep "^HOSTNAME=" "$ENV_FILE" | cut -d '=' -f2)
printf "${CYAN}Removing current hostname ${CURRENT_HOSTNAME}${NC}...\n"
printf "Current hostname: ${CYAN}${CURRENT_HOSTNAME}${NC}\n"
printf "\n"
# Force hostname to be empty so populate_hostname will ask for a new one
sed -i.bak "/^HOSTNAME=/d" "$ENV_FILE" && rm -f "$ENV_FILE.bak"
# Ask for new hostname
while true; do
read -p "Enter new hostname (e.g. aliasvault.net): " NEW_HOSTNAME
if [ -n "$NEW_HOSTNAME" ]; then
break
else
printf "${YELLOW}> Hostname cannot be empty. Please enter a valid hostname.${NC}\n"
fi
done
# Reuse existing hostname population logic
populate_hostname
# Update the hostname
update_env_var "HOSTNAME" "$NEW_HOSTNAME"
if [ $? -eq 0 ]; then
printf "New hostname: ${CYAN}${HOSTNAME}${NC}\n"
printf "\n"
printf "${MAGENTA}=========================================================${NC}\n"
else
printf "${RED}> Failed to update hostname. Please try again.${NC}\n"
printf "\n"
printf "${MAGENTA}=========================================================${NC}\n"
exit 1
fi
printf "\n"
printf "${GREEN}Hostname updated successfully!${NC}\n"
printf "New hostname: ${CYAN}${NEW_HOSTNAME}${NC}\n"
printf "\n"
printf "${MAGENTA}=========================================================${NC}\n"
}
# Function to handle IP logging configuration

View File

@@ -21,8 +21,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.2">
<PackageReference Include="Blazor-ApexCharts" Version="5.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -13,7 +13,7 @@
<HeadOutlet @rendermode="RenderModeForPage"/>
</head>
<body class="bg-gray-50 dark:bg-gray-900">
<body class="bg-gray-50 dark:bg-gray-900" av-disable="true">
<Routes @rendermode="RenderModeForPage"/>
<script src="@VersionService.GetVersionedPath("lib/qrcode.min.js")"></script>
<script src="@VersionService.GetVersionedPath("js/dark-mode.js")"></script>

View File

@@ -0,0 +1,5 @@
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg class="w-5 h-5 text-gray-500 dark:text-gray-400" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"></path>
</svg>
</div>

View File

@@ -32,6 +32,11 @@ public class UserEmailClaimWithCount
/// </summary>
public string AddressDomain { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether the email claim is disabled.
/// </summary>
public bool Disabled { get; set; }
/// <summary>
/// Gets or sets the created at timestamp.
/// </summary>

View File

@@ -42,6 +42,11 @@ public class UserViewModel
/// </summary>
public int VaultCount { get; set; }
/// <summary>
/// Gets or sets the credential count.
/// </summary>
public int CredentialCount { get; set; }
/// <summary>
/// Gets or sets the email claim count.
/// </summary>

View File

@@ -1,16 +1,6 @@
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Total active users</h3>
<button
@onclick="ToggleUserNames"
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
@(ShowUserNames ? "Hide names" : "Show names")
</button>
</div>
<div class="flex items-center justify-between mb-4">
<p class="text-sm text-gray-500 dark:text-gray-400">
This card shows the number of active users in the last 24 hours, 7 days, and 14 days. This includes users who have created their accounts in these time periods.
</p>
</div>
@if (IsLoading)
{
@@ -21,63 +11,31 @@
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<div class="bg-green-50 dark:bg-green-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 24 hours</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last24Hours</h4>
@if (ShowUserNames)
{
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
<ul>
@foreach (var user in UserStats.Last24HourUsers)
{
<li>@user</li>
}
</ul>
</div>
}
<div class="flex items-baseline gap-2">
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last24Hours</h4>
<span class="text-sm text-gray-600 dark:text-gray-400" title="Returning users">(@UserStats.ReturningLast24Hours)</span>
</div>
</div>
<div class="bg-green-50 dark:bg-green-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 3 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last3Days</h4>
@if (ShowUserNames)
{
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
<ul>
@foreach (var user in UserStats.Last3DayUsers)
{
<li>@user</li>
}
</ul>
</div>
}
<div class="flex items-baseline gap-2">
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last3Days</h4>
<span class="text-sm text-gray-600 dark:text-gray-400" title="Returning users">(@UserStats.ReturningLast3Days)</span>
</div>
</div>
<div class="bg-green-50 dark:bg-green-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 7 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last7Days</h4>
@if (ShowUserNames)
{
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
<ul>
@foreach (var user in UserStats.Last7DayUsers)
{
<li>@user</li>
}
</ul>
</div>
}
<div class="flex items-baseline gap-2">
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last7Days</h4>
<span class="text-sm text-gray-600 dark:text-gray-400" title="Returning users">(@UserStats.ReturningLast7Days)</span>
</div>
</div>
<div class="bg-green-50 dark:bg-green-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 14 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last14Days</h4>
@if (ShowUserNames)
{
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
<ul>
@foreach (var user in UserStats.Last14DayUsers)
{
<li>@user</li>
}
</ul>
</div>
}
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 30 days</p>
<div class="flex items-baseline gap-2">
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last30Days</h4>
<span class="text-sm text-gray-600 dark:text-gray-400" title="Returning users (activity 24h after registration)">(@UserStats.ReturningLast30Days)</span>
</div>
</div>
</div>
}
@@ -86,7 +44,6 @@
@code {
private bool IsLoading { get; set; } = true;
private UserStatistics UserStats { get; set; } = new();
private bool ShowUserNames { get; set; }
/// <summary>
/// Refreshes the data displayed on the card.
@@ -100,56 +57,56 @@
var last24Hours = now.AddHours(-24);
var last3Days = now.AddDays(-3);
var last7Days = now.AddDays(-7);
var last14Days = now.AddDays(-14);
var last30Days = now.AddDays(-30);
// Get user statistics
var (count24h, users24h) = await GetActiveUserCount(last24Hours);
var (count3d, users3d) = await GetActiveUserCount(last3Days);
var (count7d, users7d) = await GetActiveUserCount(last7Days);
var (count14d, users14d) = await GetActiveUserCount(last14Days);
var (count24h, returning24h) = await GetActiveUserCount(last24Hours);
var (count3d, returning3d) = await GetActiveUserCount(last3Days);
var (count7d, returning7d) = await GetActiveUserCount(last7Days);
var (count30d, returning30d) = await GetActiveUserCount(last30Days);
UserStats = new UserStatistics
{
Last24Hours = count24h,
Last3Days = count3d,
Last7Days = count7d,
Last14Days = count14d,
Last24HourUsers = users24h,
Last3DayUsers = users3d,
Last7DayUsers = users7d,
Last14DayUsers = users14d
Last30Days = count30d,
ReturningLast24Hours = returning24h,
ReturningLast3Days = returning3d,
ReturningLast7Days = returning7d,
ReturningLast30Days = returning30d,
};
IsLoading = false;
StateHasChanged();
}
private async Task<(int count, List<string> users)> GetActiveUserCount(DateTime since)
private async Task<(int totalCount, int returningCount)> GetActiveUserCount(DateTime since)
{
// Get unique users who either:
// 1. Have successful auth logs
// 2. Have updated their vault
// 3. Are not the admin user
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
// Get all active users for the period
var activeUsers = await dbContext.AuthLogs
.Where(l => l.Timestamp >= since && l.IsSuccess && l.Username != "admin")
.Select(l => l.Username)
.Union(
dbContext.Vaults
.Where(v => v.UpdatedAt >= since)
.Select(v => v.User.UserName!)
)
.Distinct()
.ToListAsync();
return (activeUsers.Count, activeUsers);
}
// Get returning users (those who have activity at least 24h after registration
var returningUsers = await dbContext.AuthLogs
.Where(l => l.Timestamp >= since && l.IsSuccess && l.Username != "admin")
.Join(
dbContext.AliasVaultUsers,
log => log.Username,
user => user.UserName,
(log, user) => new { log, user }
)
.Where(x => x.log.Timestamp >= x.user.CreatedAt.AddHours(24))
.Select(x => x.log.Username)
.Distinct()
.ToListAsync();
private void ToggleUserNames()
{
ShowUserNames = !ShowUserNames;
StateHasChanged();
return (activeUsers.Count, returningUsers.Count);
}
private sealed class UserStatistics
@@ -157,10 +114,10 @@
public int Last24Hours { get; set; }
public int Last3Days { get; set; }
public int Last7Days { get; set; }
public int Last14Days { get; set; }
public List<string> Last24HourUsers { get; set; } = new();
public List<string> Last3DayUsers { get; set; } = new();
public List<string> Last7DayUsers { get; set; } = new();
public List<string> Last14DayUsers { get; set; } = new();
public int Last30Days { get; set; }
public int ReturningLast24Hours { get; set; }
public int ReturningLast3Days { get; set; }
public int ReturningLast7Days { get; set; }
public int ReturningLast30Days { get; set; }
}
}

View File

@@ -0,0 +1,140 @@
@rendermode InteractiveServer
@using AliasVault.Shared.Server.Models
@using AliasVault.Shared.Server.Services
@inject ServerSettingsService SettingsService
<div class="col-span-2 p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800 max-h-[500px]">
@if (IsLoading)
{
<LoadingIndicator />
}
else
{
<ApexChart TItem="DailyUserCount"
Title="@($"User activity - last {DaysToShow} days")"
Height="400">
<ApexPointSeries TItem="DailyUserCount"
Items="TotalDailyUserCounts"
SeriesType="@SeriesType.Area"
Name="Total Active Users"
XValue="@(e => e.Date.ToString("MM-dd"))"
YValue="@(e => e.Count)" />
<ApexPointSeries TItem="DailyUserCount"
Items="DailyUserCounts"
SeriesType="@SeriesType.Area"
Name="Returning Users"
XValue="@(e => e.Date.ToString("MM-dd"))"
YValue="@(e => e.Count)" />
<ApexPointSeries TItem="DailyUserCount"
Items="NewUserRegistrations"
SeriesType="@SeriesType.Area"
Name="New Registrations"
XValue="@(e => e.Date.ToString("MM-dd"))"
YValue="@(e => e.Count)" />
</ApexChart>
}
</div>
@code {
private bool IsLoading { get; set; } = true;
private List<DailyUserCount> DailyUserCounts = new();
private List<DailyUserCount> TotalDailyUserCounts = new();
private List<DailyUserCount> NewUserRegistrations = new();
private int DaysToShow { get; set; } = 30;
private ServerSettingsModel Settings { get; set; } = new();
/// <inheritdoc/>
protected override async Task OnInitializedAsync()
{
Settings = await SettingsService.GetAllSettingsAsync();
// Set the number of days to show to the auth log retention days up to a maximum of 60 days (for performance reasons).
int maxDays = Math.Min(Settings.AuthLogRetentionDays, 60);
// If the auth log retention days is 0 (unlimited), set the number of days to show to 60.
DaysToShow = maxDays == 0 ? 60 : maxDays;
}
/// <summary>
/// Refreshes the data displayed on the card.
/// </summary>
public async Task RefreshData()
{
IsLoading = true;
StateHasChanged();
// Get daily active user counts for the past 14 days
await GetDailyActiveUserCounts();
IsLoading = false;
StateHasChanged();
}
/// <summary>
/// Get daily active user counts for up to the last 90 days to display on the chart.
/// </summary>
private async Task GetDailyActiveUserCounts()
{
DailyUserCounts = new List<DailyUserCount>();
TotalDailyUserCounts = new List<DailyUserCount>();
NewUserRegistrations = new List<DailyUserCount>();
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
// Define the date range (defaults to amount of days in auth log retention, with a maximum of 90 days)
var endDate = DateTime.UtcNow.Date;
var startDate = endDate.AddDays(-DaysToShow);
// Get total active users (all users who logged in based on auth logs)
var totalUsersByDay = await dbContext.AuthLogs
.Where(l => l.Timestamp >= startDate && l.Timestamp < endDate && l.IsSuccess && l.Username != "admin")
.GroupBy(x => x.Timestamp.Date)
.Select(g => new { Day = g.Key, Count = g.Select(x => x.Username).Distinct().Count() })
.ToListAsync();
// Get new user registrations by day
var newUsersByDay = await dbContext.AliasVaultUsers
.Where(u => u.CreatedAt >= startDate && u.CreatedAt < endDate && u.UserName != "admin")
.GroupBy(u => u.CreatedAt.Date)
.Select(g => new { Day = g.Key, Count = g.Count() })
.ToListAsync();
// Fill in the results for all days
for (int i = 0; i < DaysToShow; i++)
{
// Subtract 1 day to avoid showing the current day as those numbers are not complete yet.
var day = endDate.AddDays(-i - 1);
var totalActiveCount = totalUsersByDay.FirstOrDefault(d => d.Day == day)?.Count ?? 0;
var registeredUsersCount = newUsersByDay.FirstOrDefault(d => d.Day == day)?.Count ?? 0;
// Calculate the number of returning users by subtracting the number of users registered that day from the total active users.
var returningUsersCount = totalActiveCount - registeredUsersCount;
DailyUserCounts.Add(new DailyUserCount
{
Date = day,
Count = returningUsersCount
});
TotalDailyUserCounts.Add(new DailyUserCount
{
Date = day,
Count = totalActiveCount
});
NewUserRegistrations.Add(new DailyUserCount
{
Date = day,
Count = registeredUsersCount
});
}
}
private sealed class DailyUserCount
{
public DateTime Date { get; set; }
public int Count { get; set; }
}
}

View File

@@ -1,6 +1,11 @@
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Email aliases created</h3>
<button
@onclick="ToggleChart"
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
@(ShowChart ? "Hide chart" : "Show chart")
</button>
</div>
@if (IsLoading)
{
@@ -11,27 +16,60 @@
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-green-50 dark:bg-green-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 24 hours</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailClaimsStats.Hours24</h4>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailClaimsStats.Hours24.ToString("N0")</h4>
</div>
<div class="bg-green-50 dark:bg-green-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 3 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailClaimsStats.Days3</h4>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailClaimsStats.Days3.ToString("N0")</h4>
</div>
<div class="bg-green-50 dark:bg-green-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 7 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailClaimsStats.Days7</h4>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailClaimsStats.Days7.ToString("N0")</h4>
</div>
<div class="bg-green-50 dark:bg-green-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 14 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailClaimsStats.Days14</h4>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 30 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailClaimsStats.Days30.ToString("N0")</h4>
</div>
</div>
}
@if (ShowChart && !IsLoading)
{
<div class="mt-6">
<ApexChart TItem="DailyEmailClaimCount"
Title="@($"Aliases created - last {DaysToShow} days")"
Height="250">
<ApexPointSeries TItem="DailyEmailClaimCount"
Items="DailyEmailClaimCounts"
SeriesType="@SeriesType.Bar"
Name="Aliases created"
XValue="@(e => e.Date.ToString("MM-dd"))"
YValue="@(e => e.Count)" />
</ApexChart>
</div>
}
</div>
@code {
private bool IsLoading { get; set; } = true;
private EmailClaimsStatistics EmailClaimsStats { get; set; } = new();
private List<DailyEmailClaimCount> DailyEmailClaimCounts { get; set; } = new();
/// <summary>
/// The number of days to show in the chart.
/// </summary>
private int DaysToShow { get; set; } = 30;
/// <summary>
/// Whether the chart is visible.
/// </summary>
private bool ShowChart { get; set; } = false;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await RefreshData();
}
/// <summary>
/// Refreshes the data displayed on the card.
@@ -41,11 +79,23 @@
IsLoading = true;
StateHasChanged();
await RefreshCardData();
await RefreshChartData();
IsLoading = false;
StateHasChanged();
}
/// <summary>
/// Refreshes the card data.
/// </summary>
private async Task RefreshCardData()
{
var now = DateTime.UtcNow;
var hours24 = now.AddHours(-24);
var days3 = now.AddDays(-3);
var days7 = now.AddDays(-7);
var days14 = now.AddDays(-14);
var days30 = now.AddDays(-30);
// Get email claims statistics
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
@@ -55,11 +105,61 @@
Hours24 = await emailClaimsQuery.CountAsync(e => e.CreatedAt >= hours24),
Days3 = await emailClaimsQuery.CountAsync(e => e.CreatedAt >= days3),
Days7 = await emailClaimsQuery.CountAsync(e => e.CreatedAt >= days7),
Days14 = await emailClaimsQuery.CountAsync(e => e.CreatedAt >= days14)
Days30 = await emailClaimsQuery.CountAsync(e => e.CreatedAt >= days30)
};
}
IsLoading = false;
StateHasChanged();
/// <summary>
/// Refreshes the chart data.
/// </summary>
private async Task RefreshChartData()
{
// Only fetch chart data if the chart is visible
if (ShowChart)
{
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
var dateFrom = DateTime.UtcNow.AddDays(-DaysToShow);
// Get daily email claim counts for the chart
DailyEmailClaimCounts = await dbContext.UserEmailClaims
.Where(e => e.CreatedAt >= dateFrom)
.GroupBy(e => e.CreatedAt.Date)
.Select(g => new DailyEmailClaimCount
{
Date = g.Key,
Count = g.Count()
}).ToListAsync();
// Fill in any missing days with zero counts
var allDates = Enumerable.Range(0, DaysToShow)
.Select(offset => DateTime.UtcNow.Date.AddDays(-offset))
.Reverse();
DailyEmailClaimCounts = allDates
.GroupJoin(
DailyEmailClaimCounts,
date => date,
claimCount => claimCount.Date,
(date, claimCounts) => claimCounts.FirstOrDefault() ?? new DailyEmailClaimCount { Date = date, Count = 0 }
)
.OrderByDescending(e => e.Date)
.ToList();
}
}
private void ToggleChart()
{
ShowChart = !ShowChart;
// If we're showing the chart but haven't loaded the data yet
if (ShowChart && DailyEmailClaimCounts.Count == 0)
{
_ = RefreshData();
}
else
{
StateHasChanged();
}
}
private sealed class EmailClaimsStatistics
@@ -67,6 +167,12 @@
public int Hours24 { get; set; }
public int Days3 { get; set; }
public int Days7 { get; set; }
public int Days14 { get; set; }
public int Days30 { get; set; }
}
private sealed class DailyEmailClaimCount
{
public DateTime Date { get; set; }
public int Count { get; set; }
}
}

View File

@@ -1,6 +1,11 @@
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Emails received</h3>
<button
@onclick="ToggleChart"
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
@(ShowChart ? "Hide chart" : "Show chart")
</button>
</div>
@if (IsLoading)
{
@@ -11,27 +16,60 @@
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-primary-50 dark:bg-gray-700/50 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 24 hours</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailStats.Hours24</h4>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailStats.Hours24.ToString("N0")</h4>
</div>
<div class="bg-primary-50 dark:bg-gray-700/50 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 3 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailStats.Days3</h4>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailStats.Days3.ToString("N0")</h4>
</div>
<div class="bg-primary-50 dark:bg-gray-700/50 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 7 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailStats.Days7</h4>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailStats.Days7.ToString("N0")</h4>
</div>
<div class="bg-primary-50 dark:bg-gray-700/50 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 14 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailStats.Days14</h4>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 30 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailStats.Days30.ToString("N0")</h4>
</div>
</div>
}
@if (ShowChart && !IsLoading)
{
<div class="mt-6">
<ApexChart TItem="DailyEmailCount"
Title="@($"Emails received - last {DaysToShow} days")"
Height="250">
<ApexPointSeries TItem="DailyEmailCount"
Items="DailyEmailCounts"
SeriesType="@SeriesType.Bar"
Name="Emails received"
XValue="@(e => e.Date.ToString("MM-dd"))"
YValue="@(e => e.Count)" />
</ApexChart>
</div>
}
</div>
@code {
private bool IsLoading { get; set; } = true;
private EmailStatistics EmailStats { get; set; } = new();
private List<DailyEmailCount> DailyEmailCounts { get; set; } = new();
/// <summary>
/// The number of days to show in the chart.
/// </summary>
private int DaysToShow { get; set; } = 30;
/// <summary>
/// Whether the chart is visible.
/// </summary>
private bool ShowChart { get; set; } = false;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await RefreshData();
}
/// <summary>
/// Refreshes the data displayed on the card.
@@ -41,11 +79,23 @@
IsLoading = true;
StateHasChanged();
await RefreshCardData();
await RefreshChartData();
IsLoading = false;
StateHasChanged();
}
/// <summary>
/// Refreshes the card data.
/// </summary>
private async Task RefreshCardData()
{
var now = DateTime.UtcNow;
var hours24 = now.AddHours(-24);
var days3 = now.AddDays(-3);
var days7 = now.AddDays(-7);
var days14 = now.AddDays(-14);
var days30 = now.AddDays(-30);
// Get email statistics
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
@@ -55,11 +105,61 @@
Hours24 = await emailQuery.CountAsync(e => e.DateSystem >= hours24),
Days3 = await emailQuery.CountAsync(e => e.DateSystem >= days3),
Days7 = await emailQuery.CountAsync(e => e.DateSystem >= days7),
Days14 = await emailQuery.CountAsync(e => e.DateSystem >= days14)
Days30 = await emailQuery.CountAsync(e => e.DateSystem >= days30)
};
}
IsLoading = false;
StateHasChanged();
/// <summary>
/// Refreshes the chart data.
/// </summary>
private async Task RefreshChartData()
{
// Only fetch chart data if the chart is visible.
if (ShowChart)
{
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
var dateFrom = DateTime.UtcNow.AddDays(-DaysToShow);
// Get daily email counts for the chart.
DailyEmailCounts = await dbContext.Emails
.Where(e => e.DateSystem >= dateFrom)
.GroupBy(e => e.DateSystem.Date)
.Select(g => new DailyEmailCount
{
Date = g.Key,
Count = g.Count()
}).ToListAsync();
// Fill in any missing days with zero counts
var allDates = Enumerable.Range(0, DaysToShow)
.Select(offset => DateTime.UtcNow.Date.AddDays(-offset))
.Reverse();
DailyEmailCounts = allDates
.GroupJoin(
DailyEmailCounts,
date => date,
emailCount => emailCount.Date,
(date, emailCounts) => emailCounts.FirstOrDefault() ?? new DailyEmailCount { Date = date, Count = 0 }
)
.OrderByDescending(e => e.Date)
.ToList();
}
}
private void ToggleChart()
{
ShowChart = !ShowChart;
// If we're showing the chart but haven't loaded the data yet
if (ShowChart && DailyEmailCounts.Count == 0)
{
_ = RefreshData();
}
else
{
StateHasChanged();
}
}
private sealed class EmailStatistics
@@ -67,6 +167,12 @@
public int Hours24 { get; set; }
public int Days3 { get; set; }
public int Days7 { get; set; }
public int Days14 { get; set; }
public int Days30 { get; set; }
}
private sealed class DailyEmailCount
{
public DateTime Date { get; set; }
public int Count { get; set; }
}
}

View File

@@ -11,19 +11,19 @@
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-blue-50 dark:bg-blue-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 24 hours</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@RegistrationStats.Hours24</h4>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@RegistrationStats.Hours24.ToString("N0")</h4>
</div>
<div class="bg-blue-50 dark:bg-blue-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 3 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@RegistrationStats.Days3</h4>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@RegistrationStats.Days3.ToString("N0")</h4>
</div>
<div class="bg-blue-50 dark:bg-blue-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 7 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@RegistrationStats.Days7</h4>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@RegistrationStats.Days7.ToString("N0")</h4>
</div>
<div class="bg-blue-50 dark:bg-blue-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 14 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@RegistrationStats.Days14</h4>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 30 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@RegistrationStats.Days30.ToString("N0")</h4>
</div>
</div>
}
@@ -45,7 +45,7 @@
var hours24 = now.AddHours(-24);
var days3 = now.AddDays(-3);
var days7 = now.AddDays(-7);
var days14 = now.AddDays(-14);
var days30 = now.AddDays(-30);
// Get registration statistics
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
@@ -55,7 +55,7 @@
Hours24 = await registrationQuery.CountAsync(u => u.CreatedAt >= hours24),
Days3 = await registrationQuery.CountAsync(u => u.CreatedAt >= days3),
Days7 = await registrationQuery.CountAsync(u => u.CreatedAt >= days7),
Days14 = await registrationQuery.CountAsync(u => u.CreatedAt >= days14)
Days30 = await registrationQuery.CountAsync(u => u.CreatedAt >= days30)
};
IsLoading = false;
@@ -67,6 +67,6 @@
public int Hours24 { get; set; }
public int Days3 { get; set; }
public int Days7 { get; set; }
public int Days14 { get; set; }
public int Days30 { get; set; }
}
}

View File

@@ -1,165 +0,0 @@
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Returning users</h3>
<button
@onclick="ToggleUserNames"
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
@(ShowUserNames ? "Hide names" : "Show names")
</button>
</div>
<div class="flex items-center justify-between mb-4">
<p class="text-sm text-gray-500 dark:text-gray-400">
This card shows the number of returning users in the last 24 hours, 3 days, 7 days, and 14 days. This excludes users who have created their accounts in these time periods.
</p>
</div>
@if (IsLoading)
{
<LoadingIndicator />
}
else
{
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-yellow-50 dark:bg-yellow-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 24 hours</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last24Hours</h4>
@if (ShowUserNames)
{
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
<ul>
@foreach (var user in UserStats.Last24HourUsers)
{
<li>@user</li>
}
</ul>
</div>
}
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 3 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last3Days</h4>
@if (ShowUserNames)
{
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
<ul>
@foreach (var user in UserStats.Last3DayUsers)
{
<li>@user</li>
}
</ul>
</div>
}
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 7 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last7Days</h4>
@if (ShowUserNames)
{
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
<ul>
@foreach (var user in UserStats.Last7DayUsers)
{
<li>@user</li>
}
</ul>
</div>
}
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 14 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last14Days</h4>
@if (ShowUserNames)
{
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
<ul>
@foreach (var user in UserStats.Last14DayUsers)
{
<li>@user</li>
}
</ul>
</div>
}
</div>
</div>
}
</div>
@code {
private bool IsLoading { get; set; } = true;
private bool ShowUserNames { get; set; } = false;
private UserStatistics UserStats { get; set; } = new();
/// <summary>
/// Toggles the visibility of user names.
/// </summary>
private void ToggleUserNames()
{
ShowUserNames = !ShowUserNames;
}
/// <summary>
/// Refreshes the data displayed on the card.
/// </summary>
public async Task RefreshData()
{
IsLoading = true;
StateHasChanged();
var now = DateTime.UtcNow;
var last24Hours = now.AddHours(-24);
var last3Days = now.AddDays(-3);
var last7Days = now.AddDays(-7);
var last14Days = now.AddDays(-14);
var (count24h, users24h) = await GetReturningUserCount(last24Hours);
var (count3d, users3d) = await GetReturningUserCount(last3Days);
var (count7d, users7d) = await GetReturningUserCount(last7Days);
var (count14d, users14d) = await GetReturningUserCount(last14Days);
UserStats = new UserStatistics
{
Last24Hours = count24h,
Last3Days = count3d,
Last7Days = count7d,
Last14Days = count14d,
Last24HourUsers = users24h,
Last3DayUsers = users3d,
Last7DayUsers = users7d,
Last14DayUsers = users14d
};
IsLoading = false;
StateHasChanged();
}
private async Task<(int count, List<string?> users)> GetReturningUserCount(DateTime since)
{
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
// Get users who have auth logs in the time period
var activeUserNames = await dbContext.AuthLogs
.Where(l => l.Timestamp >= since)
.Select(l => l.Username)
.Distinct()
.ToListAsync();
// Get returning users (created before the time period)
var returningUsers = await dbContext.AliasVaultUsers
.Where(u => activeUserNames.Contains(u.UserName!) && u.CreatedAt < since)
.Select(u => u.UserName)
.ToListAsync();
return (returningUsers.Count, returningUsers);
}
private sealed class UserStatistics
{
public int Last24Hours { get; set; }
public int Last3Days { get; set; }
public int Last7Days { get; set; }
public int Last14Days { get; set; }
public List<string?> Last24HourUsers { get; set; } = new();
public List<string?> Last3DayUsers { get; set; } = new();
public List<string?> Last7DayUsers { get; set; } = new();
public List<string?> Last14DayUsers { get; set; } = new();
}
}

View File

@@ -7,7 +7,7 @@
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="AliasVault Admin"
Description="Welcome to the AliasVault admin portal. Below you can find statistics about recent email activity and active users.">
Description="Welcome to the AliasVault admin dashboard. Below you can find statistics about recent activity on this server.">
<CustomActions>
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
</CustomActions>
@@ -15,17 +15,17 @@
<div class="px-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<ActiveUsersChart @ref="_activeUsersChart" />
<ActiveUsersCard @ref="_activeUsersCard" />
<RegistrationStatisticsCard @ref="_registrationStatisticsCard" />
<EmailStatisticsCard @ref="_emailStatisticsCard" />
<ReturningUsersCard @ref="_returningUsersCard" />
<ActiveUsersCard @ref="_activeUsersCard" />
<EmailClaimsCard @ref="_emailClaimsCard" />
</div>
</div>
@code {
private ActiveUsersChart? _activeUsersChart;
private ActiveUsersCard? _activeUsersCard;
private ReturningUsersCard? _returningUsersCard;
private RegistrationStatisticsCard? _registrationStatisticsCard;
private EmailStatisticsCard? _emailStatisticsCard;
private EmailClaimsCard? _emailClaimsCard;
@@ -45,6 +45,8 @@
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
await RefreshData();
@@ -56,15 +58,15 @@
/// </summary>
private async Task RefreshData()
{
if (_activeUsersCard != null &&
_returningUsersCard != null &&
if (_activeUsersChart != null &&
_activeUsersCard != null &&
_registrationStatisticsCard != null &&
_emailStatisticsCard != null &&
_emailClaimsCard != null)
{
await Task.WhenAll(
_activeUsersChart.RefreshData(),
_activeUsersCard.RefreshData(),
_returningUsersCard.RefreshData(),
_registrationStatisticsCard.RefreshData(),
_emailStatisticsCard.RefreshData(),
_emailClaimsCard.RefreshData()

View File

@@ -7,12 +7,25 @@
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@(TotalRecords > 0 ? $"Emails ({TotalRecords:N0})" : "Emails")"
Description="This page gives an overview of recently received mails by this AliasVault server. Note that all email fields except 'To' are encrypted with the public key of the user and cannot be decrypted by the server.">
Description="This page shows an overview of recently received mails by this AliasVault server. Note: all email fields except 'To' are encrypted with the public key of the user and are unreadable by the server.">
<CustomActions>
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
</CustomActions>
</PageHeader>
@if (IsInitialized)
{
<div class="px-4">
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
<div class="mb-3">
<div class="relative">
<SearchIcon />
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search emails..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
</div>
</div>
</div>
}
@if (IsLoading)
{
<LoadingIndicator />
@@ -20,23 +33,25 @@
else
{
<div class="overflow-x-auto px-4">
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
@foreach (var email in EmailList)
@foreach (var viewModel in EmailViewModelList)
{
<SortableTableRow>
<SortableTableColumn IsPrimary="true">@email.Id</SortableTableColumn>
<SortableTableColumn>@email.DateSystem.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
<SortableTableColumn>@(email.FromLocal.Length > 15 ? email.FromLocal.Substring(0, 15) : email.FromLocal)@@@(email.FromDomain.Length > 15 ? email.FromDomain.Substring(0, 15) : email.FromDomain)</SortableTableColumn>
<SortableTableColumn>@email.ToLocal@@@email.ToDomain</SortableTableColumn>
<SortableTableColumn>@(email.Subject.Length > 30 ? email.Subject.Substring(0, 30) : email.Subject)</SortableTableColumn>
<SortableTableColumn IsPrimary="true">@viewModel.Email.Id</SortableTableColumn>
<SortableTableColumn>@viewModel.Email.DateSystem.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
<SortableTableColumn>@(viewModel.Email.FromLocal.Length > 10 ? viewModel.Email.FromLocal.Substring(0, 10) : viewModel.Email.FromLocal)@@@(viewModel.Email.FromDomain.Length > 10 ? viewModel.Email.FromDomain.Substring(0, 10) : viewModel.Email.FromDomain)</SortableTableColumn>
<SortableTableColumn>@viewModel.Email.ToLocal@@@viewModel.Email.ToDomain</SortableTableColumn>
<SortableTableColumn>
<span class="line-clamp-1">
@(email.MessagePreview?.Length > 30 ? email.MessagePreview.Substring(0, 30) : email.MessagePreview)
</span>
@if (viewModel.UserName.Length > 0)
{
<span class="line-clamp-1"><a href="users/@viewModel.UserId">@viewModel.UserName</a></span>
}
else
{
<span class="line-clamp-1">n/a</span>
}
</SortableTableColumn>
<SortableTableColumn>@email.Attachments.Count</SortableTableColumn>
<SortableTableColumn>@viewModel.Email.Attachments.Count</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
@@ -44,25 +59,50 @@ else
}
@code {
/// <summary>
/// The search term from the query parameter.
/// </summary>
[Parameter]
[SupplyParameterFromQuery(Name = "search")]
public string? SearchTermFromQuery { get; set; }
private readonly List<TableColumn> _tableColumns = [
new TableColumn { Title = "ID", PropertyName = "Id" },
new TableColumn { Title = "Time", PropertyName = "DateSystem" },
new TableColumn { Title = "From", PropertyName = "From" },
new TableColumn { Title = "To", PropertyName = "To" },
new TableColumn { Title = "Subject", PropertyName = "Subject" },
new TableColumn { Title = "Preview", PropertyName = "MessagePreview" },
new TableColumn { Title = "User", Sortable = false },
new TableColumn { Title = "Attachments", PropertyName = "Attachments" },
];
private List<Email> EmailList { get; set; } = [];
private List<EmailViewModel> EmailViewModelList { get; set; } = [];
private bool IsInitialized { get; set; } = false;
private bool IsLoading { get; set; } = true;
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 50;
private int TotalRecords { get; set; }
private string _searchTerm = string.Empty;
/// <summary>
/// The last search term.
/// </summary>
private string _lastSearchTerm = string.Empty;
private string SearchTerm
{
get => _searchTerm;
set
{
if (_searchTerm != value)
{
_searchTerm = value;
_ = RefreshData();
}
}
}
private string SortColumn { get; set; } = "Id";
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
private async Task HandleSortChanged((string column, SortDirection direction) sort)
{
SortColumn = sort.column;
@@ -75,6 +115,12 @@ else
{
if (firstRender)
{
// Set the search term from the query parameter if it exists
if (!string.IsNullOrEmpty(SearchTermFromQuery))
{
_searchTerm = SearchTermFromQuery;
}
await RefreshData();
}
}
@@ -91,8 +137,68 @@ else
StateHasChanged();
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
IQueryable<Email> query = dbContext.Emails;
query = ApplySearchFilter(query);
query = ApplySort(query);
TotalRecords = await query.CountAsync();
var emailList = await query
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToListAsync();
// Get all usernames for the emails in the current list
var encryptionKeyIds = emailList.Select(x => x.UserEncryptionKeyId).Distinct().ToList();
var encryptionKeyUsernames = await dbContext.UserEncryptionKeys
.Where(x => encryptionKeyIds.Contains(x.Id))
.Join(dbContext.AliasVaultUsers, x => x.UserId, y => y.Id, (x, y) => new { EncryptionKeyId = x.Id, UserId = y.Id, y.UserName })
.ToListAsync();
// Create new list of viewmodels
EmailViewModelList = new List<EmailViewModel>();
foreach (var email in emailList)
{
var encryptionKey = encryptionKeyUsernames.FirstOrDefault(x => x.EncryptionKeyId == email.UserEncryptionKeyId);
EmailViewModelList.Add(new EmailViewModel { Email = email, UserId = encryptionKey?.UserId ?? string.Empty, UserName = encryptionKey?.UserName ?? string.Empty });
}
IsLoading = false;
IsInitialized = true;
StateHasChanged();
}
/// <summary>
/// Applies a search filter to the query based on the search term.
/// </summary>
/// <param name="query">The query to filter.</param>
/// <returns>The filtered query.</returns>
private IQueryable<Email> ApplySearchFilter(IQueryable<Email> query)
{
if (SearchTerm.Length > 0)
{
// Reset page number back to 1 if the search term has changed.
if (SearchTerm != _lastSearchTerm && CurrentPage != 1)
{
CurrentPage = 1;
}
_lastSearchTerm = SearchTerm;
query = query.Where(x => EF.Functions.Like(x.To.ToLower(), "%" + SearchTerm.Trim().ToLower() + "%"));
}
return query;
}
/// <summary>
/// Applies sorting to the query based on the sort column and direction.
/// </summary>
/// <param name="query">The query to sort.</param>
/// <returns>The sorted query.</returns>
private IQueryable<Email> ApplySort(IQueryable<Email> query)
{
// Apply sort
switch (SortColumn)
{
@@ -116,16 +222,6 @@ else
? query.OrderBy(x => x.ToLocal + "@" + x.ToDomain)
: query.OrderByDescending(x => x.ToLocal + "@" + x.ToDomain);
break;
case "Subject":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Subject)
: query.OrderByDescending(x => x.Subject);
break;
case "MessagePreview":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.MessagePreview)
: query.OrderByDescending(x => x.MessagePreview);
break;
case "Attachments":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Attachments.Count)
@@ -136,13 +232,13 @@ else
break;
}
TotalRecords = await query.CountAsync();
EmailList = await query
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToListAsync();
return query;
}
IsLoading = false;
StateHasChanged();
private sealed class EmailViewModel
{
public Email Email { get; set; } = new();
public string UserId { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
}
}

View File

@@ -8,26 +8,25 @@
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@(TotalRecords > 0 ? $"Auth logs ({TotalRecords:N0})" : "Auth logs")"
Description="This page gives an overview of recent auth attempts.">
Description="This page shows an overview of recent auth attempts.">
<CustomActions>
<DeleteButton OnClick="DeleteLogsWithConfirmation" ButtonText="Delete all logs" />
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
</CustomActions>
</PageHeader>
@if (IsLoading)
{
<LoadingIndicator />
}
else
@if (IsInitialized)
{
<div class="px-4">
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
<div class="mb-4 flex space-x-4">
<div class="mb-3 flex space-x-4">
<div class="flex w-full">
<div class="w-2/3 pr-2">
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
<div class="relative">
<SearchIcon />
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
</div>
</div>
<div class="w-1/3 pl-2">
<select @bind="SelectedEventType" class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
@@ -40,7 +39,16 @@ else
</div>
</div>
</div>
</div>
}
@if (IsLoading)
{
<LoadingIndicator />
}
else
{
<div class="px-4">
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
@foreach (var log in LogList)
{
@@ -70,12 +78,14 @@ else
];
private List<AuthLog> LogList { get; set; } = [];
private bool IsInitialized { get; set; } = false;
private bool IsLoading { get; set; } = true;
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 50;
private int TotalRecords { get; set; }
private string _searchTerm = string.Empty;
private string _lastSearchTerm = string.Empty;
private string SearchTerm
{
get => _searchTerm;
@@ -130,12 +140,33 @@ else
private async Task RefreshData()
{
IsLoading = true;
StateHasChanged();
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
var query = dbContext.AuthLogs.AsQueryable();
if (!string.IsNullOrEmpty(SearchTerm))
{
query = query.Where(x => EF.Functions.Like(x.Username.ToLower(), "%" + SearchTerm.ToLower() + "%"));
// Reset page number back to 1 if the search term has changed.
if (SearchTerm != _lastSearchTerm)
{
CurrentPage = 1;
}
_lastSearchTerm = SearchTerm;
// If the search term starts with "client:", we search for the client header.
if (SearchTerm.StartsWith("client:", StringComparison.OrdinalIgnoreCase))
{
var clientSearchTerm = SearchTerm.Substring(7).ToLower();
query = query.Where(x => EF.Functions.Like((x.Client ?? string.Empty).ToLower(), "%" + clientSearchTerm + "%"));
}
else
{
var searchTerm = SearchTerm.Trim().ToLower();
query = query.Where(x => EF.Functions.Like((x.Username ?? string.Empty).ToLower(), "%" + searchTerm + "%") ||
EF.Functions.Like((x.IpAddress ?? string.Empty).ToLower(), "%" + searchTerm + "%"));
}
}
if (!string.IsNullOrEmpty(SelectedEventType))
@@ -156,6 +187,7 @@ else
.ToListAsync();
IsLoading = false;
IsInitialized = true;
StateHasChanged();
}

View File

@@ -7,26 +7,25 @@
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@(TotalRecords > 0 ? $"General logs ({TotalRecords:N0})" : "General logs")"
Description="This page gives an overview of recent system logs.">
Description="This page shows an overview of recent system logs.">
<CustomActions>
<DeleteButton OnClick="DeleteLogsWithConfirmation" ButtonText="Delete all logs" />
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
</CustomActions>
</PageHeader>
@if (IsLoading)
{
<LoadingIndicator />
}
else
@if (IsInitialized)
{
<div class="px-4">
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
<div class="mb-4 flex space-x-4">
<div class="mb-3 flex space-x-4">
<div class="flex w-full">
<div class="w-2/3 pr-2">
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
<div class="relative">
<SearchIcon />
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
</div>
</div>
<div class="w-1/3 pl-2">
<select @bind="SelectedServiceName" class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
@@ -39,7 +38,16 @@ else
</div>
</div>
</div>
</div>
}
@if (IsLoading)
{
<LoadingIndicator />
}
else
{
<div class="px-4">
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
@foreach (var log in LogList)
{
@@ -85,12 +93,15 @@ else
];
private List<Log> LogList { get; set; } = [];
private bool IsInitialized { get; set; } = false;
private bool IsLoading { get; set; } = true;
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 50;
private int TotalRecords { get; set; }
private string _searchTerm = string.Empty;
private string _lastSearchTerm = string.Empty;
private string SearchTerm
{
get => _searchTerm;
@@ -149,20 +160,14 @@ else
private async Task RefreshData()
{
IsLoading = true;
StateHasChanged();
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
var query = dbContext.Logs.AsQueryable();
if (!string.IsNullOrEmpty(SearchTerm))
{
query = query.Where(x => EF.Functions.Like(x.Application.ToLower(), "%" + SearchTerm.ToLower() + "%") ||
EF.Functions.Like(x.Message.ToLower(), "%" + SearchTerm.ToLower() + "%") ||
EF.Functions.Like(x.Level.ToLower(), "%" + SearchTerm.ToLower() + "%"));
}
if (!string.IsNullOrEmpty(SelectedServiceName))
{
query = query.Where(x => x.Application == SelectedServiceName);
}
query = ApplySearchTermFilter(query);
query = ApplyServiceNameFilter(query);
// Apply sort.
switch (SortColumn)
@@ -201,9 +206,49 @@ else
.ToListAsync();
IsLoading = false;
IsInitialized = true;
StateHasChanged();
}
/// <summary>
/// Applies a search term filter to the query.
/// </summary>
/// <param name="query">The query to apply the filter to.</param>
private IQueryable<Log> ApplySearchTermFilter(IQueryable<Log> query)
{
if (!string.IsNullOrEmpty(SearchTerm))
{
// Reset page number back to 1 if the search term has changed.
if (SearchTerm != _lastSearchTerm)
{
CurrentPage = 1;
}
_lastSearchTerm = SearchTerm;
var searchTerm = SearchTerm.Trim().ToLower();
query = query.Where(x => EF.Functions.Like(x.Application.ToLower(), "%" + searchTerm + "%") ||
EF.Functions.Like(x.Message.ToLower(), "%" + searchTerm + "%") ||
EF.Functions.Like(x.Level.ToLower(), "%" + searchTerm + "%") ||
EF.Functions.Like(x.SourceContext.ToLower(), "%" + searchTerm + "%"));
}
return query;
}
/// <summary>
/// Applies a service name filter to the query.
/// </summary>
/// <param name="query">The query to apply the filter to.</param>
private IQueryable<Log> ApplyServiceNameFilter(IQueryable<Log> query)
{
if (!string.IsNullOrEmpty(SelectedServiceName))
{
query = query.Where(x => x.Application == SelectedServiceName);
}
return query;
}
private async Task DeleteLogsWithConfirmation()
{
if (await ConfirmModalService.ShowConfirmation("Confirm Delete", "Are you sure you want to delete all logs? This action cannot be undone."))

View File

@@ -12,9 +12,9 @@ using AliasVault.Admin.Services;
using AliasVault.Auth;
using AliasVault.RazorComponents.Models;
using AliasVault.RazorComponents.Services;
using ApexCharts;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.EntityFrameworkCore;
using Microsoft.JSInterop;
/// <summary>
@@ -73,6 +73,12 @@ public abstract class MainBase : OwningComponentBase
[Inject]
protected ConfirmModalService ConfirmModalService { get; set; } = null!;
/// <summary>
/// Gets or sets the ApexChartService.
/// </summary>
[Inject]
protected IApexChartService ApexChartService { get; set; } = null!;
/// <summary>
/// Gets or sets the injected JSRuntime instance.
/// </summary>
@@ -96,6 +102,18 @@ public abstract class MainBase : OwningComponentBase
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Home", Url = NavigationService.BaseUri });
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
// Update default ApexCharts chart color based on the dark mode setting.
await SetDefaultApexChartOptionsAsync();
}
}
/// <summary>
/// Gets the username from the authentication state asynchronously.
/// </summary>
@@ -104,4 +122,50 @@ public abstract class MainBase : OwningComponentBase
{
return UserService.User().UserName ?? "[Unknown]";
}
/// <summary>
/// Sets the default ApexCharts chart color based on the dark mode setting.
/// </summary>
private async Task SetDefaultApexChartOptionsAsync()
{
var darkMode = await JsInvokeService.RetryInvokeWithResultAsync<bool>("isDarkMode", TimeSpan.Zero, 5);
var options = new ApexChartBaseOptions
{
Chart = new Chart
{
ForeColor = darkMode ? "#bbb" : "#555",
},
Fill = new Fill
{
Colors = darkMode ?
[
"#FFB84D", // Bright gold
"#8B6CB9", // Darker Purple
"#68A890", // Darker Sea Green
"#CD5C5C", // Darker Coral
"#4F94CD", // Darker Sky Blue
"#BA55D3", // Darker Plum
"#CDC673", // Darker Khaki
"#6B8E23", // Darker Sage Green
"#CD853F", // Darker Burlywood
"#7B68EE", // Darker Slate Blue
]
:
[
"#FFB366", // Light Orange
"#B19CD9", // Light Purple
"#98D8C1", // Light Sea Green
"#F08080", // Light Coral
"#87CEEB", // Sky Blue
"#DDA0DD", // Plum
"#F0E68C", // Khaki
"#9CB071", // Sage Green
"#DEB887", // Burlywood
"#A7A1E8", // Light Slate Blue
],
},
};
await ApexChartService.SetGlobalOptionsAsync(options, false);
}
}

View File

@@ -7,12 +7,25 @@
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@(TotalRecords > 0 ? $"Users ({TotalRecords:N0})" : "Users")"
Description="This page gives an overview of all registered users and the associated vaults.">
Description="This page shows an overview of all registered users and the associated vaults.">
<CustomActions>
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
</CustomActions>
</PageHeader>
@if (IsInitialized)
{
<div class="px-4">
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
<div class="mb-3">
<div class="relative">
<SearchIcon />
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search users..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
</div>
</div>
</div>
}
@if (IsLoading)
{
<LoadingIndicator />
@@ -20,12 +33,6 @@
else
{
<div class="px-4">
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
<div class="mb-4">
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search users..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
</div>
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
@foreach (var user in UserList)
{
@@ -33,15 +40,19 @@ else
<SortableTableColumn IsPrimary="true">@user.CreatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
<SortableTableColumn>@user.UserName</SortableTableColumn>
<SortableTableColumn>@user.VaultCount</SortableTableColumn>
<SortableTableColumn>@user.CredentialCount</SortableTableColumn>
<SortableTableColumn>@user.EmailClaimCount</SortableTableColumn>
<SortableTableColumn>@Math.Round((double)user.VaultStorageInKb / 1024, 1) MB</SortableTableColumn>
<SortableTableColumn><StatusPill Enabled="user.TwoFactorEnabled" /></SortableTableColumn>
<SortableTableColumn>@user.LastVaultUpdate.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
<SortableTableColumn>
@if (user.Blocked)
{
<StatusPill Enabled="false" TextFalse="Blocked" />
}
@if (user.TwoFactorEnabled)
{
<StatusPill Enabled="true" TextTrue="2FA enabled" />
}
</SortableTableColumn>
<SortableTableColumn>
<LinkButton Color="primary" Href="@($"users/{user.Id}")" Text="View" />
@@ -57,21 +68,28 @@ else
new TableColumn { Title = "Registered", PropertyName = "CreatedAt" },
new TableColumn { Title = "Username", PropertyName = "UserName" },
new TableColumn { Title = "# Vaults", PropertyName = "VaultCount" },
new TableColumn { Title = "# Credentials", PropertyName = "CredentialCount" },
new TableColumn { Title = "# Email claims", PropertyName = "EmailClaimCount" },
new TableColumn { Title = "Storage", PropertyName = "VaultStorageInKb" },
new TableColumn { Title = "2FA", PropertyName = "TwoFactorEnabled" },
new TableColumn { Title = "LastVaultUpdate", PropertyName = "LastVaultUpdate" },
new TableColumn { Title = "Status", Sortable = false },
new TableColumn { Title = "Actions", Sortable = false},
];
private List<UserViewModel> UserList { get; set; } = [];
private bool IsInitialized { get; set; } = false;
private bool IsLoading { get; set; } = true;
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 50;
private int TotalRecords { get; set; }
private string _searchTerm = string.Empty;
/// <summary>
/// The last search term.
/// </summary>
private string _lastSearchTerm = string.Empty;
private string SearchTerm
{
get => _searchTerm;
@@ -112,15 +130,13 @@ else
private async Task RefreshData()
{
IsLoading = true;
StateHasChanged();
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
IQueryable<AliasVaultUser> query = dbContext.AliasVaultUsers;
if (SearchTerm.Length > 0)
{
query = query.Where(x => EF.Functions.Like(x.UserName!.ToLower(), "%" + SearchTerm.ToLower() + "%"));
}
// Apply sort.
query = ApplySearchFilter(query);
query = ApplySort(query);
TotalRecords = await query.CountAsync();
@@ -137,7 +153,9 @@ else
Vaults = u.Vaults.Select(v => new
{
v.FileSize,
v.CreatedAt
v.CreatedAt,
v.RevisionNumber,
CredentialCount = v.CredentialsCount,
}),
EmailClaims = u.EmailClaims.Select(ec => new
{
@@ -154,15 +172,38 @@ else
Blocked = user.Blocked,
CreatedAt = user.CreatedAt,
VaultCount = user.Vaults.Count(),
CredentialCount = user.Vaults.OrderByDescending(x => x.RevisionNumber).First().CredentialCount,
EmailClaimCount = user.EmailClaims.Count(),
VaultStorageInKb = user.Vaults.Sum(x => x.FileSize),
LastVaultUpdate = user.Vaults.Any() ? user.Vaults.Max(x => x.CreatedAt) : user.CreatedAt,
}).ToList();
IsLoading = false;
IsInitialized = true;
StateHasChanged();
}
/// <summary>
/// Apply search filter to the query.
/// </summary>
private IQueryable<AliasVaultUser> ApplySearchFilter(IQueryable<AliasVaultUser> query)
{
if (SearchTerm.Length > 0)
{
// Reset page number back to 1 if the search term has changed.
if (SearchTerm != _lastSearchTerm && CurrentPage != 1)
{
CurrentPage = 1;
}
_lastSearchTerm = SearchTerm;
var searchTerm = SearchTerm.Trim().ToLower();
query = query.Where(x => EF.Functions.Like(x.UserName!.ToLower(), "%" + searchTerm + "%"));
}
return query;
}
/// <summary>
/// Apply sort to the query.
/// </summary>
@@ -191,6 +232,11 @@ else
? query.OrderBy(x => x.Vaults.Count)
: query.OrderByDescending(x => x.Vaults.Count);
break;
case "CredentialCount":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Vaults.OrderByDescending(x => x.RevisionNumber).First().CredentialsCount)
: query.OrderByDescending(x => x.Vaults.OrderByDescending(x => x.RevisionNumber).First().CredentialsCount);
break;
case "EmailClaimCount":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.EmailClaims.Count)
@@ -201,11 +247,6 @@ else
? query.OrderBy(x => x.Vaults.Sum(v => v.FileSize))
: query.OrderByDescending(x => x.Vaults.Sum(v => v.FileSize));
break;
case "TwoFactorEnabled":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.TwoFactorEnabled)
: query.OrderByDescending(x => x.TwoFactorEnabled);
break;
case "LastVaultUpdate":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Vaults.Max(v => v.CreatedAt))

View File

@@ -1,35 +1,125 @@
@using AliasVault.RazorComponents.Tables
<SortableTable Columns="@_emailClaimTableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
@foreach (var entry in SortedEmailClaimList)
{
<SortableTableRow>
<SortableTableColumn IsPrimary="true">@entry.Id</SortableTableColumn>
<SortableTableColumn>@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
<SortableTableColumn>@entry.Address</SortableTableColumn>
<SortableTableColumn>@entry.EmailCount</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
<div class="d-flex justify-content-between mb-3">
<div class="flex items-center space-x-2">
<Button Color="secondary" OnClick="ToggleShowDisabled">
@(ShowDisabled ? "Hide Disabled Claims" : "Show Disabled Claims")
</Button>
@if (EmailClaimList.Any(e => !e.Disabled))
{
<Button Color="danger" OnClick="DisableAllEmailClaims">Disable All</Button>
}
</div>
</div>
@if (IsLoading)
{
<LoadingIndicator />
}
else
{
<SortableTable Columns="@_emailClaimTableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
@foreach (var entry in SortedEmailClaimList)
{
<SortableTableRow Class="@(entry.Disabled ? "bg-secondary" : "")">
<SortableTableColumn IsPrimary="true">@entry.Id</SortableTableColumn>
<SortableTableColumn>@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
<SortableTableColumn><a href="emails?search=@entry.Address">@entry.Address</a></SortableTableColumn>
<SortableTableColumn>@entry.EmailCount</SortableTableColumn>
<SortableTableColumn>@(entry.Disabled ? "Disabled" : "Enabled")</SortableTableColumn>
<SortableTableColumn>
@if (entry.Disabled)
{
<Button Color="success" OnClick="() => ToggleEmailClaimStatus(entry)">Enable</Button>
}
else
{
<Button Color="danger" OnClick="() => ToggleEmailClaimStatus(entry)">Disable</Button>
}
</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
}
@code {
/// <summary>
/// Gets or sets the list of email claims to display.
/// Gets or sets the user.
/// </summary>
[Parameter]
public List<UserEmailClaimWithCount> EmailClaimList { get; set; } = [];
public AliasVaultUser User { get; set; } = new();
/// <summary>
/// Gets or sets the callback for when an email claim is enabled or disabled.
/// </summary>
[Parameter]
public EventCallback<(Guid id, bool disabled)> OnEmailClaimStatusChanged { get; set; }
/// <summary>
/// Gets or sets the list of email claims to display.
/// </summary>
private List<UserEmailClaimWithCount> EmailClaimList { get; set; } = [];
private bool IsLoading { get; set; } = true;
private string SortColumn { get; set; } = "CreatedAt";
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
private bool ShowDisabled { get; set; } = false;
private readonly List<TableColumn> _emailClaimTableColumns = [
new TableColumn { Title = "ID", PropertyName = "Id" },
new TableColumn { Title = "Created", PropertyName = "CreatedAt" },
new TableColumn { Title = "Email", PropertyName = "Address" },
new TableColumn { Title = "Email Count", PropertyName = "EmailCount" },
new TableColumn { Title = "Status", PropertyName = "Disabled" },
new TableColumn { Title = "Actions", PropertyName = "" },
];
private IEnumerable<UserEmailClaimWithCount> SortedEmailClaimList => SortList(EmailClaimList, SortColumn, SortDirection);
private IEnumerable<UserEmailClaimWithCount> SortedEmailClaimList =>
SortList(ShowDisabled ? EmailClaimList : EmailClaimList.Where(e => !e.Disabled).ToList(), SortColumn, SortDirection);
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
IsLoading = true;
StateHasChanged();
await RefreshData();
IsLoading = false;
StateHasChanged();
}
/// <summary>
/// This method will refresh the email claim list.
/// </summary>
private async Task RefreshData()
{
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
if (string.IsNullOrEmpty(User.Id))
{
EmailClaimList = [];
return;
}
// Load all email claims for this user.
EmailClaimList = await dbContext.UserEmailClaims
.Where(x => x.UserId == User.Id)
.Select(x => new UserEmailClaimWithCount
{
Id = x.Id,
Address = x.Address,
AddressLocal = x.AddressLocal,
AddressDomain = x.AddressDomain,
CreatedAt = x.CreatedAt,
UpdatedAt = x.UpdatedAt,
EmailCount = dbContext.Emails.Count(e => e.To == x.Address),
Disabled = x.Disabled
})
.OrderBy(x => x.CreatedAt)
.ToListAsync();
}
private void HandleSortChanged((string column, SortDirection direction) sort)
{
@@ -38,6 +128,87 @@
StateHasChanged();
}
private void ToggleShowDisabled()
{
ShowDisabled = !ShowDisabled;
StateHasChanged();
}
/// <summary>
/// This method will toggle the disabled status of an email claim.
/// </summary>
private async Task ToggleEmailClaimStatus(UserEmailClaimWithCount entry)
{
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
if (entry.Disabled)
{
// Enable email claim without confirmation.
var emailClaim = await dbContext.UserEmailClaims.FindAsync(entry.Id);
if (emailClaim != null)
{
// Re-enable the email claim.
emailClaim.Disabled = false;
emailClaim.UpdatedAt = DateTime.UtcNow;
await dbContext.SaveChangesAsync();
await RefreshData();
}
}
else
{
if (await ConfirmModalService.ShowConfirmation(
title: "Confirm Email Claim Disable",
message: @"Are you sure you want to disable this email claim?
Important notes:
• Disabling an email claim means that emails will no longer be received for this address and will be rejected by the server.
• The user can re-enable this at will by re-saving their vault which will claim it again.
Do you want to proceed with disabling this claim?"))
{
// Load email claim
var emailClaim = await dbContext.UserEmailClaims.FindAsync(entry.Id);
if (emailClaim != null)
{
// Set the disabled status to true.
emailClaim.Disabled = true;
emailClaim.UpdatedAt = DateTime.UtcNow;
await dbContext.SaveChangesAsync();
await RefreshData();
}
}
}
}
private async Task DisableAllEmailClaims()
{
if (await ConfirmModalService.ShowConfirmation(
title: "Confirm Email Claim Disable",
message: @"Are you sure you want to disable all email claims?
Important notes:
• Disabling an email claim means that emails will no longer be received for this address and will be rejected by the server.
• The user can re-enable this at will by re-saving their vault which will claim it again.
Do you want to proceed with disabling all email claims?"))
{
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
// Load email claims
var emailClaims = await dbContext.UserEmailClaims.Where(x => x.UserId == User.Id).ToListAsync();
// Disable all email claims.
foreach (var emailClaim in emailClaims)
{
emailClaim.Disabled = true;
emailClaim.UpdatedAt = DateTime.UtcNow;
}
await dbContext.SaveChangesAsync();
await RefreshData();
}
}
private static IEnumerable<UserEmailClaimWithCount> SortList(List<UserEmailClaimWithCount> emailClaims, string sortColumn, SortDirection sortDirection)
{
return sortColumn switch
@@ -46,6 +217,7 @@
"CreatedAt" => SortableTable.SortListByProperty(emailClaims, e => e.CreatedAt, sortDirection),
"Address" => SortableTable.SortListByProperty(emailClaims, e => e.Address, sortDirection),
"EmailCount" => SortableTable.SortListByProperty(emailClaims, e => e.EmailCount, sortDirection),
"Disabled" => SortableTable.SortListByProperty(emailClaims, e => e.Disabled, sortDirection),
_ => emailClaims
};
}

View File

@@ -25,39 +25,57 @@ else
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="items-center xl:block sm:space-x-4 xl:space-x-0 2xl:space-x-4">
<div>
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">@User.UserName</h3>
<h3 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white border-b border-gray-200 pb-2">@User.UserName</h3>
<div class="mb-4">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Id</label>
<div class="text-gray-700 dark:text-gray-300">@User.Id</div>
</div>
<div class="flex items-center space-x-2">
<span class="text-sm font-medium text-gray-900 dark:text-white">2FA Status:</span>
<StatusPill Enabled="@User.TwoFactorEnabled"/>
<span class="text-gray-700 dark:text-gray-300">Authenticator key(s) active: @TwoFactorKeysCount</span>
@if (User.TwoFactorEnabled)
{
<Button Color="danger" OnClick="DisableTwoFactor">Disable 2FA</Button>
}
else
{
if (TwoFactorKeysCount > 0)
{
<Button Color="success" OnClick="EnableTwoFactor">Enable 2FA</Button>
<Button Color="danger" OnClick="ResetTwoFactor">Remove 2FA keys</Button>
}
}
</div>
<div class="flex items-center space-x-2 mt-4">
<span class="text-sm font-medium text-gray-900 dark:text-white">Account Status:</span>
<StatusPill Enabled="@(!User.Blocked)" TextTrue="Active" TextFalse="Blocked" />
<Button Color="@(User.Blocked ? "success" : "danger")" OnClick="ToggleBlockStatus">
@(User.Blocked ? "Unblock User" : "Block User")
</Button>
<span class="text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1"></i>
Blocking a user prevents them from logging in or accessing AliasVault
</span>
<div class="w-full mb-4 overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<tbody>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white">Id</th>
<td class="px-4 py-3">@User.Id</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white">Registered at</th>
<td class="px-4 py-3">@User.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
</tr>
<tr>
<th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white">2FA Status</th>
<td class="px-4 py-3">
<div class="flex items-center space-x-2">
<StatusPill Enabled="@User.TwoFactorEnabled"/>
<span class="text-gray-700 dark:text-gray-300">Authenticator key(s) active: @TwoFactorKeysCount</span>
@if (User.TwoFactorEnabled)
{
<Button Color="danger" OnClick="DisableTwoFactor">Disable 2FA</Button>
}
else
{
if (TwoFactorKeysCount > 0)
{
<Button Color="success" OnClick="EnableTwoFactor">Enable 2FA</Button>
<Button Color="danger" OnClick="ResetTwoFactor">Remove 2FA keys</Button>
}
}
</div>
</td>
</tr>
<tr>
<th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white">Account Status</th>
<td class="px-4 py-3">
<div class="flex items-center space-x-2">
<StatusPill Enabled="@(!User.Blocked)" TextTrue="Active" TextFalse="Blocked" />
<Button Color="@(User.Blocked ? "success" : "danger")" OnClick="ToggleBlockStatus">
@(User.Blocked ? "Unblock User" : "Block User")
</Button>
<span class="text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1"></i>
Blocking a user prevents them from logging in or accessing AliasVault
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@@ -87,8 +105,12 @@ else
<div class="items-center">
<div>
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">Email claims</h3>
<EmailClaimTable EmailClaimList="@EmailClaimList" />
<p class="text-sm text-gray-500 dark:text-gray-400">
Email claims represent the email addresses that the user has (historically) used. Whenever a user deletes an email alias
the claim gets disabled and the server will reject all emails sent to that alias. A user can always re-enable
the claim by using it again. Email claims are permanently tied to a user and cannot be transferred to another user.
</p>
<EmailClaimTable User="@User" />
</div>
</div>
</div>
@@ -108,7 +130,6 @@ else
private int TwoFactorKeysCount { get; set; }
private List<AliasVaultUserRefreshToken> RefreshTokenList { get; set; } = [];
private List<Vault> VaultList { get; set; } = [];
private List<UserEmailClaimWithCount> EmailClaimList { get; set; } = [];
/// <inheritdoc />
protected override async Task OnInitializedAsync()
@@ -183,22 +204,6 @@ else
.OrderBy(x => x.UpdatedAt)
.ToListAsync();
// Load all email claims for this user.
EmailClaimList = await dbContext.UserEmailClaims
.Where(x => x.UserId == User.Id)
.Select(x => new UserEmailClaimWithCount
{
Id = x.Id,
Address = x.Address,
AddressLocal = x.AddressLocal,
AddressDomain = x.AddressDomain,
CreatedAt = x.CreatedAt,
UpdatedAt = x.UpdatedAt,
EmailCount = dbContext.Emails.Count(e => e.To == x.Address)
})
.OrderBy(x => x.CreatedAt)
.ToListAsync();
IsLoading = false;
StateHasChanged();
}
@@ -321,6 +326,14 @@ Do you want to proceed with the restoration?")) {
if (User != null)
{
User.Blocked = !User.Blocked;
// If user is unblocked by the admin, also reset any lockout status, which can be
// automatically triggered by the system when user has entered an incorrect password too many times.
if (!User.Blocked) {
User.AccessFailedCount = 0;
User.LockoutEnd = null;
}
await dbContext.SaveChangesAsync();
await RefreshData();
}

View File

@@ -15,6 +15,7 @@
@using AliasVault.Admin.Main.Layout
@using AliasVault.Admin.Main.Components
@using AliasVault.Admin.Main.Components.Alerts
@using AliasVault.Admin.Main.Components.Icons
@using AliasVault.Admin.Main.Components.Layout
@using AliasVault.Admin.Main.Components.Loading
@using AliasVault.Admin.Main.Components.WorkerStatus
@@ -27,4 +28,5 @@
@using AliasVault.Admin.Main.Pages
@using AliasVault.Admin.Services
@using AliasServerDb
@using ApexCharts
@using Microsoft.AspNetCore.Authorization

View File

@@ -19,6 +19,7 @@ using AliasVault.Logging;
using AliasVault.RazorComponents.Services;
using AliasVault.Shared.Models.Configuration;
using AliasVault.Shared.Server.Services;
using ApexCharts;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
@@ -61,6 +62,7 @@ builder.Services.AddScoped<AuthLoggingService>();
builder.Services.AddScoped<ConfirmModalService>();
builder.Services.AddScoped<ServerSettingsService>();
builder.Services.AddSingleton(new VersionedContentService(Directory.GetCurrentDirectory() + "/wwwroot"));
builder.Services.AddApexCharts();
builder.Services.AddAuthentication(options =>
{

View File

@@ -50,4 +50,44 @@ public class JsInvokeService(IJSRuntime js)
// Optionally log that the JS function could not be called after maxAttempts
}
/// <summary>
/// Invoke a JavaScript function with retry and exponential backoff that returns a value.
/// </summary>
/// <typeparam name="TValue">The type of value to return from the JavaScript function.</typeparam>
/// <param name="functionName">The JS function name to call.</param>
/// <param name="initialDelay">Initial delay before calling the function.</param>
/// <param name="maxAttempts">Maximum attempts before giving up.</param>
/// <param name="args">Arguments to pass on to the javascript function.</param>
/// <returns>The value returned from the JavaScript function.</returns>
/// <exception cref="InvalidOperationException">Thrown when the JS function could not be called after all attempts.</exception>
public async Task<TValue> RetryInvokeWithResultAsync<TValue>(string functionName, TimeSpan initialDelay, int maxAttempts, params object[] args)
{
TimeSpan delay = initialDelay;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
bool isDefined = await js.InvokeAsync<bool>("isFunctionDefined", functionName);
if (isDefined)
{
return await js.InvokeAsync<TValue>(functionName, args);
}
}
catch
{
// Optionally log the exception
}
// Wait for the delay before the next attempt
await Task.Delay(delay);
// Exponential backoff: double the delay for the next attempt
delay = TimeSpan.FromTicks(delay.Ticks * 2);
}
// All attempts failed, throw an exception
throw new InvalidOperationException($"Failed to invoke JavaScript function '{functionName}' after {maxAttempts} attempts.");
}
}

View File

@@ -1,11 +1,11 @@
{
"name": "aliasvault.client",
"name": "aliasvault.admin",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aliasvault.client",
"name": "aliasvault.admin",
"version": "1.0.0",
"license": "ISC",
"dependencies": {

View File

@@ -566,6 +566,10 @@ video {
border-width: 0;
}
.pointer-events-none {
pointer-events: none;
}
.visible {
visibility: visible;
}
@@ -594,6 +598,15 @@ video {
inset: 0px;
}
.inset-y-0 {
top: 0px;
bottom: 0px;
}
.left-0 {
left: 0px;
}
.right-0 {
right: 0px;
}
@@ -614,6 +627,10 @@ video {
z-index: 50;
}
.col-span-2 {
grid-column: span 2 / span 2;
}
.col-span-full {
grid-column: 1 / -1;
}
@@ -646,10 +663,18 @@ video {
margin-bottom: 0.5rem;
}
.mb-3 {
margin-bottom: 0.75rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.mb-5 {
margin-bottom: 1.25rem;
}
.mb-6 {
margin-bottom: 1.5rem;
}
@@ -805,6 +830,10 @@ video {
height: 100%;
}
.max-h-\[500px\] {
max-height: 500px;
}
.w-1\/2 {
width: 50%;
}
@@ -947,6 +976,10 @@ video {
align-items: center;
}
.items-baseline {
align-items: baseline;
}
.justify-start {
justify-content: flex-start;
}
@@ -963,6 +996,10 @@ video {
justify-content: space-between;
}
.gap-2 {
gap: 0.5rem;
}
.gap-4 {
gap: 1rem;
}
@@ -1267,11 +1304,6 @@ video {
background-color: rgb(184 112 47 / var(--tw-bg-opacity));
}
.bg-purple-50 {
--tw-bg-opacity: 1;
background-color: rgb(250 245 255 / var(--tw-bg-opacity));
}
.bg-red-100 {
--tw-bg-opacity: 1;
background-color: rgb(254 226 226 / var(--tw-bg-opacity));
@@ -1430,14 +1462,26 @@ video {
padding-bottom: 2rem;
}
.pb-2 {
padding-bottom: 0.5rem;
}
.pl-2 {
padding-left: 0.5rem;
}
.pl-3 {
padding-left: 0.75rem;
}
.pr-2 {
padding-right: 0.5rem;
}
.ps-10 {
padding-inline-start: 2.5rem;
}
.ps-2 {
padding-inline-start: 0.5rem;
}
@@ -1653,6 +1697,10 @@ video {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.filter {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.transition {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
@@ -1933,10 +1981,6 @@ video {
background-color: rgb(214 131 56 / var(--tw-bg-opacity));
}
.dark\:bg-purple-900\/30:is(.dark *) {
background-color: rgb(88 28 135 / 0.3);
}
.dark\:bg-red-600:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
@@ -1966,10 +2010,6 @@ video {
background-color: rgb(113 63 18 / var(--tw-bg-opacity));
}
.dark\:bg-yellow-900\/30:is(.dark *) {
background-color: rgb(113 63 18 / 0.3);
}
.dark\:bg-opacity-80:is(.dark *) {
--tw-bg-opacity: 0.8;
}

View File

@@ -14,6 +14,10 @@ window.initTopMenu = function() {
initDarkModeSwitcher();
};
window.isDarkMode = function() {
return document.documentElement.classList.contains('dark');
};
window.registerClickOutsideHandler = (dotNetHelper) => {
document.addEventListener('click', (event) => {
const menu = document.getElementById('userMenuDropdown');
@@ -89,3 +93,35 @@ function generateQrCode(id) {
qrcode.makeCode(dataUrl);
}
// Keyboard navigation for pagination
window.enablePaginationKeyboardNavigation = (element, dotNetHelper, currentPage, maxPage) => {
if (!element) return;
// Add tabindex and focus if not already set
if (!element.hasAttribute('tabindex')) {
element.setAttribute('tabindex', '0');
}
// Remove any existing event listener to prevent duplicates
if (element._paginationKeyHandler) {
element.removeEventListener('keydown', element._paginationKeyHandler);
}
// Create keyboard event handler
element._paginationKeyHandler = (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
const newPage = e.key === 'ArrowLeft'
? Math.max(1, currentPage - 1)
: Math.min(maxPage, currentPage + 1);
if (newPage !== currentPage) {
dotNetHelper.invokeMethodAsync('NavigateToPage', newPage);
}
}
};
// Add event listener
element.addEventListener('keydown', element._paginationKeyHandler);
};

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
@@ -22,12 +22,12 @@
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.3" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -406,14 +406,17 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
// Get all existing user email claims.
var existingEmailClaims = await context.UserEmailClaims
.Where(x => x.UserId == user.Id)
.Select(x => x.Address)
.ToListAsync();
// Keep track of processed and sanitized email addresses to know which ones still exist.
var processedEmailAddresses = new List<string>();
// Register new email addresses.
foreach (var email in newEmailAddresses)
{
// Sanitize email address.
var sanitizedEmail = EmailHelper.SanitizeEmail(email);
processedEmailAddresses.Add(sanitizedEmail);
// If email address is invalid according to the EmailAddressAttribute, skip it.
if (!new EmailAddressAttribute().IsValid(sanitizedEmail))
@@ -421,9 +424,14 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
continue;
}
// If email address is already claimed by current user, we don't need to claim it again.
if (existingEmailClaims.Any(x => x.Address == sanitizedEmail))
{
continue;
}
// Check if the email address is already claimed (by another user).
var existingClaim = await context.UserEmailClaims
.FirstOrDefaultAsync(x => x.Address == sanitizedEmail);
var existingClaim = await context.UserEmailClaims.FirstOrDefaultAsync(x => x.Address == sanitizedEmail);
if (existingClaim != null && existingClaim.UserId != user.Id)
{
@@ -432,11 +440,10 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
continue;
}
if (!existingEmailClaims.Contains(sanitizedEmail))
// If we get to this point, the email address is new and not claimed by another user, so we can add it.
try
{
try
{
context.UserEmailClaims.Add(new UserEmailClaim
context.UserEmailClaims.Add(new UserEmailClaim
{
UserId = user.Id,
Address = sanitizedEmail,
@@ -445,19 +452,27 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
CreatedAt = timeProvider.UtcNow,
UpdatedAt = timeProvider.UtcNow,
});
}
catch (DbUpdateException ex)
{
// Error while adding email claim. Log the error and continue.
logger.LogWarning(ex, "Error while adding UserEmailClaim with email: {Email} for user: {UserId}.", sanitizedEmail, user.UserName);
}
}
catch (DbUpdateException ex)
{
// Error while adding email claim. Log the error and continue.
logger.LogWarning(ex, "Error while adding UserEmailClaim with email: {Email} for user: {UserId}.", sanitizedEmail, user.UserName);
}
}
// Do not delete email claims that are not in the new list
// as they may be re-used by the user in the future. We don't want
// to allow other users to re-use emails used by other users.
// Disable email claims that are no longer in the new list and have not been disabled yet.
// Important: we do not delete email claims ever, as they may be re-used by the user in the future.
// We also don't want to allow other users to re-use emails used by other users.
// Email claims are considered permanent.
foreach (var existingClaim in existingEmailClaims.Where(x => !x.Disabled).ToList())
{
if (!processedEmailAddresses.Contains(existingClaim.Address))
{
// Email address is no longer in the new list and has not been disabled yet, so disable it.
existingClaim.Disabled = true;
}
}
await context.SaveChangesAsync();
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<RootNamespace>AliasVault.Client</RootNamespace>
<TargetFramework>net9.0</TargetFramework>
@@ -49,11 +49,12 @@
<ItemGroup>
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.2" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="9.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="9.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.3" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.3" />
<PackageReference Include="SpamOK.PasswordGenerator" Version="1.1.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -77,12 +78,11 @@
<ItemGroup>
<ProjectReference Include="..\Databases\AliasClientDb\AliasClientDb.csproj" />
<ProjectReference Include="..\Generators\AliasVault.Generators.Identity\AliasVault.Generators.Identity.csproj" />
<ProjectReference Include="..\Generators\AliasVault.Generators.Password\AliasVault.Generators.Password.csproj" />
<ProjectReference Include="..\Shared\AliasVault.RazorComponents\AliasVault.RazorComponents.csproj" />
<ProjectReference Include="..\Shared\AliasVault.Shared.Core\AliasVault.Shared.Core.csproj" />
<ProjectReference Include="..\Shared\AliasVault.Shared\AliasVault.Shared.csproj" />
<ProjectReference Include="..\Utilities\Cryptography\AliasVault.Cryptography.Client\AliasVault.Cryptography.Client.csproj" />
<ProjectReference Include="..\Utilities\AliasVault.CsvImportExport\AliasVault.CsvImportExport.csproj" />
<ProjectReference Include="..\Utilities\AliasVault.ImportExport\AliasVault.ImportExport.csproj" />
<ProjectReference Include="..\Utilities\AliasVault.FaviconExtractor\AliasVault.FaviconExtractor.csproj" />
<ProjectReference Include="..\Utilities\AliasVault.TotpGenerator\AliasVault.TotpGenerator.csproj" />
<ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="wwwroot\service-worker.published.js" />

View File

@@ -3,7 +3,7 @@
<div class="flex flex-col items-center justify-center px-6 pt-8 pb-8 mx-auto md:h-screen pt:mt-0 dark:bg-gray-900">
<Logo />
<div class="w-full max-w-xl p-6 space-y-4 sm:p-8 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="w-full max-w-xl p-6 sm:p-8 bg-white rounded-lg shadow dark:bg-gray-800">
<GlobalNotificationDisplay />
@Body
</div>

View File

@@ -21,6 +21,12 @@ using Microsoft.JSInterop;
/// </summary>
public class LoginBase : OwningComponentBase
{
/// <summary>
/// LocalStorage key for storing the return url that should be redirected to after a succesful
/// login or unlock event.
/// </summary>
public const string ReturnUrlKey = "returnUrl";
/// <summary>
/// Gets or sets the NavigationManager.
/// </summary>

View File

@@ -136,7 +136,7 @@ else
await AuthStateProvider.GetAuthenticationStateAsync();
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (authState.User.Identity?.IsAuthenticated == true) {
// Already authenticated, redirect to home page.a
// Already authenticated, redirect to home page.
NavigationManager.NavigateTo("/");
}
}
@@ -173,7 +173,7 @@ else
/// </summary>
private async Task HandleLogin()
{
_loadingIndicator.Show();
_loadingIndicator.Show("Logging in...");
_serverValidationErrors.Clear();
try
@@ -279,7 +279,7 @@ else
/// </summary>
private async Task HandleRecoveryCode()
{
_loadingIndicator.Show();
_loadingIndicator.Show("Verifying recovery code...");
_serverValidationErrors.Clear();
try
@@ -337,7 +337,7 @@ else
/// </summary>
private async Task Handle2Fa()
{
_loadingIndicator.Show();
_loadingIndicator.Show("Verifying 2FA code...");
_serverValidationErrors.Clear();
try
@@ -409,9 +409,10 @@ else
GlobalNotificationService.ClearMessages();
// Redirect to the page the user was trying to access before if set.
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>("returnUrl");
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>(ReturnUrlKey);
if (!string.IsNullOrEmpty(localStorageReturnUrl))
{
await LocalStorage.RemoveItemAsync(ReturnUrlKey);
NavigationManager.NavigateTo(localStorageReturnUrl);
}
else
@@ -432,7 +433,7 @@ else
{
// Update the blazor model with the current value.
_loginModel2Fa.TwoFactorCode = int.Parse(e.Value.ToString()!);
// Submit the form.
await Handle2Fa();
}

View File

@@ -53,7 +53,7 @@
private async Task HandleRegister()
{
_loadingIndicator.Show();
_loadingIndicator.Show("Creating account...");
_serverValidationErrors.Clear();
var (success, errorMessage) = await UserRegistrationService.RegisterUserAsync(_registerModel.Username, _registerModel.Password);

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