Compare commits

...

147 Commits

Author SHA1 Message Date
Leendert de Borst
ab82a63a0a Bump version to 0.16.2 (#818) 2025-05-01 08:57:09 +02:00
dependabot[bot]
82376b696c 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.6 to 6.3.4
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.4/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-01 08:45:37 +02:00
Leendert de Borst
0c8fc191a6 Update date format in RecentEmails.razor (#815) 2025-04-30 14:41:26 +02:00
Leendert de Borst
b71f0dd2c3 Tweak Login.razor margins (#809) 2025-04-28 18:44:15 +02:00
Leendert de Borst
3617c551e3 Refresh password salt and ephemeral after changing it (#809) 2025-04-28 18:44:15 +02:00
Leendert de Borst
901caa896b Add dashlane importer and unittest (#811) 2025-04-28 18:44:08 +02:00
dependabot[bot]
89534bf78e Bump the npm_and_yarn group across 1 directory with 2 updates
Bumps the npm_and_yarn group with 2 updates in the /browser-extension directory: [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) and [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom).


Updates `react-router` from 7.2.0 to 7.5.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.5.2/packages/react-router)

Updates `react-router-dom` from 7.2.0 to 7.5.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.5.2/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.5.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: react-router-dom
  dependency-version: 7.5.2
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-25 17:18:01 +02:00
dependabot[bot]
e82595162f 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.4 to 1.18.8
- [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.4...v1.18.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-22 12:37:19 +02:00
Leendert de Borst
93c439e852 Fix nullability warning in FaviconExtractor.cs (#805) 2025-04-21 16:01:22 +02:00
dependabot[bot]
ff08fae579 Bump HtmlAgilityPack from 1.12.0 to 1.12.1
Bumps [HtmlAgilityPack](https://github.com/zzzprojects/html-agility-pack) from 1.12.0 to 1.12.1.
- [Release notes](https://github.com/zzzprojects/html-agility-pack/releases)
- [Commits](https://github.com/zzzprojects/html-agility-pack/compare/v1.12.0...v1.12.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-21 16:01:22 +02:00
Leendert de Borst
5fdcee50d5 Bump version to 0.16.1 (#803) 2025-04-15 18:39:10 +02:00
Leendert de Borst
8526172ec7 Add form detector improvements and tests (#794) 2025-04-15 18:39:10 +02:00
Leendert de Borst
5156988319 Merge pull request #800 from lanedirt/798-browser-extension-make-service-name-extraction-more-accurate
Browser extension make service name extraction more accurate
2025-04-15 18:02:03 +02:00
Leendert de Borst
18d92ecced Add reliable click handler for all autofill popup elements (#797) 2025-04-15 17:07:27 +02:00
Leendert de Borst
0a0bec99b1 Merge branch 'main' into 798-browser-extension-make-service-name-extraction-more-accurate 2025-04-15 17:02:46 +02:00
Leendert de Borst
791f8a758b Update Filter.ts (#801) 2025-04-15 17:00:22 +02:00
Leendert de Borst
3f11e29787 Fix autofill popup z-index visibility (#801) 2025-04-15 17:00:22 +02:00
Leendert de Borst
046d09453a Show email in credential list if username is empty (#801) 2025-04-15 17:00:22 +02:00
Leendert de Borst
1d77d05e7c Improve autofill matching (#801) 2025-04-15 17:00:22 +02:00
Leendert de Borst
22d2e09982 Make browser extension autofill dismiss button more reliable (#797) 2025-04-15 16:59:50 +02:00
Leendert de Borst
8b835a4a77 Remove cancel for sonarcloud runner as it uses pull_request_target 2025-04-15 16:58:28 +02:00
Leendert de Borst
a435305093 Simplify service name to a single input for both modes (#798) 2025-04-15 15:51:18 +02:00
Leendert de Borst
e4f3de927f Show service name suggestions (#798) 2025-04-15 15:34:48 +02:00
Leendert de Borst
1d5c288514 Add service name extraction unit tests (#798) 2025-04-15 12:57:04 +02:00
Leendert de Borst
5d3ad60dee Improve browser extension service name extractor (#798) 2025-04-15 12:56:55 +02:00
Leendert de Borst
c5244b31ec Cancel already running CI jobs on newer commit 2025-04-15 11:34:19 +02:00
Leendert de Borst
a6c7c54592 Add password visibility toggle to browser extension credential create (#793) 2025-04-15 11:24:59 +02:00
Leendert de Borst
bf46c155bd Fix browser extension autofill from causing scrollbars to appear (#794) 2025-04-15 11:24:51 +02:00
Leendert de Borst
d4e5b724ff Make autofill work with more input element variations (#794) 2025-04-15 11:24:51 +02:00
Leendert de Borst
e51219d513 Add explicit type=text for accessibility improvements (#794) 2025-04-15 11:24:51 +02:00
Leendert de Borst
800f015947 Update all .NET dependencies to 9.0.4 (#791) 2025-04-14 20:55:36 +02:00
dependabot[bot]
5f3c36263d Bump Microsoft.AspNetCore.Authorization and Microsoft.AspNetCore.Components.Web
Bumps [Microsoft.AspNetCore.Authorization](https://github.com/dotnet/aspnetcore) and [Microsoft.AspNetCore.Components.Web](https://github.com/dotnet/aspnetcore). These dependencies needed to be updated together.

Updates `Microsoft.AspNetCore.Authorization` from 9.0.3 to 9.0.4
- [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.3...v9.0.4)

Updates `Microsoft.AspNetCore.Components.Web` from 9.0.3 to 9.0.4
- [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.3...v9.0.4)

---
updated-dependencies:
- dependency-name: Microsoft.AspNetCore.Authorization
  dependency-version: 9.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Microsoft.AspNetCore.Components.Web
  dependency-version: 9.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-14 17:00:32 +02:00
dependabot[bot]
4617d5efc4 Bump Microsoft.AspNetCore.Components.WebAssembly.DevServer
Bumps [Microsoft.AspNetCore.Components.WebAssembly.DevServer](https://github.com/dotnet/aspnetcore) from 9.0.3 to 9.0.4.
- [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.3...v9.0.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-14 17:00:22 +02:00
dependabot[bot]
1401982e2c Bump Microsoft.EntityFrameworkCore and Microsoft.EntityFrameworkCore.SqlServer
Bumps [Microsoft.EntityFrameworkCore](https://github.com/dotnet/efcore) and [Microsoft.EntityFrameworkCore.SqlServer](https://github.com/dotnet/efcore). These dependencies needed to be updated together.

Updates `Microsoft.EntityFrameworkCore` from 9.0.3 to 9.0.4
- [Release notes](https://github.com/dotnet/efcore/releases)
- [Commits](https://github.com/dotnet/efcore/compare/v9.0.3...v9.0.4)

Updates `Microsoft.EntityFrameworkCore.SqlServer` from 9.0.3 to 9.0.4
- [Release notes](https://github.com/dotnet/efcore/releases)
- [Commits](https://github.com/dotnet/efcore/compare/v9.0.3...v9.0.4)

---
updated-dependencies:
- dependency-name: Microsoft.EntityFrameworkCore
  dependency-version: 9.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Microsoft.EntityFrameworkCore.SqlServer
  dependency-version: 9.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-14 17:00:15 +02:00
Leendert de Borst
ebdbf41208 Merge pull request #778 from lanedirt/775-add-spacing-in-webauthn-login-message-ui
Update webauthn unlock animation margin
2025-04-13 21:09:36 +02:00
Leendert de Borst
ed4b82e125 Update webauthn unlock animation margin (#775) 2025-04-12 16:06:12 +02:00
dependabot[bot]
1976255e98 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.5 to 6.2.6
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.2.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.6/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-12 16:03:32 +02:00
Leendert de Borst
e817326162 Merge pull request #774 from lanedirt/772-feature-request-add-proton-pass-import
Add Proton Pass importer
2025-04-12 16:03:19 +02:00
Leendert de Borst
9d2a397317 Add ProtonPass importer (#772) 2025-04-11 11:32:13 +02:00
dependabot[bot]
8f42ebdfa4 Bump System.Drawing.Common from 8.0.0 to 9.0.3
Bumps [System.Drawing.Common](https://github.com/dotnet/winforms) from 8.0.0 to 9.0.3.
- [Release notes](https://github.com/dotnet/winforms/releases)
- [Changelog](https://github.com/dotnet/winforms/blob/main/docs/release-activity.md)
- [Commits](https://github.com/dotnet/winforms/compare/v8.0.0...v9.0.3)

---
updated-dependencies:
- dependency-name: System.Drawing.Common
  dependency-version: 9.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-08 09:38:52 +02:00
dependabot[bot]
3aab43b17a Bump Swashbuckle.AspNetCore from 8.0.0 to 8.1.0
Bumps [Swashbuckle.AspNetCore](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) from 8.0.0 to 8.1.0.
- [Release notes](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/releases)
- [Commits](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/compare/v8.0.0...v8.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-08 09:38:42 +02:00
dependabot[bot]
6e922237c0 Bump NUglify from 1.21.13 to 1.21.14
Bumps [NUglify](https://github.com/trullock/NUglify) from 1.21.13 to 1.21.14.
- [Release notes](https://github.com/trullock/NUglify/releases)
- [Changelog](https://github.com/trullock/NUglify/blob/master/changelog.md)
- [Commits](https://github.com/trullock/NUglify/compare/v1.21.13...v1.21.14)

---
updated-dependencies:
- dependency-name: NUglify
  dependency-version: 1.21.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-08 09:38:36 +02:00
Leendert de Borst
ebac252162 Merge pull request #767 from lanedirt/dependabot/nuget/main/NUnit.Analyzers-4.7.0
Bump NUnit.Analyzers from 4.6.0 to 4.7.0
2025-04-08 09:38:20 +02:00
dependabot[bot]
9df76ffb43 Bump NUnit.Analyzers from 4.6.0 to 4.7.0
Bumps [NUnit.Analyzers](https://github.com/nunit/nunit.analyzers) from 4.6.0 to 4.7.0.
- [Release notes](https://github.com/nunit/nunit.analyzers/releases)
- [Changelog](https://github.com/nunit/nunit.analyzers/blob/master/CHANGES.md)
- [Commits](https://github.com/nunit/nunit.analyzers/compare/4.6.0...4.7.0)

---
updated-dependencies:
- dependency-name: NUnit.Analyzers
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-07 09:56:30 +00:00
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
202 changed files with 9416 additions and 1860 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=

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

@@ -7,6 +7,10 @@ on:
branches: [ "main" ]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-chrome-extension:
runs-on: ubuntu-latest

View File

@@ -6,6 +6,10 @@ on:
pull_request:
branches: [ "main" ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test-docker:
runs-on: ubuntu-latest

View File

@@ -7,6 +7,10 @@ on:
pull_request:
branches: [ "main" ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test-docker:
runs-on: ubuntu-latest

View File

@@ -7,6 +7,10 @@ on:
pull_request:
branches: [ "main" ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
admin-tests:
timeout-minutes: 60

View File

@@ -7,6 +7,10 @@ on:
pull_request:
branches: [ "main" ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
client-tests:
timeout-minutes: 60

View File

@@ -7,6 +7,10 @@ on:
pull_request:
branches: [ "main" ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
timeout-minutes: 60

View File

@@ -7,6 +7,10 @@ on:
pull_request:
branches: [ "main" ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest

View File

@@ -52,10 +52,10 @@ jobs:
uses: softprops/action-gh-release@v2
with:
files: |
dist/aliasvault-browser-extension-*-chrome.zip
dist/aliasvault-browser-extension-*-firefox.zip
dist/aliasvault-browser-extension-*-edge.zip
dist/aliasvault-browser-extension-*-sources.zip
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:

View File

@@ -9,6 +9,7 @@ on:
- main
pull_request_target:
types: [opened, synchronize, reopened]
jobs:
build:
name: Build and analyze

75
.vscode/tasks.json vendored
View File

@@ -2,7 +2,7 @@
"version": "2.0.0",
"tasks": [
{
"label": "Run and watch API",
"label": "Build and watch API",
"type": "shell",
"command": "dotnet watch",
"args": [],
@@ -16,7 +16,7 @@
}
},
{
"label": "Run and watch Client",
"label": "Build and watch Client",
"type": "shell",
"command": "dotnet watch",
"args": [],
@@ -30,7 +30,7 @@
}
},
{
"label": "Run and watch Admin",
"label": "Build and watch Admin",
"type": "shell",
"command": "dotnet watch",
"args": [],
@@ -42,6 +42,75 @@
"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

@@ -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://github.com/lanedirt/AliasVault/releases/latest/download/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,51 +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
- [ ] Add GUI to allow customizing password generation options (length, special chars etc.) (https://github.com/lanedirt/AliasVault/issues/167)
- [ ] 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

View File

@@ -16,7 +16,7 @@
"otpauth": "^9.3.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.4",
"react-router-dom": "^7.5.2",
"secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0",
"sql.js": "^1.12.0",
"vitest": "^3.0.8",
@@ -1973,12 +1973,6 @@
"@types/har-format": "*"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/emscripten": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.40.0.tgz",
@@ -10292,12 +10286,11 @@
}
},
"node_modules/react-router": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.2.0.tgz",
"integrity": "sha512-fXyqzPgCPZbqhrk7k3hPcCpYIlQ2ugIXDboHUzhJISFVy2DEPsmHgN588MyGmkIOv3jDgNfUE3kJi83L28s/LQ==",
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.2.tgz",
"integrity": "sha512-9Rw8r199klMnlGZ8VAsV/I8WrIF6IyJ90JQUdboupx1cdkgYqwnrYjH+I/nY/7cA1X5zia4mDJqH36npP7sxGQ==",
"license": "MIT",
"dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0",
"turbo-stream": "2.4.0"
@@ -10316,12 +10309,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.2.0.tgz",
"integrity": "sha512-cU7lTxETGtQRQbafJubvZKHEn5izNABxZhBY0Jlzdv0gqQhCPQt2J8aN5ZPjS6mQOXn5NnirWNh+FpE8TTYN0Q==",
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.5.2.tgz",
"integrity": "sha512-yk1XW8Fj7gK7flpYBXF3yzd2NbX6P7Kxjvs2b5nu1M04rb5pg/Zc4fGdBNTeT4eDYL2bvzWNyKaIMJX/RKHTTg==",
"license": "MIT",
"dependencies": {
"react-router": "7.2.0"
"react-router": "7.5.2"
},
"engines": {
"node": ">=20.0.0"
@@ -11898,6 +11891,48 @@
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
"integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
"license": "MIT",
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinypool": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz",
@@ -12449,14 +12484,17 @@
}
},
"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.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.4.tgz",
"integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.30.1"
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
@@ -12599,6 +12637,32 @@
"node": ">=8.10.0"
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
"integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
"license": "MIT",
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitest": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.8.tgz",

View File

@@ -32,7 +32,7 @@
"otpauth": "^9.3.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.4",
"react-router-dom": "^7.5.2",
"secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0",
"sql.js": "^1.12.0",
"vitest": "^3.0.8",

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 = 7;
CURRENT_PROJECT_VERSION = 15;
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.15.0;
MARKETING_VERSION = 0.16.2;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -554,7 +554,7 @@
CODE_SIGN_ENTITLEMENTS = AliasVault/AliasVault.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 15;
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.15.0;
MARKETING_VERSION = 0.16.2;
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, handleGetPasswordSettings, 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({
@@ -25,6 +25,7 @@ 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());

View File

@@ -9,7 +9,7 @@ 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';
/**
@@ -197,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);
});
}
@@ -211,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[];
@@ -233,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
@@ -259,6 +259,22 @@ 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.
*/

View File

@@ -1,7 +1,7 @@
import './contentScript/style.css';
import { FormDetector } from '../utils/formDetector/FormDetector';
import { isAutoShowPopupEnabled, openAutofillPopup, removeExistingPopup } from './contentScript/Popup';
import { injectIcon, popupDebounceTimeHasPassed } from './contentScript/Form';
import { injectIcon, popupDebounceTimeHasPassed, validateInputField } from './contentScript/Form';
import { onMessage } from "webext-bridge/content-script";
import { BoolResponse as messageBoolResponse } from '../utils/types/messaging/BoolResponse';
import { defineContentScript } from 'wxt/sandbox';
@@ -25,8 +25,10 @@ export default defineContentScript({
// Create a shadow root UI for isolation
const ui = await createShadowRootUi(ctx, {
name: 'aliasvault-ui',
position: 'inline',
anchor: 'body',
position: 'overlay',
alignment: 'top-left',
zIndex: 2147483646,
anchor: 'html',
/**
* Handle mount.
*/
@@ -40,25 +42,23 @@ export default defineContentScript({
}
// 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') {
const avDisable = ((e.target as HTMLElement).getAttribute('av-disable') ?? document.body?.getAttribute('av-disable') ?? document.documentElement.getAttribute('av-disable')) === 'true';
if (avDisable) {
return;
}
const target = e.target as HTMLInputElement;
const textInputTypes = ['text', 'email', 'tel', 'password', 'search', 'url'];
if (target.tagName === 'INPUT' && textInputTypes.includes(target.type) && !target.dataset.aliasvaultIgnore) {
const formDetector = new FormDetector(document, target);
const { isValid, inputElement } = validateInputField(e.target as Element);
if (isValid && inputElement) {
const formDetector = new FormDetector(document, inputElement);
if (!formDetector.containsLoginForm()) {
return;
}
injectIcon(target, container);
injectIcon(inputElement, container);
// Only show popup if its enabled and debounce time has passed.
if (await isAutoShowPopupEnabled() && popupDebounceTimeHasPassed()) {
openAutofillPopup(target, container);
openAutofillPopup(inputElement, container);
}
}
};
@@ -85,19 +85,19 @@ export default defineContentScript({
}
const target = document.getElementById(elementIdentifier) ?? document.getElementsByName(elementIdentifier)[0];
const { isValid, inputElement } = validateInputField(target);
if (!(target instanceof HTMLInputElement)) {
return { success: false, error: 'Target element is not an input field' };
if (!isValid || !inputElement) {
return { success: false, error: 'Target element is not a supported input field' };
}
const formDetector = new FormDetector(document, target);
if (!formDetector.containsLoginForm(true)) {
const formDetector = new FormDetector(document, inputElement);
if (!formDetector.containsLoginForm()) {
return { success: false, error: 'No form found' };
}
injectIcon(target, container);
openAutofillPopup(target, container);
injectIcon(inputElement, container);
openAutofillPopup(inputElement, container);
return { success: true };
});
},

View File

@@ -1,63 +1,108 @@
import { CombinedStopWords } from "@/utils/formDetector/FieldPatterns";
import { Credential } from "../../utils/types/Credential";
type CredentialWithPriority = Credential & {
priority: number;
}
/**
* Filter credentials based on current URL and page context to determine which credentials to show
* in the autofill popup.
* in the autofill popup. Credentials are sorted by priority:
* 1. Exact URL match (highest priority)
* 2. Base URL match AND page title word match
* 3. Base URL match only
* 4. Page title word match only (lowest priority)
*/
export function filterCredentials(credentials: Credential[], currentUrl: string, pageTitle: string): Credential[] {
const urlObject = new URL(currentUrl);
const baseUrl = `${urlObject.protocol}//${urlObject.hostname}`;
const filtered: CredentialWithPriority[] = [];
// 1. Exact URL match
let filtered = credentials.filter(cred =>
cred.ServiceUrl?.toLowerCase() === currentUrl.toLowerCase()
);
const sanitizedCurrentUrl = currentUrl.toLowerCase().replace('www.', '');
// 2. Base URL match with fuzzy domain comparison if no exact matches
filtered = filtered.concat(credentials.filter(cred => {
if (!cred.ServiceUrl) {
return false;
// 1. Exact URL match (priority 1)
credentials.forEach(cred => {
if (!cred.ServiceUrl || cred.ServiceUrl.length === 0) {
return;
}
const sanitizedCredUrl = cred.ServiceUrl.toLowerCase().replace('www.', '');
if (sanitizedCurrentUrl.startsWith(sanitizedCredUrl)) {
filtered.push({ ...cred, priority: 1 });
}
});
// If we have one or more exact matches, do not continue to other matches
if (filtered.length > 0) {
return filtered;
}
// Prepare page title words for matching
const titleWords = pageTitle.length > 0
? pageTitle.toLowerCase()
.split(/\s+/)
.filter(word =>
word.length > 3 &&
!CombinedStopWords.has(word.toLowerCase())
)
: [];
// Check for base URL matches and page title matches
credentials.forEach(cred => {
if (!cred.ServiceUrl || filtered.some(f => f.Id === cred.Id)) {
return;
}
let hasBaseUrlMatch = false;
let hasTitleMatch = false;
// Check base URL match
try {
const credUrlObject = new URL(cred.ServiceUrl);
const currentUrlObject = new URL(baseUrl);
// Extract root domains by splitting on dots and taking last two parts
const credDomainParts = credUrlObject.hostname.toLowerCase().split('.');
const currentDomainParts = currentUrlObject.hostname.toLowerCase().split('.');
// Get root domain (last two parts, e.g., 'aliasvaul.net')
const credRootDomain = credDomainParts.slice(-2).join('.');
const currentRootDomain = currentDomainParts.slice(-2).join('.');
// Compare protocols and root domains
return credUrlObject.protocol === currentUrlObject.protocol &&
credRootDomain === currentRootDomain;
if (credUrlObject.protocol === currentUrlObject.protocol &&
credRootDomain === currentRootDomain) {
hasBaseUrlMatch = true;
}
} catch {
return false;
// Invalid URL, skip
}
}));
// 3. Page title word match if still no matches
if (filtered.length === 0 && pageTitle.length > 0) {
const titleWords = pageTitle.toLowerCase()
.split(/\s+/)
.filter(word =>
word.length > 3 && // Filter out words shorter than 4 characters
!CombinedStopWords.has(word.toLowerCase()) // Filter out generic words
// Check page title match
if (titleWords.length > 0) {
const credNameWords = cred.ServiceName.toLowerCase()
.split(/\s+/)
.filter(word => word.length > 3 && !CombinedStopWords.has(word));
hasTitleMatch = titleWords.some(word =>
credNameWords.some(credWord => credWord.includes(word))
);
}
filtered = credentials.filter(cred =>
titleWords.some(word =>
cred.ServiceName.toLowerCase().includes(word)
)
);
}
// Ensure we have unique credentials
const uniqueCredentials = Array.from(new Map(filtered.map(cred => [cred.Id, cred])).values());
// Assign priority based on matches
if (hasBaseUrlMatch && hasTitleMatch) {
filtered.push({ ...cred, priority: 2 });
} else if (hasBaseUrlMatch) {
filtered.push({ ...cred, priority: 3 });
} else if (hasTitleMatch) {
filtered.push({ ...cred, priority: 4 });
}
});
// Sort by priority and then take unique credentials
const uniqueCredentials = Array.from(
new Map(filtered
.sort((a, b) => a.priority - b.priority)
.map(cred => [cred.Id, cred]))
.values()
);
// Show max 3 results
return uniqueCredentials.slice(0, 3);
}

View File

@@ -29,6 +29,34 @@ export function hidePopupFor(ms: number) : void {
popupDebounceTime = Date.now() + ms;
}
/**
* Validates if an element is a supported input field that can be processed for autofill.
* @param element The element to validate
* @returns An object containing validation result and the element cast as HTMLInputElement if valid
*/
export function validateInputField(element: Element | null): { isValid: boolean; inputElement?: HTMLInputElement } {
if (!element) {
return { isValid: false };
}
const textInputTypes = ['text', 'email', 'tel', 'password', 'search', 'url', 'number'];
const elementType = element.getAttribute('type');
const isInputElement = element.tagName.toLowerCase() === 'input';
// Check if it's a valid input field we should process
const isValid = (
// Case 1: It's an input element (with either explicit type or defaulting to "text")
(isInputElement && (!elementType || textInputTypes.includes(elementType?.toLowerCase() ?? ''))) ||
// Case 2: Non-input element but has valid type attribute
(!isInputElement && elementType && textInputTypes.includes(elementType.toLowerCase()))
) as boolean;
return {
isValid,
inputElement: isValid ? (element as HTMLInputElement) : undefined
};
}
/**
* Fill credential into current form.
*
@@ -36,8 +64,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();
@@ -51,10 +79,44 @@ export function fillCredential(credential: Credential, input: HTMLInputElement)
formFiller.fillFields(credential);
}
/**
* Find the actual visible input element, either the element itself or a child input.
* Certain websites use custom input element wrappers that not only contain the input but
* also other elements like labels, icons, etc. As we want to position the icon relative to the actual
* input, we try to find the actual input element. If there is no actual input element, we fallback
* to the provided element.
*
* This method is optional, but it improves the AliasVault icon positioning on certain websites.
*
* @param element - The element to check.
* @returns The actual input element to use for positioning.
*/
function findActualInput(element: HTMLElement): HTMLInputElement {
// If it's already an input, return it
if (element.tagName.toLowerCase() === 'input') {
return element as HTMLInputElement;
}
// Try to find a visible child input
const childInput = element.querySelector('input');
if (childInput) {
const style = window.getComputedStyle(childInput);
if (style.display !== 'none' && style.visibility !== 'hidden') {
return childInput;
}
}
// Fallback to the provided element if no child input found
return element as HTMLInputElement;
}
/**
* Inject icon for a focused input element
*/
export function injectIcon(input: HTMLInputElement, container: HTMLElement): void {
// Find the actual input element to use for positioning
const actualInput = findActualInput(input);
const aliasvaultIconSvg = `<?xml version="1.0" encoding="UTF-8"?>
<svg enable-background="new 0 0 500 500" version="1.1" viewBox="0 0 500 500" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<path d="m459.87 294.95c0.016205 5.4005 0.03241 10.801-0.35022 16.873-1.111 6.3392-1.1941 12.173-2.6351 17.649-10.922 41.508-36.731 69.481-77.351 83.408-7.2157 2.4739-14.972 3.3702-22.479 4.995-23.629 0.042205-47.257 0.11453-70.886 0.12027-46.762 0.011322-93.523-0.01416-140.95-0.43411-8.59-2.0024-16.766-2.8352-24.398-5.3326-21.595-7.0666-39.523-19.656-53.708-37.552-10.227-12.903-17.579-27.17-21.28-43.221-1.475-6.3967-2.4711-12.904-3.6852-19.361-0.051849-5.747-0.1037-11.494 0.26915-17.886 4.159-42.973 27.68-71.638 63.562-92.153 0-0.70761-0.001961-1.6988 3.12e-4 -2.69 0.022484-9.8293-1.3071-19.894 0.35664-29.438 3.2391-18.579 11.08-35.272 23.763-49.773 12.098-13.832 26.457-23.989 43.609-30.029 7.813-2.7512 16.14-4.0417 24.234-5.9948 7.392-0.025734 14.784-0.05146 22.835 0.32253 4.1959 0.95392 7.7946 1.2538 11.258 2.1053 17.16 4.2192 32.287 12.176 45.469 24.104 2.2558 2.0411 4.372 6.6241 9.621 3.868 16.839-8.8419 34.718-11.597 53.603-8.594 16.791 2.6699 31.602 9.4308 44.236 20.636 11.531 10.227 19.84 22.841 25.393 37.236 6.3436 16.445 10.389 33.163 6.0798 49.389 7.9587 8.9321 15.807 16.704 22.421 25.414 9.162 12.065 15.33 25.746 18.144 40.776 0.97046 5.1848 1.9111 10.375 2.8654 15.563m-71.597 71.012c5.5615-5.2284 12.002-9.7986 16.508-15.817 10.474-13.992 14.333-29.916 11.288-47.446-2.2496-12.95-8.1973-24.076-17.243-33.063-12.746-12.663-28.865-18.614-46.786-18.569-69.912 0.17712-139.82 0.56831-209.74 0.96176-15.922 0.089599-29.168 7.4209-39.685 18.296-14.45 14.944-20.408 33.343-16.655 54.368 2.2763 12.754 8.2167 23.748 17.158 32.66 13.299 13.255 30.097 18.653 48.728 18.651 59.321-0.005188 118.64 0.042358 177.96-0.046601 9.5912-0.014374 19.181-0.86588 28.773-0.88855 10.649-0.025146 19.978-3.825 29.687-9.1074z" fill="#EEC170"/>
@@ -71,8 +133,8 @@ export function injectIcon(input: HTMLInputElement, container: HTMLElement): voi
`;
// Generate unique ID if input doesn't have one
if (!input.id) {
input.id = `aliasvault-input-${Math.random().toString(36).substring(2, 11)}`;
if (!actualInput.id) {
actualInput.id = `aliasvault-input-${Math.random().toString(36).substring(2, 11)}`;
}
// Create an overlay container at document level if it doesn't exist
@@ -88,19 +150,26 @@ export function injectIcon(input: HTMLInputElement, container: HTMLElement): voi
const iconContainer = document.createElement('div');
iconContainer.innerHTML = ICON_HTML;
const icon = iconContainer.firstElementChild as HTMLElement;
icon.setAttribute('data-icon-for', input.id);
icon.setAttribute('data-icon-for', actualInput.id);
// Enable pointer events just for the icon
icon.style.pointerEvents = 'auto';
/**
* Update position of the icon.
* Positions icon relative to right edge, moving it left by any existing padding.
*/
const updateIconPosition = () : void => {
const rect = input.getBoundingClientRect();
const rect = actualInput.getBoundingClientRect();
const computedStyle = window.getComputedStyle(actualInput);
const paddingRight = parseInt(computedStyle.paddingLeft + computedStyle.paddingRight);
// Default offset is 32px, add any padding to move it further left
const rightOffset = 24 + paddingRight;
icon.style.position = 'fixed';
icon.style.top = `${rect.top + (rect.height - 24) / 2}px`;
icon.style.left = `${rect.right - 32}px`;
icon.style.left = `${(rect.left + rect.width) - rightOffset}px`;
};
// Update position initially and on relevant events
@@ -112,8 +181,8 @@ export function injectIcon(input: HTMLInputElement, container: HTMLElement): voi
icon.addEventListener('click', (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setTimeout(() => input.focus(), 0);
openAutofillPopup(input, container);
setTimeout(() => actualInput.focus(), 0);
openAutofillPopup(actualInput, container);
});
// Append the icon to the overlay container
@@ -131,8 +200,8 @@ export function injectIcon(input: HTMLInputElement, container: HTMLElement): voi
icon.style.opacity = '0';
setTimeout(() => {
icon.remove();
input.removeEventListener('blur', handleBlur);
input.removeEventListener('keydown', handleKeyPress);
actualInput.removeEventListener('blur', handleBlur);
actualInput.removeEventListener('keydown', handleKeyPress);
window.removeEventListener('scroll', updateIconPosition, true);
window.removeEventListener('resize', updateIconPosition);
@@ -153,8 +222,8 @@ export function injectIcon(input: HTMLInputElement, container: HTMLElement): voi
}
};
input.addEventListener('blur', handleBlur);
input.addEventListener('keydown', handleKeyPress);
actualInput.addEventListener('blur', handleBlur);
actualInput.addEventListener('keydown', handleKeyPress);
}
/**

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
/* AliasVault Content Script Styles */
body {
position: absolute;
margin: 0;
padding: 0;
}
/* Base Popup Styles */
@@ -26,16 +28,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 {
@@ -105,6 +113,20 @@ body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.av-suggested-names {
margin-top: 4px;
font-size: 12px;
color: #acacac;
}
.av-suggested-name {
color: #bababa;
cursor: pointer;
text-decoration: underline;
}
.av-suggested-name:hover {
color: #d68338;
}
.av-service-details {
font-size: 0.85em;
white-space: nowrap;
@@ -302,7 +324,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 +335,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 +426,137 @@ 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-top: 16px;
}
.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,
.av-create-popup-visibility-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,
.av-create-popup-visibility-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-visibility-btn .av-icon {
width: 18px;
height: 18px;
stroke: currentColor;
stroke-width: 1.5;
fill: none;
}
.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 +633,158 @@ 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-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.
*/
@@ -22,6 +21,28 @@ const CredentialsList: React.FC = () => {
const navigate = useNavigate();
const { showLoading, hideLoading, setIsInitialLoading } = useLoading();
/**
* Get the display text for a credential, showing username by default,
* falling back to email only if username is null/undefined
*/
const getCredentialDisplayText = (cred: Credential): string => {
const username = cred.Username ?? '';
// Show username if available.
if (username.length > 0) {
return username;
}
// Show email if username is not available.
const email = cred.Alias?.Email ?? '';
if (email.length > 0) {
return email;
}
// Show empty string if neither username nor email is available.
return '';
};
/**
* Loading state with minimum duration for more fluid UX.
*/
@@ -107,11 +128,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 +185,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) => {
@@ -173,7 +195,9 @@ const CredentialsList: React.FC = () => {
/>
<div className="text-left">
<p className="font-medium text-gray-900 dark:text-white">{cred.ServiceName}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">{cred.Username}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{getCredentialDisplayText(cred)}
</p>
</div>
</button>
</li>

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

View File

@@ -4,6 +4,11 @@ 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.
*/
@@ -164,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,
@@ -215,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,
@@ -263,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;
}
/**
@@ -281,6 +284,13 @@ 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.
*/
@@ -380,7 +390,7 @@ class SqliteClient {
const credentialId = crypto.randomUUID().toUpperCase();
this.executeUpdate(credentialQuery, [
credentialId,
credential.Username,
credential.Username ?? null,
credential.Notes ?? null,
serviceId,
aliasId,
@@ -488,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'],
@@ -86,7 +86,7 @@ export const EnglishStopWords = new Set([
// Marketing/Promotional
'free', 'create', 'new', 'your', 'special', 'offer',
'deal', 'discount', 'promotion',
'deal', 'discount', 'promotion', 'newsletter',
// Common website sections
'help', 'support', 'contact', 'about', 'faq', 'terms',
@@ -95,14 +95,17 @@ export const EnglishStopWords = new Set([
// Generic descriptors
'online', 'web', 'digital', 'mobile', 'my', 'personal',
'private', 'general', 'default', 'standard',
'private', 'general', 'default', 'standard', 'website',
// System/Technical
'system', 'admin', 'administrator', 'platform', 'portal',
'gateway', 'api', 'interface', 'console',
// Time-related
'today', 'now', 'current', 'latest', 'newest', 'recent'
'today', 'now', 'current', 'latest', 'newest', 'recent',
// General
'the', 'and', 'or', 'but', 'to', 'up'
]);
/**
@@ -174,7 +177,10 @@ export const DutchStopWords = new Set([
'interface', 'console',
// Time-related
'vandaag', 'nu', 'huidig', 'recent', 'nieuwste'
'vandaag', 'nu', 'huidig', 'recent', 'nieuwste',
// General
'je', 'in', 'op', 'de', 'van', 'ons', 'allemaal'
]);
/**

View File

@@ -1,5 +1,5 @@
import { FormFields } from "./types/FormFields";
import { CombinedFieldPatterns, CombinedGenderOptionPatterns } from "./FieldPatterns";
import { CombinedFieldPatterns, CombinedGenderOptionPatterns, CombinedStopWords } from "./FieldPatterns";
/**
* Form detector.
@@ -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,36 @@ export class FormDetector {
public constructor(document: Document, clickedElement?: HTMLElement) {
this.document = document;
this.clickedElement = clickedElement ?? null;
this.visibilityCache = new Map();
}
/**
* 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 {
let formWrapper = this.clickedElement?.closest('form, [role="dialog"]') as HTMLElement | null;
if (formWrapper?.getAttribute('role') === 'dialog') {
// If we hit a dialog, search for form only within the dialog
formWrapper = formWrapper.querySelector('form') as HTMLElement | null ?? formWrapper;
}
/**
* 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;
}
if (!formWrapper) {
// If no form or dialog found, fallback to document.body
formWrapper = this.document.body as HTMLElement;
}
// Check if the wrapper contains a password or likely username field before processing.
if (this.containsPasswordField(formWrapper) || this.containsLikelyUsernameOrEmailField(formWrapper, force)) {
return true;
}
/**
* 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)) {
return true;
}
return false;
@@ -45,8 +52,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) {
@@ -57,6 +62,185 @@ export class FormDetector {
return this.detectFormFields(formWrapper);
}
/**
* Get suggested service names from the page title and URL.
* Returns an array with two suggestions: the primary name and the domain name as an alternative.
*/
public static getSuggestedServiceName(document: Document, location: Location): string[] {
const title = document.title;
const maxWords = 4;
const maxLength = 50;
/**
* We apply a limit to the length and word count of the title to prevent
* the service name from being too long or containing too many words which
* is not likely to be a good service name.
*/
const validLength = (text: string): boolean => {
const validLength = text.length >= 3 && text.length <= maxLength;
const validWordCount = text.split(/[\s|\-—/\\]+/).length <= maxWords;
return validLength && validWordCount;
};
/**
* Filter out common words from prefix/suffix until no more matches found
*/
const getMeaningfulTitleParts = (title: string): string[] => {
const words = title.toLowerCase().split(' ').map(word => word.toLowerCase());
// Strip stopwords from start until no more matches
let startIndex = 0;
while (startIndex < words.length && CombinedStopWords.has(words[startIndex].toLowerCase())) {
startIndex++;
}
// Strip stopwords from end until no more matches
let endIndex = words.length - 1;
while (endIndex > startIndex && CombinedStopWords.has(words[endIndex].toLowerCase())) {
endIndex--;
}
// Return remaining words
return words.slice(startIndex, endIndex + 1);
};
/**
* Get original case version of meaningful words
*/
const getOriginalCase = (text: string, meaningfulParts: string[]): string => {
return text
.split(/[\s|]+/)
.filter(word => meaningfulParts.includes(word.toLowerCase()))
.join(' ');
};
// Domain name suggestion (always included as fallback or first suggestion)
const domainSuggestion = location.hostname.replace(/^www\./, '');
// First try to extract meaningful parts based on the divider
const dividerRegex = /[|\-—/\\:]/;
const dividerMatch = dividerRegex.exec(title);
if (dividerMatch) {
const dividerIndex = dividerMatch.index;
const beforeDivider = title.substring(0, dividerIndex).trim();
const afterDivider = title.substring(dividerIndex + 1).trim();
// Count meaningful words on each side
const beforeWords = getMeaningfulTitleParts(beforeDivider);
const afterWords = getMeaningfulTitleParts(afterDivider);
// Get both parts in original case
const beforePart = getOriginalCase(beforeDivider, beforeWords);
const afterPart = getOriginalCase(afterDivider, afterWords);
// Check if both parts are valid
const beforeValid = validLength(beforePart);
const afterValid = validLength(afterPart);
// If both parts are valid, return both as suggestions
if (beforeValid && afterValid) {
return [beforePart, afterPart, domainSuggestion];
}
// If only one part is valid, return it
if (beforeValid) {
return [beforePart, domainSuggestion];
}
if (afterValid) {
return [afterPart, domainSuggestion];
}
}
// If no meaningful parts found after divider, try the full title
const meaningfulParts = getMeaningfulTitleParts(title);
const serviceName = getOriginalCase(title, meaningfulParts);
if (validLength(serviceName)) {
return [serviceName, domainSuggestion];
}
// Fall back to domain name
return [domainSuggestion];
}
/**
* 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;
}
/**
* Find an input field based on common patterns in its attributes.
*/
@@ -80,12 +264,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,
@@ -101,13 +295,29 @@ export class FormDetector {
}
}
// Check for sibling elements with class containing "label"
const parent = input.parentElement;
if (parent) {
const siblings = Array.from(parent.children);
for (const sibling of siblings) {
if (sibling !== input && Array.from(sibling.classList).some(c => c.toLowerCase().includes('label'))) {
attributes.push(sibling.textContent?.toLowerCase() ?? '');
}
}
}
// 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 +375,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 +550,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 +563,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 +573,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 +635,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

@@ -34,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);
}
@@ -85,13 +98,14 @@ export class FormFiller {
private async fillPasswordField(field: HTMLInputElement, password: string): Promise<void> {
// Clear the field first
field.value = '';
this.triggerInputEvents(field, false);
this.triggerInputEvents(field, true);
// Type each character with a small delay
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));
}
@@ -103,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,19 @@ 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);
});
describe('English login form 2 detection', () => {
const htmlFile = 'en-login-form2.html';
testField(FormField.Email, 'account_name_text_field', 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

@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import { createTestDocument } from './TestUtils';
import { FormDetector } from '../FormDetector';
describe('FormDetector.getSuggestedServiceName (English)', () => {
it('should extract service name from title with divider and include domain', () => {
const { document, location } = createTestDocument(
'Welcome to MyBank - Online Banking Platform For You',
'https://www.mybank.com'
);
const suggestions = FormDetector.getSuggestedServiceName(document, location);
expect(suggestions).toEqual(['MyBank', 'Banking Platform For You', 'mybank.com']);
});
it('should extract service name from title without divider and include domain', () => {
const { document, location } = createTestDocument(
'GitHub: Let\'s build from here',
'https://github.com'
);
const suggestions = FormDetector.getSuggestedServiceName(document, location);
expect(suggestions).toEqual(['GitHub', 'Let\'s build from here', 'github.com']);
});
it('should handle titles with multiple meaningful words and include domain', () => {
const { document, location } = createTestDocument(
'Amazon Shopping Cart',
'https://www.amazon.com'
);
const suggestions = FormDetector.getSuggestedServiceName(document, location);
expect(suggestions).toEqual(['Amazon Shopping', 'amazon.com']);
});
it('should return only domain name when title has no meaningful words', () => {
const { document, location } = createTestDocument(
'Home | Welcome',
'https://www.example.com'
);
const suggestions = FormDetector.getSuggestedServiceName(document, location);
expect(suggestions).toEqual(['example.com']);
});
it('should handle titles with special characters and include domain', () => {
const { document, location } = createTestDocument(
'Netflix - Watch TV Shows Online, Watch Movies Online',
'https://www.netflix.com'
);
const suggestions = FormDetector.getSuggestedServiceName(document, location);
expect(suggestions).toEqual(['Netflix', 'netflix.com']);
});
it('should handle titles with multiple dividers and include domain', () => {
const { document, location } = createTestDocument(
'Twitter / X - Social Media Platform',
'https://twitter.com'
);
const suggestions = FormDetector.getSuggestedServiceName(document, location);
expect(suggestions).toEqual(['Twitter', 'X - Social Media', 'twitter.com']);
});
it('should handle empty titles by returning only domain', () => {
const { document, location } = createTestDocument(
'',
'https://www.example.com'
);
const suggestions = FormDetector.getSuggestedServiceName(document, location);
expect(suggestions).toEqual(['example.com']);
});
it('should handle titles with only stop words by returning only domain', () => {
const { document, location } = createTestDocument(
'The and or but',
'https://www.example.com'
);
const suggestions = FormDetector.getSuggestedServiceName(document, location);
expect(suggestions).toEqual(['example.com']);
});
});

View File

@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import { createTestDocument } from './TestUtils';
import { FormDetector } from '../FormDetector';
describe('FormDetector.getSuggestedServiceName (Dutch)', () => {
it('should extract service name from title with divider and include domain', () => {
const { document, location } = createTestDocument(
'ING - Online Bankieren',
'https://www.ing.nl'
);
const suggestions = FormDetector.getSuggestedServiceName(document, location);
expect(suggestions).toEqual(['ING', 'Bankieren', 'ing.nl']);
});
it('should extract service name from title without divider and include domain', () => {
const { document, location } = createTestDocument(
'Bol.com | De winkel van ons allemaal',
'https://www.bol.com'
);
const suggestions = FormDetector.getSuggestedServiceName(document, location);
expect(suggestions).toEqual(['Bol.com', 'bol.com']);
});
it('should handle titles with multiple meaningful words and include domain', () => {
const { document, location } = createTestDocument(
'Albert Heijn Online Boodschappen',
'https://www.ah.nl'
);
const suggestions = FormDetector.getSuggestedServiceName(document, location);
expect(suggestions).toEqual(['Albert Heijn Online Boodschappen', 'ah.nl']);
});
it('should return only domain name when title has no meaningful words', () => {
const { document, location } = createTestDocument(
'Home | Welkom',
'https://www.voorbeeld.nl'
);
const suggestions = FormDetector.getSuggestedServiceName(document, location);
expect(suggestions).toEqual(['voorbeeld.nl']);
});
it('should handle titles with special characters and include domain', () => {
const { document, location } = createTestDocument(
'NS - Nederlandse Spoorwegen',
'https://www.ns.nl'
);
const suggestions = FormDetector.getSuggestedServiceName(document, location);
expect(suggestions).toEqual(['Nederlandse Spoorwegen', 'ns.nl']);
});
it('should handle titles with multiple dividers and include domain', () => {
const { document, location } = createTestDocument(
'KPN / Internet & TV',
'https://www.kpn.nl'
);
const suggestions = FormDetector.getSuggestedServiceName(document, location);
expect(suggestions).toEqual(['KPN', 'Internet & TV', 'kpn.nl']);
});
it('should handle empty titles by returning only domain', () => {
const { document, location } = createTestDocument(
'',
'https://www.voorbeeld.nl'
);
const suggestions = FormDetector.getSuggestedServiceName(document, location);
expect(suggestions).toEqual(['voorbeeld.nl']);
});
it('should handle titles with only Dutch stop words by returning only domain', () => {
const { document, location } = createTestDocument(
'Je in op de',
'https://www.voorbeeld.nl'
);
const suggestions = FormDetector.getSuggestedServiceName(document, location);
expect(suggestions).toEqual(['voorbeeld.nl']);
});
});

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

@@ -40,6 +40,34 @@ export const createTestDom = (htmlFile: string) : JSDOM => {
});
};
/**
* Creates a test document with the specified title and URL.
* This is used for testing service name extraction.
*/
export const createTestDocument = (title: string, url: string) : { document: Document, location: Location } => {
const dom = createTestDom('empty.html');
const document = dom.window.document;
// Set the title
document.title = title;
// Create a proper Location object
const location = {
href: url,
origin: new URL(url).origin,
protocol: new URL(url).protocol,
host: new URL(url).host,
hostname: new URL(url).hostname,
port: new URL(url).port,
pathname: new URL(url).pathname,
search: new URL(url).search,
hash: new URL(url).hash,
ancestorOrigins: {} as DOMStringList,
} as Location;
return { document, location };
};
/**
* Helper function to test field detection
*/
@@ -55,27 +83,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 +114,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 +207,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

@@ -0,0 +1,12 @@
<!--
Apple ID login form where the label is a sibling element of the input field without proper label[for] attribute.
The element here should be detected as an email field.
-->
<div>
<div class=" form-cell-wrapper form-textbox ">
<input type="text" id="account_name_text_field" can-field="accountName" aria-labelledby="apple_id_field_label" autocorrect="off" autocapitalize="off" aria-required="true" required="required" spellcheck="false" ($focus)="appleIdFocusHandler($element)" ($blur)="appleIdBlurHandler()" class="force-ltr form-textbox-input form-textbox-entered " autocomplete="false" aria-invalid="false">
<span aria-hidden="true" id="apple_id_field_label" class=" form-textbox-label form-label-flyout">
Email or Phone Number
</span>
</div>
</div>

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

@@ -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,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,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.15.0",
version: "0.16.2",
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.4-x86_64-linux-gnu)
nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.4-x86_64-linux-musl)
nokogiri (1.18.8-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

@@ -105,9 +105,11 @@ The following websites have been known to cause issues in the past (but should b
| Website | Reason |
| --- | --- |
| https://www.paprika-shopping.nl/nieuwsbrief/newsletter-register-landing.html | Popup CSS style conflicts |
| 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 |
| [Paprika Shopping](https://www.paprika-shopping.nl/nieuwsbrief/newsletter-register-landing.html) | Popup CSS style conflicts |
| [Bloshing](https://bloshing.com/inschrijven-nieuwsbrief) | Popup CSS style conflicts |
| [GameFAQs](https://gamefaqs.gamespot.com/user) | Popup buttons not working |
| [Hacker News](https://news.ycombinator.com/login?goto=news) | Popup and client favicon not showing due to SVG format |
| [Bitwarden](https://vault.bitwarden.com/#/login) | Autofill password not detected (input not long enough), manually typing in works |
| [Microsoft Online](https://login.microsoftonline.com/) | Password gets reset after autofill |
| [ING Bank](https://mijn.ing.nl/login/) | Autofill doesn't detect input fields and AliasVault autofill icon placement is off |
| [GitHub Issues](https://github.com/lanedirt/AliasVault/issues) | The "New issue -> Blank Issue" title field causes the autofill to trigger because of a parent form (outside of the role=modal div) |

View File

@@ -33,17 +33,7 @@ This guide will help you set up AliasVault for development on Linux or MacOS sys
cd AliasVault
```
2. **Copy pre-commit hook script**
{: .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.
```bash
# Copy the commit-msg hook script
cp .github/hooks/commit-msg .git/hooks/commit-msg
chmod +x .git/hooks/commit-msg
```
3. **Install dotnet CLI EF Tools**
2. **Install dotnet CLI EF Tools**
```bash
# Install dotnet EF tools globally
dotnet tool install --global dotnet-ef
@@ -56,12 +46,12 @@ This guide will help you set up AliasVault for development on Linux or MacOS sys
dotnet ef
```
4. **Install dev database**
3. **Install dev database**
```bash
./install.sh configure-dev-db
```
5. **Run Tailwind CSS compiler**
4. **Run Tailwind CSS compiler**
```bash
# For Admin project
cd src/AliasVault.Admin
@@ -72,7 +62,7 @@ This guide will help you set up AliasVault for development on Linux or MacOS sys
npm run build:client-css
```
6. **Install Playwright for E2E tests**
5. **Install Playwright for E2E tests**
```bash
# Install Playwright CLI
dotnet tool install --global Microsoft.Playwright.CLI
@@ -81,7 +71,7 @@ This guide will help you set up AliasVault for development on Linux or MacOS sys
pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net9.0/playwright.ps1 install
```
7. **Configure Development Settings**
6. **Configure Development Settings**
Create `wwwroot/appsettings.Development.json` in the Client project:
```json
{

View File

@@ -39,18 +39,7 @@ This guide will help you set up AliasVault for development on Windows using WSL
git clone https://github.com/lanedirt/AliasVault.git
cd AliasVault
```
2. **Copy pre-commit hook script**
{: .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.
```bash
# Copy the commit-msg hook script
cp .github/hooks/commit-msg .git/hooks/commit-msg
chmod +x .git/hooks/commit-msg
```
3. **Configure WSL**
2. **Configure WSL**
- Open WSL terminal
- Edit WSL configuration:
```bash
@@ -72,7 +61,7 @@ This guide will help you set up AliasVault for development on Windows using WSL
wsl --shutdown
```
4. **Setup Development Database**
3. **Setup Development Database**
- Open a new WSL terminal in the AliasVault directory
- Run the development database setup:
```bash
@@ -84,7 +73,7 @@ This guide will help you set up AliasVault for development on Windows using WSL
docker ps | grep postgres-dev
```
5. **Run the Application**
4. **Run the Application**
- Open the solution in Visual Studio 2022
- Set WebApi as the startup project
- Press F5 to run in debug mode

View File

@@ -56,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.15.0
# @version 0.15.1
# Repository information used for downloading files and images from GitHub
REPO_OWNER="lanedirt"
@@ -294,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"
@@ -411,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
@@ -616,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"
}
@@ -815,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"
@@ -838,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; }
@@ -1331,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"
@@ -1413,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"
@@ -1506,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"
@@ -1558,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; }
@@ -2033,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

@@ -22,8 +22,8 @@
<ItemGroup>
<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">
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -13,7 +13,7 @@
<DataAnnotationsValidator/>
<div>
<label asp-for="Input.UserName" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your username</label>
<InputTextField id="username" @bind-Value="Input.UserName" placeholder="username" />
<InputTextField id="username" @bind-Value="Input.UserName" type="text" placeholder="username" />
<ValidationMessage For="() => Input.UserName"/>
</div>
<div>

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

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

@@ -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.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<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="8.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -49,11 +49,11 @@
<ItemGroup>
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
<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="Microsoft.AspNetCore.Authorization" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.4" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.4" />
<PackageReference Include="SpamOK.PasswordGenerator" Version="1.1.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
@@ -82,7 +82,7 @@
<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

@@ -82,13 +82,13 @@ else
</h2>
<FullScreenLoadingIndicator @ref="_loadingIndicator"/>
<ServerValidationErrors @ref="_serverValidationErrors"/>
<EditForm Model="_loginModel" OnValidSubmit="HandleLogin" class="mt-8 space-y-6">
<EditForm Model="_loginModel" OnValidSubmit="HandleLogin" class="mt-4 space-y-6">
<ServerValidationErrors @ref="_serverValidationErrors"/>
<DataAnnotationsValidator/>
<div>
<label asp-for="Input.Email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your username or email</label>
<InputTextField id="email" @bind-Value="_loginModel.Username" placeholder="name / name@company.com"/>
<InputTextField id="email" @bind-Value="_loginModel.Username" type="text" placeholder="name / name@company.com"/>
<ValidationMessage For="() => _loginModel.Username"/>
</div>
<div>
@@ -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

@@ -16,7 +16,7 @@
<DataAnnotationsValidator/>
<div>
<label asp-for="Input.Email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your username or email</label>
<InputTextField id="email" @bind-Value="_registerModel.Username" placeholder="name / name@company.com" />
<InputTextField id="email" @bind-Value="_registerModel.Username" type="text" placeholder="name / name@company.com" />
<ValidationMessage For="() => _registerModel.Username"/>
</div>
<div>
@@ -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);

View File

@@ -17,7 +17,7 @@
}
else if (IsWebAuthnLoading) {
<BoldLoadingIndicator />
<p class="text-center font-normal text-gray-500 dark:text-gray-400">
<p class="mt-6 text-center font-normal text-gray-500 dark:text-gray-400">
Logging in with WebAuthn...
</p>
}
@@ -114,7 +114,7 @@ else
/// </summary>
private async Task UnlockSubmit()
{
_loadingIndicator.Show();
_loadingIndicator.Show("Unlocking vault...");
_serverValidationErrors.Clear();
try
@@ -159,9 +159,10 @@ else
await AuthService.StoreEncryptionKeyAsync(passwordHash);
// 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

View File

@@ -80,7 +80,7 @@
}
</td>
<td class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400">
<span class="cursor-pointer" @onclick="() => OpenEmail(mail.Id)">@mail.DateSystem</span>
<span class="cursor-pointer" @onclick="() => OpenEmail(mail.Id)">@mail.DateSystem.ToString("yyyy-MM-dd")</span>
</td>
</tr>
}
@@ -125,7 +125,7 @@
public void OnVisibilityChange(bool isVisible)
{
_isPageVisible = isVisible;
if (isVisible && DbService.Settings.AutoEmailRefresh)
{
// Start polling if visible and auto-refresh is enabled
@@ -136,7 +136,7 @@
// Stop polling if hidden
StopPolling();
}
// If becoming visible, do an immediate refresh
if (isVisible)
{
@@ -150,13 +150,13 @@
if (_pollingCts != null) {
return;
}
_pollingCts = new CancellationTokenSource();
// Start polling task
_ = PollForEmails(_pollingCts.Token);
}
private void StopPolling()
{
if (_pollingCts != null)
@@ -166,7 +166,7 @@
_pollingCts = null;
}
}
private async Task PollForEmails(CancellationToken cancellationToken)
{
try
@@ -198,8 +198,8 @@
}
// Check if email has a known SpamOK domain, if not, don't show this component.
ShowComponent = IsSpamOkDomain(EmailAddress) || IsAliasVaultDomain(EmailAddress);
IsSpamOk = IsSpamOkDomain(EmailAddress);
ShowComponent = EmailService.IsAliasVaultSupportedDomain(EmailAddress);
IsSpamOk = EmailService.IsSpamOkDomain(EmailAddress);
// Create a single object reference for JS interop
_dotNetRef = DotNetObjectReference.Create(this);
@@ -217,7 +217,7 @@
{
// Stop polling
StopPolling();
// Unregister the visibility callback using the same reference
if (_dotNetRef != null)
{
@@ -252,23 +252,7 @@
return;
}
IsSpamOk = IsSpamOkDomain(EmailAddress);
}
/// <summary>
/// Returns true if the email address is from a known SpamOK domain.
/// </summary>
private bool IsSpamOkDomain(string email)
{
return Config.PublicEmailDomains.Exists(x => email.EndsWith(x));
}
/// <summary>
/// Returns true if the email address is from a known AliasVault domain.
/// </summary>
private bool IsAliasVaultDomain(string email)
{
return Config.PrivateEmailDomains.Exists(x => email.EndsWith(x));
IsSpamOk = EmailService.IsSpamOkDomain(EmailAddress);
}
/// <summary>
@@ -299,11 +283,11 @@
// Get email prefix, which is the part before the @ symbol.
string emailPrefix = EmailAddress.Split('@')[0];
if (IsSpamOkDomain(EmailAddress))
if (EmailService.IsSpamOkDomain(EmailAddress))
{
await LoadSpamOkEmails(emailPrefix);
}
else if (IsAliasVaultDomain(EmailAddress))
else if (EmailService.IsAliasVaultDomain(EmailAddress))
{
await LoadAliasVaultEmails();
}
@@ -324,11 +308,11 @@
// Get email prefix, which is the part before the @ symbol.
string emailPrefix = EmailAddress.Split('@')[0];
if (IsSpamOkDomain(EmailAddress))
if (EmailService.IsSpamOkDomain(EmailAddress))
{
await ShowSpamOkEmailInModal(emailPrefix, emailId);
}
else if (IsAliasVaultDomain(EmailAddress))
else if (EmailService.IsAliasVaultDomain(EmailAddress))
{
await ShowAliasVaultEmailInModal(emailId);
}

View File

@@ -1,4 +1,47 @@
<svg class="mx-auto animate-spin h-12 w-12 text-primary-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<div class="aliasvault-spinner mx-auto">
<div class="cloud-shape-inverted">
<div class="dot-inverted delay-1"></div>
<div class="dot-inverted delay-2"></div>
<div class="dot-inverted delay-3"></div>
<div class="dot-inverted delay-4"></div>
</div>
</div>
<style>
.aliasvault-spinner {
display: flex;
justify-content: center;
align-items: center;
height: 51px;
width: 112px;
}
.cloud-shape-inverted {
border: 6px solid #eabf69;
border-radius: 9999px;
padding: 13px 26px;
display: flex;
gap: 10px;
align-items: center;
background-color: transparent;
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
}
.dot-inverted {
width: 10px;
height: 10px;
border-radius: 9999px;
background-color: #eabf69;
animation: pulse-inverted 1.4s infinite ease-in-out;
}
.delay-1 { animation-delay: 0s; }
.delay-2 { animation-delay: 0.2s; }
.delay-3 { animation-delay: 0.4s; }
.delay-4 { animation-delay: 0.6s; }
@@keyframes pulse-inverted {
0%, 100% { opacity: 0.3; transform: scale(1); }
50% { opacity: 1; transform: scale(1.3); }
}
</style>

Before

Width:  |  Height:  |  Size: 412 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,25 +1,35 @@
@if (IsVisible)
{
<div class="loading fixed inset-0 w-full h-full z-50 bg-gray-200 !m-0 !p-0 dark:bg-gray-500" style="z-index: 2147483641 !important;">
<div class="spinner">
<div class="rect1 bg-gray-900 dark:bg-white"></div>
<div class="rect2 bg-gray-900 dark:bg-white"></div>
<div class="rect3 bg-gray-900 dark:bg-white"></div>
<div class="rect4 bg-gray-900 dark:bg-white"></div>
<div class="rect5 bg-gray-900 dark:bg-white"></div>
<div class="aliasvault-fullscreen-spinner mx-auto">
<div class="cloud-shape-inverted">
<div class="dot-inverted delay-1"></div>
<div class="dot-inverted delay-2"></div>
<div class="dot-inverted delay-3"></div>
<div class="dot-inverted delay-4"></div>
</div>
@if (!string.IsNullOrEmpty(LoadingMessage))
{
<div class="loading-message mt-4 text-center text-gray-700 dark:text-gray-300">
@LoadingMessage
</div>
}
</div>
</div>
}
@code {
private bool IsVisible { get; set; }
private string LoadingMessage { get; set; } = string.Empty;
/// <summary>
/// Shows the loading indicator.
/// </summary>
public void Show()
/// <param name="message">Optional message to display below the loading spinner.</param>
public void Show(string? message = null)
{
IsVisible = true;
LoadingMessage = message ?? string.Empty;
StateHasChanged();
}
@@ -29,6 +39,77 @@
public void Hide()
{
IsVisible = false;
LoadingMessage = string.Empty;
StateHasChanged();
}
}
<style>
.loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.9);
}
.dark .loading {
background-color: rgba(107, 114, 128, 0.9);
}
.aliasvault-fullscreen-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 160px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.aliasvault-spinner {
display: flex;
justify-content: center;
align-items: center;
height: 51px;
width: auto;
}
.cloud-shape-inverted {
border: 6px solid #eabf69;
border-radius: 9999px;
padding: 13px 26px;
display: flex;
gap: 10px;
align-items: center;
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
}
.dot-inverted {
width: 10px;
height: 10px;
border-radius: 9999px;
background-color: #eabf69;
animation: pulse-inverted 1.4s infinite ease-in-out;
}
.delay-1 { animation-delay: 0s; }
.delay-2 { animation-delay: 0.2s; }
.delay-3 { animation-delay: 0.4s; }
.delay-4 { animation-delay: 0.6s; }
@@keyframes pulse-inverted {
0%, 100% { opacity: 0.3; transform: scale(1); }
50% { opacity: 1; transform: scale(1.3); }
}
.loading-message {
font-size: 0.875rem;
line-height: 1.25rem;
max-width: 300px;
word-wrap: break-word;
}
</style>

View File

@@ -1,73 +0,0 @@
.loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 0.9;
}
.spinner {
position: absolute;
top: 50%;
left: 50%;
margin-top: -20px;
margin-left: -25px;
width: 50px;
height: 40px;
text-align: center;
font-size: 10px;
}
.spinner>div {
z-index: 999;
height: 100%;
width: 6px;
display: inline-block;
-webkit-animation: sk-stretchdelay 1.2s infinite ease-in-out;
animation: sk-stretchdelay 1.2s infinite ease-in-out;
}
.spinner .rect2 {
-webkit-animation-delay: -1.1s;
animation-delay: -1.1s;
}
.spinner .rect3 {
-webkit-animation-delay: -1.0s;
animation-delay: -1.0s;
}
.spinner .rect4 {
-webkit-animation-delay: -0.9s;
animation-delay: -0.9s;
}
.spinner .rect5 {
-webkit-animation-delay: -0.8s;
animation-delay: -0.8s;
}
@-webkit-keyframes sk-stretchdelay {
0%,
40%,
100% {
-webkit-transform: scaleY(0.4)
}
20% {
-webkit-transform: scaleY(1.0)
}
}
@keyframes sk-stretchdelay {
0%,
40%,
100% {
transform: scaleY(0.4);
-webkit-transform: scaleY(0.4);
}
20% {
transform: scaleY(1.0);
-webkit-transform: scaleY(1.0);
}
}

View File

@@ -1,7 +1,49 @@
<div role="status" class="px-4 mt-4">
<svg aria-hidden="true" class="inline w-10 h-10 text-gray-200 animate-spin dark:text-gray-600 fill-primary-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
</svg>
<span class="sr-only">Loading...</span>
<div class="aliasvault-spinner-inline">
<div class="cloud-shape-inline-enhanced">
<div class="dot-inline delay-1"></div>
<div class="dot-inline delay-2"></div>
<div class="dot-inline delay-3"></div>
<div class="dot-inline delay-4"></div>
</div>
</div>
<span class="sr-only">Loading...</span>
</div>
<style>
.aliasvault-spinner-inline {
display: inline-flex;
justify-content: center;
align-items: center;
height: 32px;
width: auto;
}
.cloud-shape-inline-enhanced {
background-color: #eabf69;
border-radius: 9999px;
padding: 8px 20px;
display: flex;
gap: 10px;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.dot-inline {
width: 8px;
height: 8px;
border-radius: 9999px;
background-color: #ffffff;
animation: pulse-inline 1.4s infinite ease-in-out;
}
.delay-1 { animation-delay: 0s; }
.delay-2 { animation-delay: 0.2s; }
.delay-3 { animation-delay: 0.4s; }
.delay-4 { animation-delay: 0.6s; }
@@keyframes pulse-inline {
0%, 100% { opacity: 0.3; transform: scale(1); }
50% { opacity: 1; transform: scale(1.3); }
}
</style>

View File

@@ -140,62 +140,21 @@
/// </summary>
private async Task AddTotpCode()
{
string secretKey = NewTotpCode.SecretKey;
// Sanitize the secret key (remove whitespace and hyphens)
secretKey = secretKey.Replace(" ", string.Empty).Replace("-", string.Empty);
string? name = NewTotpCode.Name;
// Check if the input is a TOTP URI
if (secretKey.StartsWith("otpauth://totp/"))
{
try
{
var uri = new Uri(secretKey);
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
// Extract the secret from query parameters
secretKey = queryParams["secret"] ?? throw new ArgumentException("Secret not found in URI");
// If no name was provided, try to get it from the URI
if (string.IsNullOrWhiteSpace(name))
{
// The label is everything after 'totp/' and before '?'
var label = uri.AbsolutePath.TrimStart('/');
// If the label contains ':', take the part after it
name = label.Contains(':') ? label.Split(':')[1] : label;
// If there's an issuer in the query params, use it as a prefix
var issuer = queryParams["issuer"];
if (!string.IsNullOrWhiteSpace(issuer))
{
name = $"{issuer}: {name}";
}
NewTotpCode.Name = name;
}
NewTotpCode.SecretKey = secretKey;
}
catch (Exception)
{
GlobalNotificationService.AddErrorMessage("Invalid TOTP URI format. Please check and try again.", true);
return;
}
// Sanitize the secret key by converting from URI to secret key and name.
try {
var (secretKey, name) = TotpHelper.SanitizeSecretKey(NewTotpCode.SecretKey, NewTotpCode.Name);
NewTotpCode.SecretKey = secretKey;
NewTotpCode.Name = name;
}
try
catch (Exception ex)
{
// Validate the secret key by trying to generate a code
TotpGenerator.GenerateTotpCode(secretKey);
}
catch (Exception)
{
GlobalNotificationService.AddErrorMessage("Invalid secret key. Please check and try again.", true);
GlobalNotificationService.AddErrorMessage(ex.Message, true);
return;
}
// Create a new TOTP code in memory
var newTotpCode = NewTotpCode.ToEntity();
newTotpCode.Name = name ?? "Authenticator";
newTotpCode.Name = NewTotpCode.Name ?? "Authenticator";
// Add to the list
TotpCodeList.Add(newTotpCode);

View File

@@ -146,7 +146,7 @@
}
IsCreating = true;
GlobalLoadingSpinner.Show();
GlobalLoadingSpinner.Show("Creating new alias...");
StateHasChanged();
var credential = new Credential();

View File

@@ -4,68 +4,80 @@
@inject JsInteropService JsInteropService
@implements IAsyncDisposable
<div class="relative" id="searchWidgetContainer">
<input
id="searchWidget"
type="text"
placeholder="Search vault..."
autocomplete="off"
class="w-full px-4 py-2 text-gray-700 bg-white border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:focus:ring-primary-500"
@bind-value="SearchTerm"
@oninput="SearchTermChanged"
@onfocus="OnFocus"
@onblur="OnBlur"
@onkeydown="HandleKeyDown"/>
<ClickOutsideHandler OnClose="OnClose" ContentId="searchWidgetContainer">
<div class="relative" id="searchWidgetContainer">
<input
id="searchWidget"
type="text"
placeholder="Search vault..."
autocomplete="off"
class="w-full px-4 py-2 text-gray-700 bg-white border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:focus:ring-primary-500"
@bind-value="SearchTerm"
@oninput="SearchTermChanged"
@onfocus="OnFocus"
@onkeydown="HandleKeyDown"/>
@if (ShowHelpText)
{
<div class="absolute z-10 w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 p-2 text-sm text-gray-600 dark:text-gray-400">
@if (string.IsNullOrEmpty(SearchTerm))
{
<p>Type a term to search for, this can be the service name, description or email address.</p>
}
else if (SearchTerm.Length == 1)
{
<p>Please type more chars</p>
}
else
{
<p>Searching for "@SearchTerm"</p>
}
</div>
}
@if (ShowResults)
{
@if (SearchResults.Any())
@if (ShowHelpText)
{
<div class="absolute z-10 w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
@for (int i = 0; i < SearchResults.Count; i++)
<div class="absolute z-10 w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 p-2 text-sm text-gray-600 dark:text-gray-400">
@if (string.IsNullOrEmpty(SearchTerm))
{
var result = SearchResults[i];
<div
class="search-result @(i == SelectedIndex ? "bg-gray-100 dark:bg-gray-700" : "") px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
@onclick="() => SelectResult(result)">
@result.Service.Name <span class="text-gray-500">(@result.Alias.Email)</span>
</div>
<p>Type a term to search for, this can be the service name, description or email address.</p>
}
else if (SearchTerm.Length == 1)
{
<p>Please type more chars</p>
}
else
{
<p>Searching for "@SearchTerm"</p>
}
</div>
}
else
@if (ShowResults && SearchTerm.Length >= 2)
{
<div class="absolute z-10 w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
<div class="px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
No results found
@if (SearchResults.Any())
{
<div class="absolute z-10 w-screen left-0 sm:left-auto sm:w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
@for (int i = 0; i < SearchResults.Count; i++)
{
var result = SearchResults[i];
<div
class="search-result @(i == SelectedIndex ? "bg-gray-100 dark:bg-gray-700" : "") px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center"
@onclick="() => SelectResult(result)">
<DisplayFavicon FaviconBytes="@result.Service.Logo" Width="24" />
<div class="ml-2">
<div>@result.Service.Name</div>
@if (!string.IsNullOrEmpty(result.Alias.Email))
{
<span class="text-gray-500">(@result.Alias.Email)</span>
}
else if (!string.IsNullOrEmpty(result.Username))
{
<span class="text-gray-500">(@result.Username)</span>
}
</div>
</div>
}
</div>
</div>
}
else
{
<div class="absolute z-10 w-screen left-0 sm:left-auto sm:w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
<div class="px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
No results found
</div>
</div>
}
}
}
</div>
</div>
</ClickOutsideHandler>
@code {
private string SearchTerm { get; set; } = string.Empty;
private List<Credential> SearchResults { get; set; } = new();
private bool ShowResults => SearchTerm.Length >= 2;
private bool ShowResults { get; set; }
private bool ShowHelpText { get; set; }
private int SelectedIndex { get; set; } = -1;
@@ -91,11 +103,13 @@
private void OnFocus()
{
ShowHelpText = true;
ShowResults = true;
}
private void OnBlur()
private void OnClose()
{
ShowHelpText = false;
ShowResults = false;
}
private async Task SearchTermChanged(ChangeEventArgs e)
@@ -122,7 +136,8 @@
query = query.Where(x =>
(x.Service.Name != null && EF.Functions.Like(x.Service.Name.ToLower(), $"%{term}%")) ||
(x.Alias.Email != null && EF.Functions.Like(x.Alias.Email.ToLower(), $"%{term}%")) ||
(x.Username != null && EF.Functions.Like(x.Username.ToLower(), $"%{term}%"))
(x.Username != null && EF.Functions.Like(x.Username.ToLower(), $"%{term}%")) ||
(x.Service.Url != null && EF.Functions.Like(x.Service.Url.ToLower(), $"%{term}%"))
);
}
@@ -170,6 +185,7 @@
SearchTerm = string.Empty;
SearchResults.Clear();
StateHasChanged();
OnClose();
}
private async Task FocusSearchField()

View File

@@ -48,13 +48,12 @@
{
if (GlobalLoadingService.IsLoading)
{
LoadingIndicator.Show();
LoadingIndicator.Show(GlobalLoadingService.LoadingMessage);
}
else
{
LoadingIndicator.Hide();
}
StateHasChanged();
}
}

View File

@@ -68,8 +68,8 @@
</NavLink>
</li>
<li>
<NavLink href="/settings/vault" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
Vault settings
<NavLink href="/settings/import-export" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
Import / Export
</NavLink>
</li>
<li class="border-t border-b border-gray-100 dark:border-gray-600">

View File

@@ -59,9 +59,9 @@ public sealed class CredentialEdit
public Alias Alias { get; set; } = null!;
/// <summary>
/// Gets or sets the Alias BirthDate.
/// Gets or sets the Alias BirthDate. Can be empty string or a date in yyyy-MM-dd format.
/// </summary>
[StringDateFormat("yyyy-MM-dd")]
[StringDateFormat("yyyy-MM-dd", AllowEmpty = true)]
public string AliasBirthDate { get; set; } = string.Empty;
/// <summary>
@@ -122,7 +122,7 @@ public sealed class CredentialEdit
UpdatedAt = DateTime.UtcNow,
},
Alias = credentialCopy.Alias,
AliasBirthDate = credentialCopy.Alias.BirthDate.ToString("yyyy-MM-dd"),
AliasBirthDate = credentialCopy.Alias.BirthDate == DateTime.MinValue ? string.Empty : credentialCopy.Alias.BirthDate.ToString("yyyy-MM-dd"),
Attachments = credentialCopy.Attachments.ToList(),
TotpCodes = credentialCopy.TotpCodes.ToList(),
CreateDate = credentialCopy.CreatedAt,
@@ -147,10 +147,10 @@ public sealed class CredentialEdit
Url = ServiceUrl,
Logo = ServiceLogo,
},
Passwords = new List<Password>
{
Passwords =
[
Password,
},
],
Alias = Alias,
Attachments = Attachments,
TotpCodes = TotpCodes,

View File

@@ -19,6 +19,11 @@ using System.Globalization;
/// <param name="format">The date format to validate.</param>
public sealed class StringDateFormatAttribute(string format) : ValidationAttribute
{
/// <summary>
/// Gets or sets a value indicating whether empty strings should be considered valid.
/// </summary>
public bool AllowEmpty { get; set; } = false;
/// <summary>
/// Check if the date string is in the correct format.
/// </summary>
@@ -27,9 +32,17 @@ public sealed class StringDateFormatAttribute(string format) : ValidationAttribu
/// <returns>ValidationResult.</returns>
protected override ValidationResult IsValid(object? value, ValidationContext validationContext)
{
if (value is string dateString && DateTime.TryParseExact(dateString, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
if (value is string dateString)
{
return ValidationResult.Success!;
if (string.IsNullOrWhiteSpace(dateString) && AllowEmpty)
{
return ValidationResult.Success!;
}
if (DateTime.TryParseExact(dateString, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
{
return ValidationResult.Success!;
}
}
return new ValidationResult($"The date must be in the format {format}.", [validationContext.MemberName!]);

View File

@@ -13,10 +13,10 @@
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@(EditMode ? "Edit credentials" : "Add credentials")"
Description="@(EditMode ? "Edit the existing credentials entry below." : "Create a new credentials entry below.")">
Title="@(EditMode ? "Edit credential" : "Add credential")"
Description="@(EditMode ? "Edit the existing credential below." : "Create a new credential below.")">
<CustomActions>
<ConfirmButton OnClick="TriggerFormSubmit">Save Credentials</ConfirmButton>
<ConfirmButton OnClick="TriggerFormSubmit">Save Credential</ConfirmButton>
<CancelButton OnClick="Cancel">Cancel</CancelButton>
</CustomActions>
</PageHeader>
@@ -125,7 +125,7 @@ else
</div>
</div>
</div>
<button type="submit" class="hidden">Save Credentials</button>
<button type="submit" class="hidden">Save Credential</button>
</EditForm>
}
@@ -175,7 +175,7 @@ else
if (EditMode)
{
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "View credentials entry", Url = $"/credentials/{Id}" });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "View credential", Url = $"/credentials/{Id}" });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Edit credential" });
}
else
@@ -192,44 +192,14 @@ else
if (firstRender)
{
Module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/modules/newIdentityWidget.js");
if (EditMode)
{
if (Id is null)
{
// Error loading alias.
GlobalNotificationService.AddErrorMessage("This credential does not exist (anymore). Please try again.");
NavigationManager.NavigateTo("/", false, true);
return;
}
// Load existing Obj, retrieve from service
var alias = await CredentialService.LoadEntryAsync(Id.Value);
if (alias is null)
{
// Error loading alias.
GlobalNotificationService.AddErrorMessage("This credential does not exist (anymore). Please try again.");
NavigationManager.NavigateTo("/", false, true);
return;
}
Obj = CredentialEdit.FromEntity(alias);
if (Obj.ServiceUrl is null)
{
Obj.ServiceUrl = CredentialService.DefaultServiceUrl;
}
await LoadExistingCredential();
}
else
{
// Create new Obj
var alias = new Credential();
alias.Alias = new Alias();
alias.Alias.Email = "@" + CredentialService.GetDefaultEmailDomain();
alias.Service = new Service();
alias.Passwords = new List<Password> { new Password() };
alias.TotpCodes = new List<TotpCode>();
Obj = CredentialEdit.FromEntity(alias);
Obj.ServiceUrl = CredentialService.DefaultServiceUrl;
CreateNewCredential();
}
Loading = false;
@@ -243,6 +213,73 @@ else
}
}
/// <summary>
/// Loads an existing credential for editing.
/// </summary>
private async Task LoadExistingCredential()
{
if (Id is null)
{
NavigateAwayWithError("This credential does not exist (anymore). Please try again.");
return;
}
// Load existing Obj, retrieve from service
var alias = await CredentialService.LoadEntryAsync(Id.Value);
if (alias is null)
{
NavigateAwayWithError("This credential does not exist (anymore). Please try again.");
return;
}
Obj = CredentialEdit.FromEntity(alias);
// If BirthDate is MinValue, set AliasBirthDate to empty string
// TODO: after date field in alias data model is made optional and
// all min values have been replaced with null, we can remove this check.
if (Obj.Alias.BirthDate == DateTime.MinValue)
{
Obj.AliasBirthDate = string.Empty;
}
if (Obj.ServiceUrl is null)
{
Obj.ServiceUrl = CredentialService.DefaultServiceUrl;
}
}
/// <summary>
/// Creates a new credential object.
/// </summary>
private void CreateNewCredential()
{
// Create new Obj
var alias = new Credential();
alias.Alias = new Alias();
alias.Alias.Email = "@" + CredentialService.GetDefaultEmailDomain();
alias.Service = new Service();
alias.Passwords = new List<Password> { new Password() };
alias.TotpCodes = new List<TotpCode>();
Obj = CredentialEdit.FromEntity(alias);
// Always set AliasBirthDate to empty for new credentials
// TODO: after date field in alias data model is made optional and
// all min values have been replaced with null, we can remove this check.
Obj.AliasBirthDate = string.Empty;
Obj.ServiceUrl = CredentialService.DefaultServiceUrl;
}
/// <summary>
/// Adds an error message and navigates to the home page.
/// </summary>
private void NavigateAwayWithError(string errorMessage)
{
GlobalNotificationService.AddErrorMessage(errorMessage);
NavigationManager.NavigateTo("/credentials", false, true);
}
/// <summary>
/// When the URL input is focused, place cursor at the end of the default URL to allow for easy typing.
/// </summary>
@@ -277,8 +314,25 @@ else
GlobalLoadingSpinner.Show();
StateHasChanged();
Obj = CredentialEdit.FromEntity(await CredentialService.GenerateRandomIdentity(Obj.ToEntity()));
IsPasswordVisible = true;
if (EditMode)
{
// Store current username and password
string currentUsername = Obj.Username;
string currentPassword = Obj.Password.Value ?? string.Empty;
// Generate random identity but preserve username and password
Obj = CredentialEdit.FromEntity(await CredentialService.GenerateRandomIdentity(Obj.ToEntity()));
// Restore username and password
Obj.Username = currentUsername;
Obj.Password.Value = currentPassword;
}
else
{
// For new credentials, generate everything
Obj = CredentialEdit.FromEntity(await CredentialService.GenerateRandomIdentity(Obj.ToEntity()));
IsPasswordVisible = true;
}
GlobalLoadingSpinner.Hide();
StateHasChanged();
@@ -339,7 +393,7 @@ else
/// </summary>
private async Task SaveAlias()
{
GlobalLoadingSpinner.Show();
GlobalLoadingSpinner.Show("Saving vault...");
StateHasChanged();
if (EditMode)

View File

@@ -2,12 +2,12 @@
@inherits MainBase
@inject CredentialService CredentialService
<LayoutPageTitle>Delete credentials entry</LayoutPageTitle>
<LayoutPageTitle>Delete credential</LayoutPageTitle>
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="Delete credentials"
Description="You can delete a credentials entry below.">
Title="Delete credential"
Description="You can delete the credential below.">
</PageHeader>
@if (IsLoading)
@@ -51,7 +51,7 @@ else
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
BreadcrumbItems.Add(new BreadcrumbItem { Url = "credentials/" + Id, DisplayName = "View credentials entry" });
BreadcrumbItems.Add(new BreadcrumbItem { Url = "credentials/" + Id, DisplayName = "View credential" });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Delete credential" });
}
@@ -77,21 +77,21 @@ else
{
if (Obj is null)
{
GlobalNotificationService.AddErrorMessage("Error deleting. Credentials entry not found.", true);
GlobalNotificationService.AddErrorMessage("Error deleting. Credential not found.", true);
return;
}
GlobalLoadingSpinner.Show();
GlobalLoadingSpinner.Show("Deleting credential...");
if (await CredentialService.SoftDeleteEntryAsync(Id))
{
GlobalNotificationService.AddSuccessMessage("Credentials entry successfully deleted.");
GlobalNotificationService.AddSuccessMessage("Credential successfully deleted.");
}
else {
GlobalNotificationService.AddErrorMessage("Error saving database.", true);
}
GlobalLoadingSpinner.Hide();
NavigationManager.NavigateTo("/");
NavigationManager.NavigateTo("/credentials");
}
private void Cancel()

View File

@@ -65,7 +65,25 @@ else
<div class="credential-card col-span-full p-4 space-y-2 bg-amber-50 border border-primary-500 rounded-lg shadow-sm dark:border-primary-700 dark:bg-gray-800">
<div class="px-4 py-6 text-gray-700 dark:text-gray-200 rounded text-center flex flex-col items-center">
<p class="mb-2 text-lg font-semibold text-primary-700 dark:text-primary-400">No credentials yet</p>
<p class="text-sm mb-4">Create your first credential using the <span class="hidden md:inline">"+ New Alias"</span><span class="md:hidden">"+"</span> button in the top right corner.</p>
<div class="max-w-md mx-auto">
<div class="mb-6">
<p class="text-sm mb-2">Create your first credential using the <span class="hidden md:inline">"+ New Alias"</span><span class="md:hidden">"+"</span> button in the top right corner.</p>
</div>
<div class="flex items-center my-6">
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600"></div>
<span class="px-4 text-sm text-gray-500 dark:text-gray-400">or</span>
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600"></div>
</div>
<div>
<p class="text-sm mb-2">If you previously used a different password manager, you can import your credentials from it.</p>
<a href="/settings/import-export" class="inline-block text-sm px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors dark:bg-primary-700 dark:hover:bg-primary-600">
Import from KeePass, Bitwarden, Chrome, Firefox...
</a>
</div>
</div>
</div>
</div>
}
@@ -143,7 +161,7 @@ else
}
/// <summary>
/// Loads and/or refreshesthe credentials.
/// Loads and/or refreshes the credentials.
/// </summary>
private async Task LoadCredentialsAsync()
{
@@ -158,9 +176,10 @@ else
GlobalNotificationService.AddErrorMessage("Failed to load credentials.", true);
return;
}
if (credentialListEntries.Count == 0 && !DbService.Settings.TutorialDone)
{
// Redirect to welcome page.
// Redirect to the welcome page.
NavigationManager.NavigateTo("/welcome");
}

View File

@@ -13,16 +13,16 @@ else
{
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="View credentials entry">
Title="View credential">
<CustomActions>
<LinkButton
Text="Edit"
AdditionalText="credentials entry"
AdditionalText="credential"
Href="@($"/credentials/{Id}/edit")"
Color="primary" />
<LinkButton
Text="Delete"
AdditionalText="credentials entry"
AdditionalText="credential"
Href="@($"/credentials/{Id}/delete")"
Color="danger" />
</CustomActions>
@@ -38,7 +38,12 @@ else
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">@Alias.Service.Name</h3>
@if (Alias.Service.Url is not null && Alias.Service.Url.Length > 0)
{
<a href="@Alias.Service.Url" target="_blank" class="text-blue-500 break-all dark:text-blue-400">@Alias.Service.Url</a>
var url = Alias.Service.Url;
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
{
url = "https://" + url;
}
<a href="@url" target="_blank" class="text-blue-500 break-all dark:text-blue-400">@Alias.Service.Url</a>
}
</div>
</div>
@@ -64,13 +69,23 @@ else
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-2 text-xl font-semibold dark:text-white">Login credentials</h3>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Use the generated credentials below to create your account. Any emails sent to the shown address will automatically appear on this page.
@if (EmailService.IsAliasVaultSupportedDomain(Alias.Alias.Email ?? string.Empty))
{
<span>Below you can view and copy the generated credentials for this account. Any emails sent to the shown address will automatically appear on this page.</span>
}
else
{
<span>Below you can view and copy the stored login credentials for this account.</span>
}
</p>
<form action="#">
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Id="email" Label="Email" Value="@Alias.Alias.Email"></CopyPasteFormRow>
</div>
@if (!string.IsNullOrWhiteSpace(Alias.Alias.Email))
{
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Id="email" Label="Email" Value="@Alias.Alias.Email"></CopyPasteFormRow>
</div>
}
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Id="username" Label="Username" Value="@(Alias.Username)"></CopyPasteFormRow>
</div>
@@ -80,28 +95,46 @@ else
</div>
</form>
</div>
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Alias</h3>
<form action="#">
<div class="grid grid-cols-6 gap-6">
<div class="col-span-6">
<CopyPasteFormRow Label="Full name" Value="@(Alias.Alias.FirstName + " " + Alias.Alias.LastName)"></CopyPasteFormRow>
@if (HasAlias)
{
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Alias</h3>
<form action="#">
<div class="grid grid-cols-6 gap-6">
@if (!string.IsNullOrWhiteSpace(Alias.Alias.FirstName) && !string.IsNullOrWhiteSpace(Alias.Alias.LastName))
{
<div class="col-span-6">
<CopyPasteFormRow Label="Full name" Value="@(Alias.Alias.FirstName + " " + Alias.Alias.LastName)"></CopyPasteFormRow>
</div>
}
@if (!string.IsNullOrWhiteSpace(Alias.Alias.FirstName))
{
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="First name" Value="@(Alias.Alias.FirstName)"></CopyPasteFormRow>
</div>
}
@if (!string.IsNullOrWhiteSpace(Alias.Alias.LastName))
{
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="Last name" Value="@(Alias.Alias.LastName)"></CopyPasteFormRow>
</div>
}
@if (IsValidDate(Alias.Alias.BirthDate))
{
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="Birthdate" Value="@(Alias.Alias.BirthDate.ToString("yyyy-MM-dd"))"></CopyPasteFormRow>
</div>
}
@if (!string.IsNullOrWhiteSpace(Alias.Alias.NickName))
{
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="Nickname" Value="@(Alias.Alias.NickName)"></CopyPasteFormRow>
</div>
}
</div>
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="First name" Value="@(Alias.Alias.FirstName)"></CopyPasteFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="Last name" Value="@(Alias.Alias.LastName)"></CopyPasteFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="Birthdate" Value="@(Alias.Alias.BirthDate.ToString("yyyy-MM-dd"))"></CopyPasteFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="Nickname" Value="@(Alias.Alias.NickName)"></CopyPasteFormRow>
</div>
</div>
</form>
</div>
</form>
</div>
}
</div>
</div>
}
@@ -114,12 +147,46 @@ else
public Guid Id { get; set; }
private bool IsLoading { get; set; } = true;
private Credential? Alias { get; set; } = new();
private bool HasAlias { get; set; } = false;
/// <summary>
/// Checks if a date is valid and not a min value.
/// </summary>
/// <param name="date">The date to check.</param>
/// <returns>True if the date is valid and not a min value, false otherwise.</returns>
private static bool IsValidDate(DateTime date)
{
// Check if date is min value (year 1 or 0001-01-01)
if (date.Year <= 1 || date.ToString("yyyy-MM-dd") == "0001-01-01")
{
return false;
}
return true;
}
/// <summary>
/// Checks if the alias has any valid data.
/// </summary>
/// <param name="alias">The credential containing alias information.</param>
/// <returns>True if the alias has any valid data, false otherwise.</returns>
private static bool CheckHasAlias(Credential alias)
{
if (alias?.Alias == null)
{
return false;
}
return !string.IsNullOrWhiteSpace(alias.Alias.FirstName) ||
!string.IsNullOrWhiteSpace(alias.Alias.LastName) ||
!string.IsNullOrWhiteSpace(alias.Alias.NickName) ||
IsValidDate(alias.Alias.BirthDate);
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "View credentials entry" });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "View credential" });
}
/// <inheritdoc />
@@ -130,7 +197,7 @@ else
}
/// <summary>
/// Loads the credentials entry.
/// Loads the credential.
/// </summary>
private async Task LoadEntryAsync()
{
@@ -143,11 +210,14 @@ else
if (Alias is null)
{
// Error loading alias.
GlobalNotificationService.AddErrorMessage("This credentials entry does not exist (anymore). Please try again.");
GlobalNotificationService.AddErrorMessage("This credential does not exist (anymore). Please try again.");
NavigationManager.NavigateTo("/credentials", false, true);
return;
}
// Check if the alias has any valid data
HasAlias = CheckHasAlias(Alias);
IsLoading = false;
StateHasChanged();
}

View File

@@ -2,12 +2,15 @@
@inherits MainBase
@code {
private const string DefaultRedirectUri = "/credentials";
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Redirect to /credentials.
NavigationManager.NavigateTo("/credentials");
// Navigate to the default entry page.
Console.WriteLine("Navigating to default entry page");
NavigationManager.NavigateTo(DefaultRedirectUri);
}
}

View File

@@ -7,6 +7,7 @@
namespace AliasVault.Client.Main.Pages;
using AliasVault.Client.Auth.Pages.Base;
using AliasVault.Client.Services;
using AliasVault.Client.Services.Auth;
using AliasVault.RazorComponents.Models;
@@ -21,7 +22,6 @@ using Microsoft.AspNetCore.Components.Authorization;
/// </summary>
public abstract class MainBase : OwningComponentBase
{
private const string ReturnUrlKey = "returnUrl";
private bool _parametersInitialSet;
/// <summary>
@@ -117,11 +117,11 @@ public abstract class MainBase : OwningComponentBase
}
}
// Check if DB is initialized, if not, redirect to setup page.
// Check if DB is initialized, if not, redirect to sync page.
if (!DbService.GetState().CurrentState.IsInitialized())
{
var currentUrl = NavigationManager.Uri;
await LocalStorage.SetItemAsync(ReturnUrlKey, currentUrl);
var currentRelativeUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
await LocalStorage.SetItemAsync(LoginBase.ReturnUrlKey, currentRelativeUrl);
NavigationManager.NavigateTo("/sync");
while (true)
@@ -148,8 +148,8 @@ public abstract class MainBase : OwningComponentBase
// Check if DB is initialized, if not, redirect to setup page.
if (!DbService.GetState().CurrentState.IsInitialized())
{
var currentUrl = NavigationManager.Uri;
await LocalStorage.SetItemAsync(ReturnUrlKey, currentUrl);
var currentRelativeUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
await LocalStorage.SetItemAsync(LoginBase.ReturnUrlKey, currentRelativeUrl);
NavigationManager.NavigateTo("/sync");
while (true)
@@ -203,13 +203,14 @@ public abstract class MainBase : OwningComponentBase
if (!AuthService.IsEncryptionKeySet())
{
// If returnUrl is not set and current URL is not unlock page, set it to the current URL.
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>(ReturnUrlKey);
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>(LoginBase.ReturnUrlKey);
if (string.IsNullOrEmpty(localStorageReturnUrl))
{
var currentUrl = NavigationManager.Uri;
if (!currentUrl.Contains("unlock"))
{
await LocalStorage.SetItemAsync(ReturnUrlKey, currentUrl);
var currentRelativeUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
await LocalStorage.SetItemAsync(LoginBase.ReturnUrlKey, currentRelativeUrl);
}
}

View File

@@ -29,7 +29,7 @@
{
<div class="mb-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Recommended for Your Browser</h3>
<div class="p-4 border rounded-lg dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20 border-blue-200">
<div class="p-4 border rounded-lg dark:border-amber-500/50 bg-amber-50 dark:bg-amber-800/30 border-amber-400">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex items-center">
<img src="@CurrentBrowserExtension?.IconPath" alt="@CurrentBrowserExtension?.Name" class="w-8 h-8 mr-3">

View File

@@ -0,0 +1,21 @@
@using AliasVault.ImportExport.Models
@using AliasVault.ImportExport.Importers
@inject NavigationManager NavigationManager
@inject GlobalNotificationService GlobalNotificationService
@inject ILogger<ImportService1Password> Logger
<ImportServiceCard
ServiceName="1Password"
Description="Import passwords from your 1Password vault"
LogoUrl="img/importers/1password.svg"
ProcessFileCallback="ProcessFile">
<p class="text-gray-700 dark:text-gray-300 mb-4">In order to import your 1Password vault, you need to export it as a CSV file. You can do this by logging into your 1Password account in the 1Password 8 desktop app (Windows / MacOS / Linux), going to the 'File' menu and selecting 'Export' (to CSV).</p>
<p class="text-gray-700 dark:text-gray-300 mb-4">Once you have exported the file, you can upload it below.</p>
</ImportServiceCard>
@code {
private static async Task<List<ImportedCredential>> ProcessFile(string fileContents)
{
return await OnePasswordImporter.ImportFromCsvAsync(fileContents);
}
}

View File

@@ -0,0 +1,25 @@
@inject ILogger<ImportServiceAliasVault> Logger
@inject NavigationManager NavigationManager
@inject GlobalNotificationService GlobalNotificationService
@using AliasVault.ImportExport.Models
@using AliasVault.ImportExport.Importers
<ImportServiceCard
ServiceName="AliasVault"
Description="Import passwords from another AliasVault instance or manual back-up"
LogoUrl="img/logo.svg"
ProcessFileCallback="ProcessFile">
<p class="text-gray-700 dark:text-gray-300 mb-4">If you have a CSV file back-up of your AliasVault database (from a different AliasVault instance), you can import it here.</p>
</ImportServiceCard>
@code {
private static async Task<List<ImportedCredential>> ProcessFile(string fileContents)
{
var importedCredentials = await Task.Run(() =>
{
return AliasVault.ImportExport.CredentialCsvService.ImportCredentialsFromCsv(fileContents);
});
return importedCredentials;
}
}

View File

@@ -0,0 +1,21 @@
@using AliasVault.ImportExport.Models
@using AliasVault.ImportExport.Importers
@inject NavigationManager NavigationManager
@inject GlobalNotificationService GlobalNotificationService
@inject ILogger<ImportServiceBitwarden> Logger
<ImportServiceCard
ServiceName="Bitwarden"
Description="Import passwords from your Bitwarden vault"
LogoUrl="img/importers/bitwarden.svg"
ProcessFileCallback="ProcessFile">
<p class="text-gray-700 dark:text-gray-300 mb-4">In order to import your Bitwarden vault, you need to export it as a CSV file. You can do this by logging into your Bitwarden account, going to the 'Tools' menu and selecting 'Export vault' (to CSV).</p>
<p class="text-gray-700 dark:text-gray-300 mb-4">Once you have exported the file, you can upload it below.</p>
</ImportServiceCard>
@code {
private static async Task<List<ImportedCredential>> ProcessFile(string fileContents)
{
return await BitwardenImporter.ImportFromCsvAsync(fileContents);
}
}

View File

@@ -0,0 +1,570 @@
@inject ILogger<ImportServiceCard> Logger
@inject CredentialService CredentialService
@inject DbService DbService
@inject NavigationManager NavigationManager
@inject GlobalNotificationService GlobalNotificationService
@inject HttpClient HttpClient
@using AliasVault.ImportExport.Importers
@using AliasVault.ImportExport.Models
@using AliasVault.Shared.Models.WebApi.Favicon
<div @onclick="OpenImportModal" class="flex flex-col p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
<div class="flex items-center">
<div class="w-12 h-12 mr-3 flex-shrink-0">
@if (!string.IsNullOrEmpty(LogoUrl))
{
<img src="@LogoUrl" alt="@ServiceName logo" class="w-full h-full object-contain" />
}
else
{
<div class="w-full h-full bg-gray-200 dark:bg-gray-700 rounded-md flex items-center justify-center">
<span class="text-gray-500 dark:text-gray-400 text-xs">No logo</span>
</div>
}
</div>
<div>
<h4 class="text-lg font-semibold dark:text-white">@ServiceName</h4>
@if (!string.IsNullOrEmpty(Description))
{
<p class="text-sm text-gray-500 dark:text-gray-400">@Description</p>
}
</div>
</div>
</div>
@if (IsModalOpen)
{
<ClickOutsideHandler OnClose="CloseModal" ContentId="importServiceModal">
<ModalWrapper OnEnter="HandleModalConfirm">
<div id="importServiceModal" class="relative top-20 mx-auto p-5 shadow-lg rounded-md bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-400 md:min-w-[32rem]">
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 w-full mx-auto">
<div class="flex justify-between items-center mb-4">
<div class="flex"><img src="@LogoUrl" alt="@ServiceName logo" class="w-8 h-8 float-left mr-4" /><h3 class="text-xl font-semibold dark:text-white">Import from @ServiceName</h3></div>
<button @onclick="CloseModal" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 mb-4 dark:bg-gray-700">
<div class="bg-primary-600 h-2.5 rounded-full transition-all duration-300" style="width: @(GetProgressPercentage())%"></div>
</div>
@switch (CurrentStep)
{
case ImportStep.FileUpload:
<div class="max-w-lg mx-auto">
@if (!string.IsNullOrEmpty(ImportError))
{
<div class="mb-4 p-4 text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
@ImportError
</div>
}
@if (IsImporting)
{
<LoadingIndicator />
}
<div class="@(IsImporting ? "hidden" : "")">
@ChildContent
<div class="mb-4 bg-amber-50 border border-amber-400 dark:bg-amber-800/30 dark:border-amber-500/50 rounded-lg p-4">
<p class="mb-4 text-gray-700 dark:text-gray-200">Upload your @ServiceName export file:</p>
<InputFile OnChange="HandleFileUpload" class="text-gray-700 dark:text-gray-200 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900/40 dark:file:text-primary-300 dark:hover:file:bg-primary-800/60" />
</div>
<div class="flex justify-end mt-6 space-x-2">
<Button OnClick="@CloseModal" Color="secondary">Cancel</Button>
</div>
</div>
</div>
break;
case ImportStep.Preview:
<div class="mb-4">
@if (DuplicateCredentialsCount > 0)
{
<div class="p-4 mb-4 text-blue-700 bg-blue-100 rounded-lg dark:bg-blue-800/30 dark:text-blue-300" role="alert">
<p>@DuplicateCredentialsCount duplicate credential(s) were found and will not be imported.</p>
</div>
}
@if (ImportedCredentials.Count == 0)
{
<div class="p-4 mb-4 text-amber-700 bg-amber-100 rounded-lg dark:bg-amber-800/30 dark:text-amber-300" role="alert">
<p>No new credentials were found to import.</p>
</div>
}
else
{
<p class="mb-4 text-gray-700 dark:text-gray-300">Check if the following detected credentials look correct before continuing:</p>
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Service</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Username</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Password</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
@foreach (var credential in ImportedCredentials.Take(3))
{
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">@credential.ServiceName</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">@credential.Username</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">@(new string('*', credential.Password?.Length ?? 0))</td>
</tr>
}
</tbody>
</table>
@if (ImportedCredentials.Count > 3)
{
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">... and @(ImportedCredentials.Count - 3) more credentials</p>
}
}
</div>
@if (ImportedCredentials.Count > 0)
{
<div class="mb-4">
<label class="inline-flex items-center">
<input type="checkbox" @bind="ExtractFavicons" class="form-checkbox h-4 w-4 text-primary-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700">
<span class="ml-2 text-gray-700 dark:text-gray-300">Extract favicons for services with URLs</span>
</label>
</div>
}
<div class="flex justify-end mt-6 space-x-2">
<Button OnClick="@HandlePreviousStep" Color="secondary">Back</Button>
@if (ImportedCredentials.Count > 0)
{
<Button OnClick="@HandleNextStep" Color="primary">Next</Button>
}
</div>
break;
case ImportStep.Confirm:
<div class="max-w-lg mx-auto">
@if (IsImporting)
{
@if (IsExtractingFavicons)
{
<div class="text-center">
<LoadingIndicator />
<p class="mt-4 text-gray-700 dark:text-gray-300">Extracting favicons... @(FaviconExtractionProgress)/@(TotalFaviconsToExtract)</p>
<div class="w-full bg-gray-200 rounded-full h-2.5 mt-4 dark:bg-gray-700">
<div class="bg-primary-600 h-2.5 rounded-full transition-all duration-300" style="width: @(GetFaviconProgressPercentage())%"></div>
</div>
<div class="mt-4">
<Button OnClick="@CancelFaviconExtraction" Color="secondary">Cancel</Button>
</div>
</div>
}
else
{
<LoadingIndicator />
}
}
else {
<div class="mb-4">
<p class="mb-4 text-gray-700 dark:text-gray-300">Are you sure you want to import (@ImportedCredentials.Count) credentials? Note: the import process can take a short while.</p>
@if (ExtractFavicons)
{
<div class="p-4 mb-4 text-amber-700 bg-amber-100 rounded-lg dark:bg-amber-800/30 dark:text-amber-300" role="alert">
<p>Note: Favicon extraction is enabled. This process can take several minutes depending on the number of credentials with URLs. Please keep the page open.</p>
</div>
}
</div>
<div class="flex justify-end mt-6 space-x-2">
<Button OnClick="@HandlePreviousStep" Color="secondary">Back</Button>
<Button OnClick="@HandleModalConfirm" Color="primary">Import</Button>
</div>
}
</div>
break;
}
</div>
</div>
</ModalWrapper>
</ClickOutsideHandler>
}
@code {
private enum ImportStep
{
FileUpload,
Preview,
Confirm
}
/// <summary>
/// The name of the service.
/// </summary>
[Parameter]
public string ServiceName { get; set; } = string.Empty;
/// <summary>
/// The description of the service.
/// </summary>
[Parameter]
public string Description { get; set; } = string.Empty;
/// <summary>
/// The URL of the logo of the service.
/// </summary>
[Parameter]
public string LogoUrl { get; set; } = string.Empty;
/// <summary>
/// The event callback for when the import is confirmed.
/// </summary>
[Parameter]
public EventCallback OnImportConfirmed { get; set; }
/// <summary>
/// The callback for processing the file.
/// </summary>
[Parameter]
public Func<string, Task<List<ImportedCredential>>> ProcessFileCallback { get; set; } = null!;
private bool IsModalOpen { get; set; } = false;
private bool IsImporting { get; set; } = false;
private string? ImportError { get; set; }
private string? ImportSuccessMessage { get; set; }
private ImportStep CurrentStep { get; set; } = ImportStep.FileUpload;
/// <summary>
/// Child content which is shown in the modal popup. This can contain custom instructions.
/// </summary>
[Parameter]
public RenderFragment ChildContent { get; set; } = null!;
/// <summary>
/// The imported credentials.
/// </summary>
private List<ImportedCredential> ImportedCredentials { get; set; } = new();
private bool ExtractFavicons { get; set; } = true;
private bool IsExtractingFavicons { get; set; }
private int FaviconExtractionProgress { get; set; }
private int TotalFaviconsToExtract { get; set; }
private CancellationTokenSource? FaviconExtractionCancellation { get; set; }
private Dictionary<string, byte[]> ExtractedFavicons { get; set; } = new();
private int DuplicateCredentialsCount { get; set; } = 0;
/// <summary>
/// Sets the imported credentials and continues to the preview step.
/// </summary>
/// <param name="importedCredentials">The imported credentials.</param>
public async Task SetImportedCredentials(List<ImportedCredential> importedCredentials)
{
ImportedCredentials = importedCredentials;
// Continue to step 2.
await HandleNextStep();
}
/// <summary>
/// Called when a file is selected in the parent file upload step.
/// </summary>
public async Task FileSelected()
{
// If the file is selected, we can go to the preview step.
await HandleNextStep();
}
/// <summary>
/// Opens the import modal.
/// </summary>
protected virtual void OpenImportModal()
{
IsModalOpen = true;
CurrentStep = ImportStep.FileUpload;
StateHasChanged();
}
/// <summary>
/// Closes the import modal.
/// </summary>
protected virtual void CloseModal()
{
IsModalOpen = false;
CurrentStep = ImportStep.FileUpload;
ImportError = null;
ImportSuccessMessage = null;
ImportedCredentials.Clear();
StateHasChanged();
}
/// <summary>
/// Handles the next step in the import process.
/// </summary>
protected virtual async Task HandleNextStep()
{
if (CurrentStep == ImportStep.Preview)
{
CurrentStep = ImportStep.Confirm;
}
else if (CurrentStep == ImportStep.Confirm)
{
await HandleModalConfirm();
}
}
/// <summary>
/// Handles the previous step in the import process.
/// </summary>
protected virtual void HandlePreviousStep()
{
if (CurrentStep == ImportStep.Preview)
{
CurrentStep = ImportStep.FileUpload;
}
else if (CurrentStep == ImportStep.Confirm)
{
CurrentStep = ImportStep.Preview;
}
}
/// <summary>
/// Handles the modal confirm.
/// </summary>
protected virtual async Task HandleModalConfirm()
{
if (IsImporting)
{
return;
}
await InitializeImport();
try
{
if (ExtractFavicons)
{
await ExtractFaviconsForCredentials();
if (FaviconExtractionCancellation?.Token.IsCancellationRequested == true)
{
CleanupImport();
return;
}
}
await ImportCredentialsToDatabase();
}
catch (Exception ex)
{
ImportError = $"Error importing credentials: {ex.Message}";
}
finally
{
CleanupImport();
}
}
/// <summary>
/// Initializes the import.
/// </summary>
private async Task InitializeImport()
{
IsImporting = true;
ImportError = null;
ImportSuccessMessage = null;
StateHasChanged();
// Let UI update to start showing the loading indicator
await Task.Delay(50);
}
/// <summary>
/// Cleans up the import.
/// </summary>
private void CleanupImport()
{
IsImporting = false;
IsExtractingFavicons = false;
StateHasChanged();
}
/// <summary>
/// Extracts favicons for credentials.
/// </summary>
private async Task ExtractFaviconsForCredentials()
{
IsExtractingFavicons = true;
FaviconExtractionProgress = 0;
ExtractedFavicons.Clear();
FaviconExtractionCancellation = new CancellationTokenSource();
StateHasChanged();
var credentialsWithUrls = ImportedCredentials.Where(c => !string.IsNullOrEmpty(c.ServiceUrl)).ToList();
TotalFaviconsToExtract = credentialsWithUrls.Count;
foreach (var credential in credentialsWithUrls)
{
if (FaviconExtractionCancellation.Token.IsCancellationRequested)
{
break;
}
await ExtractFaviconForCredential(credential);
FaviconExtractionProgress++;
StateHasChanged();
}
}
/// <summary>
/// Extracts a favicon for a credential.
/// </summary>
/// <param name="credential">The credential to extract the favicon for.</param>
private async Task ExtractFaviconForCredential(ImportedCredential credential)
{
try
{
var apiReturn = await HttpClient.GetFromJsonAsync<FaviconExtractModel>($"v1/Favicon/Extract?url={credential.ServiceUrl!}");
if (apiReturn?.Image is not null)
{
ExtractedFavicons[credential.ServiceUrl!] = apiReturn.Image;
}
}
catch
{
// Ignore favicon extraction errors
}
}
/// <summary>
/// Imports the credentials to the database.
/// </summary>
private async Task ImportCredentialsToDatabase()
{
var credentials = BaseImporter.ConvertToCredential(ImportedCredentials);
foreach (var credential in credentials)
{
await ProcessSingleCredential(credential);
await Task.Delay(2); // Small delay to avoid blocking the UI thread
}
var success = await DbService.SaveDatabaseAsync();
if (success)
{
GlobalNotificationService.AddSuccessMessage($"Successfully imported {ImportedCredentials.Count} credentials.");
NavigationManager.NavigateTo("/credentials");
}
else
{
ImportError = "Error saving database.";
}
}
/// <summary>
/// Processes a single credential.
/// </summary>
/// <param name="credential">The credential to process.</param>
private async Task ProcessSingleCredential(Credential credential)
{
if (!string.IsNullOrEmpty(credential.Service.Url) && ExtractedFavicons.TryGetValue(credential.Service.Url, out var favicon))
{
credential.Service.Logo = favicon;
}
await CredentialService.InsertEntryAsync(credential, false, false);
}
/// <summary>
/// Handles the file upload.
/// </summary>
private async Task HandleFileUpload(InputFileChangeEventArgs e)
{
if (string.IsNullOrEmpty(e.File.Name))
{
ImportError = $"Please select a valid {ServiceName} export file to import";
return;
}
if (e.File.Name.EndsWith(".zip"))
{
ImportError = $"Please unzip the {ServiceName} export file before importing, please read the instructions below for more information.";
return;
}
try
{
IsImporting = true;
StateHasChanged();
// Limit file size to 10MB
if (e.File.Size > 10 * 1024 * 1024)
{
throw new ArgumentException("File size exceeds 10MB limit");
}
await using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
using var reader = new StreamReader(stream);
var fileContents = await reader.ReadToEndAsync();
var processingTask = ProcessFileCallback(fileContents);
var delayTask = Task.Delay(500);
await Task.WhenAll(processingTask, delayTask);
ImportedCredentials = await processingTask;
// Detect and remove duplicates before showing the preview
await DetectAndRemoveDuplicates();
CurrentStep = ImportStep.Preview;
}
catch (Exception ex)
{
ImportError = $"Error processing {ServiceName} export file. Please check the file format and try again.";
Logger.LogError(ex, "Error processing {ServiceName} export file", ServiceName);
}
finally
{
IsImporting = false;
StateHasChanged();
}
}
/// <summary>
/// Detects and removes duplicates from the import list.
/// </summary>
private async Task DetectAndRemoveDuplicates()
{
var existingCredentials = await CredentialService.LoadAllAsync();
var duplicates = ImportedCredentials.Where(imported =>
existingCredentials.Any(existing =>
existing.Service.Name != null && existing.Service.Name.Equals(imported.ServiceName, StringComparison.OrdinalIgnoreCase) &&
existing.Username != null && existing.Username.Equals(imported.Username, StringComparison.OrdinalIgnoreCase) &&
existing.Passwords.Any(p => p.Value != null && p.Value.Equals(imported.Password, StringComparison.OrdinalIgnoreCase))
)).ToList();
DuplicateCredentialsCount = duplicates.Count;
// Remove duplicates from the import list
ImportedCredentials = ImportedCredentials.Except(duplicates).ToList();
}
/// <summary>
/// Calculates the progress percentage based on the current step in the import process.
/// </summary>
/// <returns>The progress percentage as an integer.</returns>
private int GetProgressPercentage()
{
return (int)CurrentStep * 100 / (Enum.GetValues(typeof(ImportStep)).Length - 1);
}
private int GetFaviconProgressPercentage()
{
if (TotalFaviconsToExtract == 0) {
return 0;
}
return (FaviconExtractionProgress * 100) / TotalFaviconsToExtract;
}
private void CancelFaviconExtraction()
{
FaviconExtractionCancellation?.Cancel();
IsExtractingFavicons = false;
StateHasChanged();
}
}

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