Compare commits

...

433 Commits

Author SHA1 Message Date
Leendert de Borst
2b19d27902 Merge pull request #1164 from lanedirt/1163-prepare-0220-release
Prepare 0.22.0 release
2025-09-01 18:17:55 +02:00
Leendert de Borst
812302b9bc Merge branch '1163-prepare-0220-release' of https://github.com/lanedirt/AliasVault into 1163-prepare-0220-release
* '1163-prepare-0220-release' of https://github.com/lanedirt/AliasVault:
  Add 0.22.0 changelogs (#1163)
2025-09-01 17:55:30 +02:00
Leendert de Borst
4581dc8fd9 Bump browser extension safari build (#1163) 2025-09-01 17:55:01 +02:00
Leendert de Borst
42ba9d2869 Add 0.22.0 changelogs (#1163) 2025-09-01 17:54:51 +02:00
Leendert de Borst
773e6569c2 Add 0.22.0 changelogs (#1163) 2025-09-01 16:57:04 +02:00
Leendert de Borst
c24671ffb1 Bump version to 0.22.0 (#1163) 2025-09-01 16:56:53 +02:00
Leendert de Borst
cd87692588 Create funding.json 2025-09-01 15:17:18 +02:00
Leendert de Borst
15dc89ac07 New Crowdin updates (#1162)
* New translations general.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (Catalan)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Finnish)
Update translations from Crowdin [ci skip]

* New translations en.json (Italian)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Russian)
Update translations from Crowdin [ci skip]

* New translations en.json (Swedish)
Update translations from Crowdin [ci skip]

* New translations en.json (Turkish)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations en.json (Chinese Simplified)
Update translations from Crowdin [ci skip]

* Update source file en.json
Update translations from Crowdin [ci skip]

* New translations en.json (Italian)
Update translations from Crowdin [ci skip]

* New translations en.json (Finnish)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations en.json (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Chinese Simplified)
Update translations from Crowdin [ci skip]
2025-09-01 11:45:57 +02:00
Leendert de Borst
a95757e982 Tweak browser extension autofill popup UI 2025-08-31 21:04:08 +02:00
Leendert de Borst
6061511d3c Update en.json 2025-08-31 20:55:29 +02:00
Leendert de Borst
cc873fd483 New Crowdin updates (#1152)
* New translations vaultdecryptionprogress.en.resx (Turkish)
Update translations from Crowdin [ci skip]

* New translations vaultdecryptionprogress.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations sync.en.resx (Catalan)
Update translations from Crowdin [ci skip]

* New translations sync.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations sync.en.resx (Finnish)
Update translations from Crowdin [ci skip]

* New translations sync.en.resx (Italian)
Update translations from Crowdin [ci skip]

* New translations sync.en.resx (Russian)
Update translations from Crowdin [ci skip]

* New translations sync.en.resx (Swedish)
Update translations from Crowdin [ci skip]

* New translations sync.en.resx (Turkish)
Update translations from Crowdin [ci skip]

* New translations sync.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations welcome.en.resx (Catalan)
Update translations from Crowdin [ci skip]

* New translations welcome.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations welcome.en.resx (Finnish)
Update translations from Crowdin [ci skip]

* New translations welcome.en.resx (Italian)
Update translations from Crowdin [ci skip]

* New translations welcome.en.resx (Russian)
Update translations from Crowdin [ci skip]

* New translations welcome.en.resx (Swedish)
Update translations from Crowdin [ci skip]

* New translations welcome.en.resx (Turkish)
Update translations from Crowdin [ci skip]

* New translations welcome.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (Catalan)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (Finnish)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (Italian)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (Russian)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (Swedish)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (Turkish)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations changepassword.en.resx (Catalan)
Update translations from Crowdin [ci skip]

* New translations changepassword.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations changepassword.en.resx (Finnish)
Update translations from Crowdin [ci skip]

* New translations changepassword.en.resx (Italian)
Update translations from Crowdin [ci skip]

* New translations changepassword.en.resx (Russian)
Update translations from Crowdin [ci skip]

* New translations changepassword.en.resx (Swedish)
Update translations from Crowdin [ci skip]

* New translations changepassword.en.resx (Turkish)
Update translations from Crowdin [ci skip]

* New translations changepassword.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations deleteaccount.en.resx (Catalan)
Update translations from Crowdin [ci skip]

* New translations deleteaccount.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations deleteaccount.en.resx (Finnish)
Update translations from Crowdin [ci skip]

* New translations deleteaccount.en.resx (Italian)
Update translations from Crowdin [ci skip]

* New translations deleteaccount.en.resx (Russian)
Update translations from Crowdin [ci skip]

* New translations deleteaccount.en.resx (Swedish)
Update translations from Crowdin [ci skip]

* New translations deleteaccount.en.resx (Turkish)
Update translations from Crowdin [ci skip]

* New translations deleteaccount.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations enable2fa.en.resx (Catalan)
Update translations from Crowdin [ci skip]

* New translations enable2fa.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations enable2fa.en.resx (Finnish)
Update translations from Crowdin [ci skip]

* New translations enable2fa.en.resx (Italian)
Update translations from Crowdin [ci skip]

* New translations enable2fa.en.resx (Russian)
Update translations from Crowdin [ci skip]

* New translations enable2fa.en.resx (Swedish)
Update translations from Crowdin [ci skip]

* New translations enable2fa.en.resx (Turkish)
Update translations from Crowdin [ci skip]

* New translations enable2fa.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Catalan)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Finnish)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Italian)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Russian)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Swedish)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Turkish)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (Catalan)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Finnish)
Update translations from Crowdin [ci skip]

* New translations en.json (Italian)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Russian)
Update translations from Crowdin [ci skip]

* New translations en.json (Swedish)
Update translations from Crowdin [ci skip]

* New translations en.json (Turkish)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations en.json (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations resetvault.en.resx (Catalan)
Update translations from Crowdin [ci skip]

* New translations resetvault.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations resetvault.en.resx (Finnish)
Update translations from Crowdin [ci skip]

* New translations resetvault.en.resx (Italian)
Update translations from Crowdin [ci skip]

* New translations resetvault.en.resx (Russian)
Update translations from Crowdin [ci skip]

* New translations resetvault.en.resx (Swedish)
Update translations from Crowdin [ci skip]

* New translations resetvault.en.resx (Turkish)
Update translations from Crowdin [ci skip]

* New translations resetvault.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations resetvaultsection.en.resx (Catalan)
Update translations from Crowdin [ci skip]

* New translations resetvaultsection.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations resetvaultsection.en.resx (Finnish)
Update translations from Crowdin [ci skip]

* New translations resetvaultsection.en.resx (Italian)
Update translations from Crowdin [ci skip]

* New translations resetvaultsection.en.resx (Russian)
Update translations from Crowdin [ci skip]

* New translations resetvaultsection.en.resx (Swedish)
Update translations from Crowdin [ci skip]

* New translations resetvaultsection.en.resx (Turkish)
Update translations from Crowdin [ci skip]

* New translations resetvaultsection.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations disable2fa.en.resx (Catalan)
Update translations from Crowdin [ci skip]

* New translations disable2fa.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations disable2fa.en.resx (Finnish)
Update translations from Crowdin [ci skip]

* New translations disable2fa.en.resx (Italian)
Update translations from Crowdin [ci skip]

* New translations disable2fa.en.resx (Russian)
Update translations from Crowdin [ci skip]

* New translations disable2fa.en.resx (Swedish)
Update translations from Crowdin [ci skip]

* New translations disable2fa.en.resx (Turkish)
Update translations from Crowdin [ci skip]

* New translations disable2fa.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (Catalan)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (German)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (Finnish)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (Italian)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (Russian)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (Swedish)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (Turkish)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations strings.xml (French)
Update translations from Crowdin [ci skip]

* New translations strings.xml (Spanish)
Update translations from Crowdin [ci skip]

* New translations strings.xml (Catalan)
Update translations from Crowdin [ci skip]

* New translations strings.xml (German)
Update translations from Crowdin [ci skip]

* New translations strings.xml (Finnish)
Update translations from Crowdin [ci skip]

* New translations strings.xml (Italian)
Update translations from Crowdin [ci skip]

* New translations strings.xml (Dutch)
Update translations from Crowdin [ci skip]

* New translations strings.xml (Russian)
Update translations from Crowdin [ci skip]

* New translations strings.xml (Swedish)
Update translations from Crowdin [ci skip]

* New translations strings.xml (Turkish)
Update translations from Crowdin [ci skip]

* New translations strings.xml (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations strings.xml (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (Catalan)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (German)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (Finnish)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (Italian)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (Russian)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (Swedish)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (Turkish)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (Chinese Simplified)
Update translations from Crowdin [ci skip]

* Refactor LanguageService.cs (#1079)

* Add new languages to apps (#1079)

* Update LanguageService.cs (#1079)

* Add language config to both AliasVault and Autofill targets for iOS (#1079)

* Update Program.cs to read available languages from LanguageService.cs (#1079)

* Add finnish language to all apps (#1079)

* Add german language (#1079)

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Catalan)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Finnish)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Italian)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Russian)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Swedish)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Turkish)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Catalan)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Finnish)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Italian)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Russian)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Swedish)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Turkish)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations register.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations usernamestep.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations setup.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations start.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations passwordstep.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations usernamestep.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations importservices.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations home.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations general.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations welcome.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations resetvault.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations recentemails.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations passwordsettingspopup.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations activesessionssection.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations deleteaccountsection.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations passwordchangesection.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations quickvaultunlocksection.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations recentauthlogssection.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations twofactorauthenticationsection.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations createnewidentitywidget.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations unlock.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations addedit.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations view.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations home.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations apps.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations general.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations sync.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations changepassword.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations deleteaccount.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Catalan)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Finnish)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Italian)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Russian)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Swedish)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Turkish)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Chinese Simplified)
Update translations from Crowdin [ci skip]

* Update source file SharedResources.en.resx
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Finnish)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Finnish)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Finnish)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations quickvaultunlocksection.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations twofactorauthenticationsection.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations strings.xml (German)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (Catalan)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Finnish)
Update translations from Crowdin [ci skip]

* New translations en.json (Italian)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Russian)
Update translations from Crowdin [ci skip]

* New translations en.json (Swedish)
Update translations from Crowdin [ci skip]

* New translations en.json (Turkish)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations en.json (Chinese Simplified)
Update translations from Crowdin [ci skip]

* Update source file en.json
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Finnish)
Update translations from Crowdin [ci skip]

* New translations en.json (Italian)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Italian)
Update translations from Crowdin [ci skip]

* New translations en.json (Italian)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Italian)
Update translations from Crowdin [ci skip]

* New translations general.en.resx (Italian)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Italian)
Update translations from Crowdin [ci skip]
2025-08-31 17:13:40 +02:00
Leendert de Borst
8caa69e130 Prevent input fields from increasing in height on Android (#1160) 2025-08-31 17:11:57 +02:00
Leendert de Borst
c45d0c8f56 Add missing translation key to credential list search field in browser extension 2025-08-30 20:39:14 +02:00
Leendert de Borst
6c0fc44a66 Merge branch 'main' of https://github.com/lanedirt/AliasVault
* 'main' of https://github.com/lanedirt/AliasVault:
  Style refactor (#1157)
  Update ClipboardUtility.ts (#1157)
  Add missing translation (#1157)
  Add stubs for new NativeVaultManager spec for iOS (#1157)
  Add ignore battery optimization check for Android clipboard clear (#1157)
  Update native vault manager package namespace (#1157)
  Add android precise alarm timing implementation for clipboard clear (#1157)
  Implement native iOS clipboard clear after delay (#1157)
2025-08-29 20:39:10 +02:00
Leendert de Borst
3b88cb5b50 Update CredentialFilter.swift 2025-08-29 20:39:08 +02:00
Leendert de Borst
7314dc3d1d Style refactor (#1157) 2025-08-29 19:07:48 +02:00
Leendert de Borst
2c98b81111 Update ClipboardUtility.ts (#1157) 2025-08-29 19:07:48 +02:00
Leendert de Borst
fe7da551a4 Add missing translation (#1157) 2025-08-29 19:07:48 +02:00
Leendert de Borst
c4c29b11f3 Add stubs for new NativeVaultManager spec for iOS (#1157) 2025-08-29 19:07:48 +02:00
Leendert de Borst
ab740c093f Add ignore battery optimization check for Android clipboard clear (#1157) 2025-08-29 19:07:48 +02:00
Leendert de Borst
056f8e97e9 Update native vault manager package namespace (#1157) 2025-08-29 19:07:48 +02:00
Leendert de Borst
819924c6e2 Add android precise alarm timing implementation for clipboard clear (#1157) 2025-08-29 19:07:48 +02:00
Leendert de Borst
c6203b9e19 Implement native iOS clipboard clear after delay (#1157) 2025-08-29 19:07:48 +02:00
Leendert de Borst
347a72e55d Update CredentialFilter.swift 2025-08-29 11:40:06 +02:00
Leendert de Borst
30a2b1326c Autofocus browser extension unlock page 2025-08-29 11:39:43 +02:00
Leendert de Borst
4d66ea9694 Make refresh button spin counter clockwise (#1155) 2025-08-29 00:10:38 +02:00
Leendert de Borst
1cf28c43fb Add missing translations in web app (#1155) 2025-08-29 00:10:38 +02:00
Leendert de Borst
6a75e56123 Refactor client form model validation messages and add missing translations (#1153) 2025-08-28 13:19:28 +02:00
Leendert de Borst
ef72abceb4 Add missing translations for login and other client forms (#1153) 2025-08-28 13:19:28 +02:00
Leendert de Borst
19406cf58d Cleanup certificates dir (#1148) 2025-08-27 23:15:21 +02:00
Leendert de Borst
9fda76a5ff Use sequential builds (#1148) 2025-08-27 23:15:21 +02:00
Leendert de Borst
610d1b4654 Update all-in-one Dockerfile to reduce layers (#1148) 2025-08-27 23:15:21 +02:00
Leendert de Borst
602d59d268 Update release.yml (#1148) 2025-08-27 23:15:21 +02:00
Leendert de Borst
edae632025 Add all-in-one docker image push (#1148) 2025-08-27 23:15:21 +02:00
Leendert de Borst
2c3d2379ee Improve private email domain documentation in apps (#1150) 2025-08-27 16:40:22 +02:00
Leendert de Borst
70ed03e1b3 Update BaseImporter.cs (#1146) 2025-08-26 23:42:00 +02:00
Leendert de Borst
bf1a235dd2 Refactor (#1146) 2025-08-26 23:42:00 +02:00
Leendert de Borst
2bb7f0a742 Update Delete.razor margins (#1146) 2025-08-26 23:42:00 +02:00
Leendert de Borst
8cd5118749 Update KeePassImporter.cs (#1146) 2025-08-26 23:42:00 +02:00
Leendert de Borst
2fccb162e6 Add custom decoder support for importers (#1146) 2025-08-26 23:42:00 +02:00
Leendert de Borst
ad3c0323b9 Make CSV import more robust by handling special char decoding (#1146) 2025-08-26 23:42:00 +02:00
Leendert de Borst
9e859f6dc0 Update browser extension UI with settings subpages (#1144) 2025-08-26 13:23:44 +02:00
Leendert de Borst
5f70912b7a Update Filter.test.ts (#1142) 2025-08-25 22:10:09 +02:00
Leendert de Borst
dcc45eb5b6 Update app autofill matching (#1142) 2025-08-25 22:10:09 +02:00
Leendert de Borst
340d3943a2 Update CredentialMatcher.kt (#1142) 2025-08-25 22:10:09 +02:00
Leendert de Borst
64a879f72d Add autofill filter test for names with punctuation (#1142) 2025-08-25 22:10:09 +02:00
Leendert de Borst
0f8e1f7e15 Update autofill filter tests for mobile app (#1142) 2025-08-25 22:10:09 +02:00
Leendert de Borst
f86400fa50 Add autofill matching mode configurable setting to browser extension (#1142) 2025-08-25 22:10:09 +02:00
Leendert de Borst
047b0723b3 Use closed shadowroot for autofill popup (#1142) 2025-08-25 22:10:09 +02:00
Leendert de Borst
f785063065 Add clickjacking prevention measures through ClickValidator.ts (#1142) 2025-08-25 22:10:09 +02:00
Leendert de Borst
3720ad1961 Update translations 2025-08-25 14:40:22 +02:00
Leendert de Borst
fe617fc024 Update admin topmenu bg color and user icon style (#1140) 2025-08-25 12:43:49 +02:00
Leendert de Borst
1138b16daa Add popup open heartbeat, refactor background.ts (#1131) 2025-08-25 11:42:42 +02:00
Leendert de Borst
108a6855c2 Add vault autolock timer to browser extension (#1131) 2025-08-25 11:42:42 +02:00
Leendert de Borst
fb002e54b7 Add top users by credentials to admin all time stats (#1136) 2025-08-24 14:30:50 +02:00
Leendert de Borst
58ae63c74b Update browser extension popup search placeholder 2025-08-24 12:56:42 +02:00
Leendert de Borst
51287c85dc Update offscreen.js (#1134) 2025-08-23 17:53:44 +02:00
Leendert de Borst
b638e3375d Add shadowdom support to autofill form field detection (#1134) 2025-08-23 17:53:44 +02:00
Leendert de Borst
5d827bb7ac Tweak Settings.tsx UI 2025-08-23 17:53:44 +02:00
Leendert de Borst
666b3ccada Update email domain active entry styling (#1129) 2025-08-22 10:51:37 +02:00
Leendert de Borst
87a62000d3 Update modal background color (#1129) 2025-08-22 10:51:37 +02:00
Leendert de Borst
54c6e94751 Update style (#1129) 2025-08-22 10:51:37 +02:00
Leendert de Borst
54a5584baf Add email domain component to mobile app (#1129) 2025-08-22 10:51:37 +02:00
Leendert de Borst
ff48f1882f Merge branch 'main' of https://github.com/lanedirt/AliasVault
* 'main' of https://github.com/lanedirt/AliasVault:
  Bump vite-plugin-static-copy
2025-08-21 19:16:03 +02:00
Leendert de Borst
0b95203aac Update translation source to match web app 2025-08-21 19:15:49 +02:00
Leendert de Borst
3f5328ab3c Merge pull request #1132 from lanedirt/dependabot/npm_and_yarn/apps/browser-extension/npm_and_yarn-1975ee8f93
Bump vite-plugin-static-copy from 2.3.1 to 2.3.2 in /apps/browser-extension in the npm_and_yarn group across 1 directory
2025-08-21 19:02:58 +02:00
Leendert de Borst
f913d84557 Add email field domain chooser to browser extension (#1129) 2025-08-21 17:22:11 +02:00
dependabot[bot]
9a9752c557 Bump vite-plugin-static-copy
Bumps the npm_and_yarn group with 1 update in the /apps/browser-extension directory: [vite-plugin-static-copy](https://github.com/sapphi-red/vite-plugin-static-copy).


Updates `vite-plugin-static-copy` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/sapphi-red/vite-plugin-static-copy/releases)
- [Changelog](https://github.com/sapphi-red/vite-plugin-static-copy/blob/vite-plugin-static-copy@2.3.2/CHANGELOG.md)
- [Commits](https://github.com/sapphi-red/vite-plugin-static-copy/compare/vite-plugin-static-copy@2.3.1...vite-plugin-static-copy@2.3.2)

---
updated-dependencies:
- dependency-name: vite-plugin-static-copy
  dependency-version: 2.3.2
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-21 15:18:06 +00:00
Leendert de Borst
82458f74e3 Update user avatar style on unlock page (#1122) 2025-08-19 15:58:45 +02:00
Leendert de Borst
71633b166e Remove unused translations (#881) 2025-08-19 14:57:40 +02:00
Leendert de Borst
3305958e60 Tweak clipboard clear for various usecases and make it more robust (#881) 2025-08-19 14:57:40 +02:00
Leendert de Borst
4ae1f6ec35 Add clipboard clear delay in seconds setting (#881) 2025-08-19 14:57:40 +02:00
Leendert de Borst
4498833b4e Add clipboard countdown bar component (#881) 2025-08-19 14:57:40 +02:00
Leendert de Borst
7054593c07 Make clipboard clear work for mv3 and mv2 browsers (#881) 2025-08-19 14:57:22 +02:00
Leendert de Borst
6d197fe870 Make manifest browser specific (#881) 2025-08-19 14:57:22 +02:00
Leendert de Borst
d70eb0a447 Add clipboard clear and timer logic to background.ts, add offscreen API for Chrome 109+ (#881) 2025-08-19 14:57:22 +02:00
Leendert de Borst
aecb52de3c Add clipboard countdown bar component (#881) 2025-08-19 14:57:22 +02:00
Leendert de Borst
cd6ea06430 Add clear clipboard settings (#881) 2025-08-19 14:57:22 +02:00
Leendert de Borst
0d13440821 Merge pull request #1127 from lanedirt/881-feature-request-add-automatic-clipboard-clear-to-clients-after-copying-a-value-mobile
Add automatic clipboard clear to mobile apps after copying a value
2025-08-19 14:57:05 +02:00
Leendert de Borst
8e3da4b381 Add Android clear clipboard implementation and disclaimer to settings screen (#881) 2025-08-18 17:59:02 +02:00
Leendert de Borst
81538d4666 Refactor to use central clipboard clear timeout retrieval method (#881) 2025-08-18 17:35:22 +02:00
Leendert de Borst
634b7cada1 Add clipboard countdown context to keep global track of copied field id (#881) 2025-08-18 17:15:02 +02:00
Leendert de Borst
bed2c78964 Add clear clipboard animation to form input component (#881) 2025-08-18 16:04:59 +02:00
Leendert de Borst
a75392c573 Add clipboard clear settings page (#881) 2025-08-18 15:55:42 +02:00
Leendert de Borst
7b10665488 Add clear clipboard logic to mobile app iOS implementation (#881) 2025-08-18 15:31:54 +02:00
Leendert de Borst
ddf995db1d Update README.md 2025-08-18 12:33:07 +02:00
Leendert de Borst
8d9d55ce82 Update README.md 2025-08-18 12:23:46 +02:00
Leendert de Borst
ccf473635e Fix issue in iOS autofill where entire alias object would be null if birthdate was null (#1123) 2025-08-15 19:11:46 +02:00
Leendert de Borst
56c8b61e9e Make srpSalt check compatible with older API versions 2025-08-15 16:11:06 +02:00
Leendert de Borst
69234de51c Make autofill match tests match for all platforms (#1120) 2025-08-15 15:19:07 +02:00
Leendert de Borst
893c06cc00 Update Android autofill matching logic to match other platforms (#1120) 2025-08-15 15:19:07 +02:00
Leendert de Borst
b2c07f6de6 Only do text fallback search on credentials without a domain name (#1120) 2025-08-15 15:19:07 +02:00
Leendert de Borst
229fbd4824 Refactor iOS credential matching to use shared method with UI logic (#1120) 2025-08-15 15:19:07 +02:00
Leendert de Borst
48c5a5e38a Add test identifiers for easier cross-platform maintenance (#1120) 2025-08-15 15:19:07 +02:00
Leendert de Borst
5b3f36936a Add autofill matching unit tests to iOS Xcode project 2025-08-15 15:19:07 +02:00
Leendert de Borst
b4c696c89b Add autofill matching test cases to browser extension (#1120) 2025-08-15 15:19:07 +02:00
Leendert de Borst
d53c133812 Improve autofill matching to also support part of domain name (#1120) 2025-08-15 15:19:07 +02:00
Leendert de Borst
cbbfe1c611 Only convert service URL to anchor tag if it starts with http/https (#1120) 2025-08-15 15:19:07 +02:00
Leendert de Borst
437c7bb807 Make service URL field accept any value (#1120) 2025-08-15 15:19:07 +02:00
Leendert de Borst
03faee8d3a Cleanup unused translations 2025-08-14 18:26:07 +02:00
Leendert de Borst
e66a87e8df Add fallback to get encryption key (#1118) 2025-08-14 18:18:43 +02:00
Leendert de Borst
11f1daa08b Update derived key name in all methods (#1118) 2025-08-14 18:18:43 +02:00
Leendert de Borst
784e64ece8 Add srp salt sanity check to browser extension (#1118) 2025-08-14 18:18:43 +02:00
Leendert de Borst
4da1333aa5 Add SrpSalt check to the useVaultSync hook (#1118) 2025-08-14 18:18:43 +02:00
Leendert de Borst
65413c7ab7 Add SrpSalt to API status endpoint response (#1118) 2025-08-14 18:18:43 +02:00
Leendert de Borst
290e5329f8 Revoke user sessions during vault restore by admin (#1118) 2025-08-14 18:18:43 +02:00
Leendert de Borst
ec060d1392 Logout all user sessions after password change (#1118) 2025-08-14 18:18:43 +02:00
Leendert de Borst
293501405f Add public vs private email domain explanation to general settings page (#1116) 2025-08-13 22:04:46 +02:00
Leendert de Borst
783b2d44ef Add Dropbox Passwords import method (#1114) 2025-08-13 21:30:36 +02:00
Leendert de Borst
29d38759eb Update logging levels in admin and task runner 2025-08-12 19:52:35 +02:00
Leendert de Borst
97f30ad9ba Enable logging non-warnings to database log and adjust existing warning levels (#1112)
* Enable logging non-warnings to database log and adjust warnings (#443)

* Add log level filter (#443)

* Update General.razor (#443)
2025-08-12 17:39:26 +02:00
Leendert de Borst
c728d71868 Update Program.cs (#1110) 2025-08-11 23:23:12 +02:00
Leendert de Borst
27fc298b5e Add cancellation token to search fields in admin (#1110) 2025-08-11 23:23:12 +02:00
Leendert de Borst
6eb8266d05 Merge branch 'main' of https://github.com/lanedirt/AliasVault
* 'main' of https://github.com/lanedirt/AliasVault:
  Add reset admin password script for all-in-one image (#1108)
  Delete SINGLE-CONTAINER.md (#1108)
2025-08-11 22:05:31 +02:00
Leendert de Borst
f22cac70e9 Add known network config to admin to prevent proxy errors 2025-08-11 22:05:28 +02:00
Leendert de Borst
f1c94ea145 Update docs 2025-08-11 22:05:03 +02:00
Leendert de Borst
d587f3fd5c Add reset admin password script for all-in-one image (#1108) 2025-08-11 21:35:22 +02:00
Leendert de Borst
db874d3799 Delete SINGLE-CONTAINER.md (#1108) 2025-08-11 21:35:22 +02:00
Leendert de Borst
3f5b731703 Update tests (#1100) 2025-08-11 18:37:57 +02:00
Leendert de Borst
258981b2e4 Add user management tests (#1100) 2025-08-11 18:37:57 +02:00
Leendert de Borst
34b3545168 Add user name change option to admin (#1100) 2025-08-11 18:37:57 +02:00
Leendert de Borst
c37dafd228 Make breadcrumb urls relative in admin app (#1099) 2025-08-11 17:08:12 +02:00
Leendert de Borst
dbe15bdc51 Consolidate unnecessary translation keys (#1104) 2025-08-11 17:08:02 +02:00
Leendert de Borst
9eb4a3136a Add missing translations (#1104) 2025-08-11 17:08:02 +02:00
Leendert de Borst
747596615e Make API URL connection errors more descriptive (#1104) 2025-08-11 17:08:02 +02:00
Leendert de Borst
60221cf0e8 Update v0.22.0 migration docs (#1098) 2025-08-11 16:16:58 +02:00
Leendert de Borst
d9aa765284 Make nginx process wait for init to finish (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
b7a916e414 Add docker all-in-one build test, replacing pull test (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
110c0d2628 Update DbService.cs (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
ecfc6f948d Update install.sh status indicators (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
990d94397b Improve nginx status page (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
b861a30596 Remove env connectionstrings (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
583534fae9 Add status HTML to nginx to show if service is down or starting up (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
8136eb379d Remove startup dependencies from nginx (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
9f5c1b35c4 Update Dockerfile (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
7bd51fa2fe Make postgres connection support optional env overrides (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
4340ed48e6 Fix email claims retrieval (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
2fabc8c4dc Update docker-compose.dev.yml paths (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
99884b9761 Make data between all-in-one and multi-container setups compatible (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
c80a9c1b32 Add auto-migrate .env secrets to install.sh (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
3c993fe875 Update init script (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
ca1f3c3f64 Move folders (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
728b5c2a9c Add default env vars, update log (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
73600a49f8 Add notification script that's printed after all services are started (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
8a2e806311 Move aio docker files to subfolder 2025-08-11 13:18:45 +02:00
Leendert de Borst
9c8462f9ce Update container startup logging (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
e2fc9878b0 Improve verbosity config in aio image (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
f5f05703a0 Update init script (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
b30f8853aa Add update docs scaffolding for v0.22.0 (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
d85d62f3b4 Add installCli admin password generation to aio image (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
8bd8d688ef Add generic secretreader to support files when running in docker (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
c174a6bfb4 Update DataProtectionExtensions to load secrets from file when running under docker (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
3125eb3751 Update .gitignore (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
1e5a84b392 Update TaskRunnerWorker.cs (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
180977b833 Update DbService.cs (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
2d40e424e8 Refactor s6 config so each service has its separate run and type files (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
af0b5ff5f8 Add file based secret generation scaffolding (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
1b8e6cc6a1 Make services wait for postgres to be available and configured (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
eb04263751 Make clean startup work sharing directories with full docker compose setup (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
daccab9bcc Fix private email domain init (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
6577021bd7 Simplify PRIVATE_EMAIL_DOMAINS to default to empty string (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
de6ae7f7e1 Refactor to make certain env vars optional (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
a272aa11f2 Update self-signed cert generation logic (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
6cc77adbab Rename to allinone, make compatible with default nginx.conf (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
b6b476f9c8 Remove duplicated files (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
86aef6961c Update install.md (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
542f99c484 Rename dockerfile and update readme for clarity (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
6ce666a35d Move alternative docker related files to subdirectory (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
0ddd47b0e7 Update .env.example structure and explanation (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
f55d7717f8 Remove top level placeholder dirs which are automatically created during docker init or install.sh (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
1eaacd1ed0 Remove letsencrypt config from single docker setup (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
4b385e0ea2 Make admin work in single docker context (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
ff90cc2937 Make API work in single docker context (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
8bb6ec2b7c Make client appsettings.json replace work (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
7a4e55912c Make single docker stack boot (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
a1f97cd709 Add other service scaffolding to single docker (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
dbb2aa5610 Create Dockerfile.single (#1098) 2025-08-11 13:18:45 +02:00
Leendert de Borst
3af46c80fa Update bump-version.sh to use semantic build versions 2025-08-06 12:10:19 +02:00
Leendert de Borst
e10ef4bd75 Update linting fixes (#1085) 2025-08-06 08:53:19 +02:00
Leendert de Borst
54853c7a4d Refactor AuthContext to return translation keys instead of direct translations (#1085) 2025-08-06 08:45:12 +02:00
Leendert de Borst
1dde9ab4b4 Update sonarcloud-code-analysis.yml 2025-08-05 19:09:25 +02:00
Leendert de Borst
3585e20354 Add missing translations for Android biometrics and general vault unlock flow (#1085) 2025-08-05 15:28:03 +02:00
Leendert de Borst
c926933804 Update import order (#1085) 2025-08-05 15:28:03 +02:00
Leendert de Borst
5a43f7142c Add missing translations for mobile app (#1085) 2025-08-05 15:28:03 +02:00
Leendert de Borst
a15138afc8 Merge branch 'main' of https://github.com/lanedirt/AliasVault
* 'main' of https://github.com/lanedirt/AliasVault:
  Add changelog for 0.21.2 (#1095)
  Update bump-version.sh to show fastlane reminder (#1095)
  Bump version (#1095)
2025-08-05 14:25:23 +02:00
Leendert de Borst
bd62ecd8bd Update AliasVault Info.plist with exempt flag 2025-08-05 14:19:44 +02:00
Leendert de Borst
f48591685a Add changelog for 0.21.2 (#1095) 2025-08-05 13:49:24 +02:00
Leendert de Borst
cae1813084 Update bump-version.sh to show fastlane reminder (#1095) 2025-08-05 13:49:24 +02:00
Leendert de Borst
74e18a8fb1 Bump version (#1095) 2025-08-05 13:49:24 +02:00
Leendert de Borst
a89546200c Update sendEmailCLI.sh to test special char handling (#1093) 2025-08-05 13:22:57 +02:00
Leendert de Borst
a40f29d467 Make plain text emails more readable in browser extension (#1093) 2025-08-05 13:22:57 +02:00
Leendert de Borst
bcda120351 Render newlines for plain text emails in web app (#1093) 2025-08-05 13:22:57 +02:00
Leendert de Borst
ad1ffd63d5 Improve soft-delete cleanup mechanism to prevent EF related issues (#1091) 2025-08-05 12:14:31 +02:00
Leendert de Borst
4b55a21d33 Linting refactor (#1075) 2025-08-04 22:34:39 +02:00
Leendert de Borst
183548616e Update TaskRunnerTests.cs with per user email limits (#1075) 2025-08-04 22:34:39 +02:00
Leendert de Borst
4938129367 Add per user email limits configurable through admin (#1075) 2025-08-04 22:34:39 +02:00
Leendert de Borst
984f5a2c52 UI cleanup (#1075) 2025-08-04 22:34:39 +02:00
Leendert de Borst
5969a9d437 Update Entity Framework docs (#1075) 2025-08-04 22:34:39 +02:00
Leendert de Borst
efbb64637d Add TaskRunner to vscode build tasks (#1075) 2025-08-04 22:34:39 +02:00
Leendert de Borst
b460023911 Expand english identity generator dictionaries (#1087) 2025-08-04 22:28:59 +02:00
Leendert de Borst
c0e869a586 Always include birth year in email prefix to make aliases more unique (#1087) 2025-08-04 22:28:59 +02:00
Leendert de Borst
cd306ef878 Add top users by email table to admin all time stats page (#1082) 2025-08-04 21:27:11 +02:00
Leendert de Borst
1a40e31470 Make header right buttons on Android use Pressable instead of TouchableOpacity (#1080) 2025-08-04 19:16:04 +02:00
Leendert de Borst
30f9199a7e Prevent app re-initialization during cold boot and unlock/login (#1073) 2025-08-02 13:50:19 +02:00
Leendert de Borst
e830b9c482 Bump version to 0.21.1 (#1069) 2025-07-31 09:03:15 +02:00
Leendert de Borst
bc6b9da10b Add wait for i18n to fix browser extension crash on startup, specifically Firefox on Windows (#1066) 2025-07-31 08:49:30 +02:00
Leendert de Borst
40991d879e Update README.md [skip ci] 2025-07-30 13:02:52 +02:00
Leendert de Borst
2949978a11 Bump version (#1064) 2025-07-30 12:08:08 +02:00
Leendert de Borst
9715be40f3 Update changelogs and add NL language (#1064) 2025-07-30 12:08:08 +02:00
Leendert de Borst
a1d146c517 Update android target SDK to 35 as per Play Store requirements (#1064) 2025-07-30 12:08:08 +02:00
Leendert de Borst
b729efbcfb New Crowdin updates (#1063)
* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]
2025-07-30 10:38:47 +02:00
Leendert de Borst
ac0b7c4be8 Make useVaultSync.ts translatable (#1060) 2025-07-30 10:12:58 +02:00
Leendert de Borst
865d5c8fce Refactor app boot to prevent translation initialization errors (#1060) 2025-07-30 10:12:58 +02:00
Leendert de Borst
f154d8afe7 New Crowdin updates (#1059)
* Update source file en.json
Update translations from Crowdin [ci skip]

* Update source file en.json
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]

* Update source file en.json
Update translations from Crowdin [ci skip]

* Update source file en.json
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]
2025-07-29 16:31:38 +02:00
Leendert de Borst
df6bcff8b3 Update browser extension and app translations 2025-07-29 15:48:13 +02:00
Leendert de Borst
3fbfca6163 Update CONTRIBUTING.md 2025-07-29 15:13:12 +02:00
Leendert de Borst
d86ad136f7 New Crowdin updates (#1058)
* Update source file en.json
Update translations from Crowdin [ci skip]

* Update source file en.json
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (Dutch)
Update translations from Crowdin [ci skip]

* Update source file en.json
Update translations from Crowdin [ci skip]

* Update source file en.json
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]
2025-07-29 15:13:03 +02:00
Leendert de Borst
a3e51409cf Update browser extension translations 2025-07-29 14:54:08 +02:00
Leendert de Borst
a11052bc77 Simplify singular/plural translations 2025-07-29 14:49:37 +02:00
Leendert de Borst
de4b102397 New Crowdin updates (#1057)
* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]

* Update source file en.json
Update translations from Crowdin [ci skip]

* Update source file en.json
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (French)
Update translations from Crowdin [ci skip]

* New translations strings.xml (French)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (French)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (Dutch)
Update translations from Crowdin [ci skip]
2025-07-29 14:28:02 +02:00
Leendert de Borst
ec66e7c339 Update README.md 2025-07-29 14:26:41 +02:00
Leendert de Borst
59b118b35d Add translations (#1054) 2025-07-29 13:48:49 +02:00
Leendert de Borst
db9ba0eac3 Update translations (#1054) 2025-07-29 13:48:49 +02:00
Leendert de Borst
215e7b0eff Persist language to vault settings in web app during registration (#1054) 2025-07-29 13:48:49 +02:00
Leendert de Borst
d7b97a7139 New Crowdin updates (#1055)
* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (French)
Update translations from Crowdin [ci skip]

* New translations strings.xml (French)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (French)
Update translations from Crowdin [ci skip]
2025-07-29 13:35:56 +02:00
Leendert de Borst
8b23bc6142 New Crowdin updates (#1053)
* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (Dutch)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (Dutch)
Update translations from Crowdin [ci skip]
2025-07-29 10:23:20 +02:00
Leendert de Borst
49eae07bce Update CONTRIBUTING.md 2025-07-28 16:45:05 +02:00
Leendert de Borst
8a2aafacfb Update translations (#883) 2025-07-28 16:39:59 +02:00
Leendert de Borst
23c386003e Update context menu (#883) 2025-07-28 16:39:59 +02:00
Leendert de Borst
16e03d4dbc Refactor slider update logic (#883) 2025-07-28 16:39:59 +02:00
Leendert de Borst
3616afa625 Update advanced password popup and add translations (#883) 2025-07-28 16:39:59 +02:00
Leendert de Borst
2fd8ade738 Add advanced password settings to mobile app and slider component (#883) 2025-07-28 16:39:59 +02:00
Leendert de Borst
e10a37328a Add advanced password settings to mobile app and slider component (#883) 2025-07-28 16:39:59 +02:00
Leendert de Borst
4811eb9ebe Rearrange credential edit interface for mobile app (#883) 2025-07-28 16:39:59 +02:00
Leendert de Borst
ba65e0c8ff Update translations (#883) 2025-07-28 16:39:59 +02:00
Leendert de Borst
1150614722 Add advanced password generator options to content script (#883) 2025-07-28 16:39:59 +02:00
Leendert de Borst
d10cc79148 Update password settings in browser extension popup CRUD (#883) 2025-07-28 16:39:59 +02:00
Leendert de Borst
ec833cb430 Tweak web app credential edit layout (#883) 2025-07-28 16:39:59 +02:00
Leendert de Borst
751f8b6afd Update password generator lib (#883) 2025-07-28 16:39:59 +02:00
Leendert de Borst
68f351cfc5 Add separate username field component with regenerate button (#883) 2025-07-28 16:39:59 +02:00
Leendert de Borst
b2177f5d98 Add separate password field component with password length slider (#883) 2025-07-28 16:39:59 +02:00
Leendert de Borst
d43efb0273 New Crowdin updates (#1047)
* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]

* Update source file en.json
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]

* Update source file en.json
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]
2025-07-28 08:26:55 +02:00
Leendert de Borst
490861016a Update icon for attachment preview (#1010) 2025-07-27 21:43:04 +02:00
Leendert de Borst
fa6ff5153a Add attachment file preview for images and text files (#1010) 2025-07-27 21:43:04 +02:00
Leendert de Borst
8ddefa56af Fix attachment download base64 decoding issue (#1010) 2025-07-27 21:43:04 +02:00
Leendert de Borst
0dac97f4ff Update IdentityGenerator return type (#1010) 2025-07-27 21:43:04 +02:00
Leendert de Borst
7da8189789 Add attachment upload option to mobile app (#1010) 2025-07-27 21:43:04 +02:00
Leendert de Borst
a674baa6d6 Add attachment download option to credential view screen (#1010) 2025-07-27 21:43:04 +02:00
Leendert de Borst
9e04e54b43 Add attachment upload during credential create flow (#808) 2025-07-26 12:33:58 +02:00
Leendert de Borst
7cb789ce9d Implement attachment uploader for credential edit flow (#808) 2025-07-26 12:33:58 +02:00
Leendert de Borst
c0a5a7db03 Update translation (#808) 2025-07-26 12:33:58 +02:00
Leendert de Borst
ccb84780eb Add attachment viewer to browser extension (#808) 2025-07-26 12:33:58 +02:00
Leendert de Borst
25acce3ae0 Add attachment to shared TS models (#808) 2025-07-26 12:33:58 +02:00
Leendert de Borst
1d29c3338d Make plain text emails selectable on Android (#1017) 2025-07-25 16:42:43 +02:00
Leendert de Borst
1c95a86c51 Fix public SpamOK email loading issue on mobile app (#998) 2025-07-25 14:29:41 +02:00
Leendert de Borst
78052e74d6 New Crowdin updates (#1040)
* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations recentemails.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations recentemails.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations recentemails.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations recentemails.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations recentemails.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]

* Update source file RecentEmails.en.resx
Update translations from Crowdin [ci skip]

* Update source file en.json
Update translations from Crowdin [ci skip]

* Update source file en.json
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (French)
Update translations from Crowdin [ci skip]

* New translations strings.xml (French)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations recentemails.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]
2025-07-24 21:47:13 +02:00
Leendert de Borst
70cc2b4985 Update EmailStorageStats hyperlink to be relative 2025-07-24 21:03:37 +02:00
Leendert de Borst
5050fdc95d Update user reference (#1041) 2025-07-24 20:27:48 +02:00
Leendert de Borst
4da10bbfba Add email storage page to admin (#1041) 2025-07-24 20:27:48 +02:00
Leendert de Borst
7844f411ef Refactoring (#1037) 2025-07-24 11:09:58 +02:00
Leendert de Borst
cca687b61f Add user usage statistics to user details page (#1037) 2025-07-24 11:09:58 +02:00
Leendert de Borst
8e6d125700 Add recent usage statistics (#1037) 2025-07-24 11:09:58 +02:00
Leendert de Borst
19fe4121ad Update linting (#990) 2025-07-24 00:30:49 +02:00
Leendert de Borst
6178303418 Add email load more button to mobile app and add missing translations (#990) 2025-07-24 00:30:49 +02:00
Leendert de Borst
d563bd5c02 Add load more button to recent emails in browser extension (#990) 2025-07-24 00:30:49 +02:00
Leendert de Borst
47f55ea08f Add load more button to recent emails in web app (#990) 2025-07-24 00:30:49 +02:00
Leendert de Borst
07bad37568 Update install.sh to avoid GitHub API rate limiting 2025-07-23 20:46:12 +02:00
Leendert de Borst
b0dda6cb77 Add InvariantCulture defaults to Api and SmtpService to prevent regional setting conflicts (#1013) 2025-07-23 20:46:12 +02:00
Leendert de Borst
7a179fcde0 New Crowdin updates (#1034)
* New translations infoplist.strings (French)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (Spanish)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (German)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (French)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (Spanish)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (German)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (Ukrainian)
Update translations from Crowdin [ci skip]
2025-07-22 21:43:52 +02:00
Leendert de Borst
53decce407 Update SearchWidget.razor (#1013) 2025-07-22 19:09:55 +02:00
Leendert de Borst
dfb8c86366 Update Android tests (#1013) 2025-07-22 19:09:55 +02:00
Leendert de Borst
cec6e7c303 Update Android autofill filter to handle empty strings correctly (#1013) 2025-07-22 19:09:55 +02:00
Leendert de Borst
1993d08487 Update search widget logic (#1013) 2025-07-22 19:09:55 +02:00
Leendert de Borst
6c54c270fa Align browser extension and mobile app credential filter logic (#1013) 2025-07-22 19:09:55 +02:00
Leendert de Borst
c92c8fc663 Update iOS app credential filter logic (#1013) 2025-07-22 19:09:55 +02:00
Leendert de Borst
b0d03d6bb1 Android autofill search in notes text as fallback mechanism (#1013) 2025-07-22 19:09:55 +02:00
Leendert de Borst
68895a7834 Improve credential filter logic for mobile app (#1013) 2025-07-22 19:09:55 +02:00
Leendert de Borst
d183a406ac New Crowdin updates (#1033)
* Update source file InfoPlist.strings
Update translations from Crowdin [ci skip]

* Update source file Localizable.strings
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations enable2fa.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations disable2fa.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations disable2fa.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations disable2fa.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations disable2fa.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations disable2fa.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (French)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (Spanish)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (German)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (Dutch)
Update translations from Crowdin [ci skip]

* New translations infoplist.strings (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations strings.xml (French)
Update translations from Crowdin [ci skip]

* New translations strings.xml (Spanish)
Update translations from Crowdin [ci skip]

* New translations strings.xml (German)
Update translations from Crowdin [ci skip]

* New translations strings.xml (Dutch)
Update translations from Crowdin [ci skip]

* New translations strings.xml (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (French)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (Spanish)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (German)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (Dutch)
Update translations from Crowdin [ci skip]

* New translations localizable.strings (Ukrainian)
Update translations from Crowdin [ci skip]
2025-07-22 18:49:15 +02:00
Leendert de Borst
55b22dcaa8 Change formatting from ios language files from utf16 to utf-8 2025-07-22 18:07:58 +02:00
Leendert de Borst
44ff1b0118 Fix iOS translation config 2025-07-22 15:39:41 +02:00
Leendert de Borst
ddd7b0a4ab Update crowdin.yml 2025-07-22 14:52:36 +02:00
Leendert de Borst
bd564a1cd9 Update iOS strings file to UTF16 LE for crowdin compatibility 2025-07-22 14:47:22 +02:00
Leendert de Borst
c7aa98a172 Update crowdin.yml 2025-07-22 14:04:51 +02:00
Leendert de Borst
553e716c31 Update crowdin.yml 2025-07-22 13:58:43 +02:00
Leendert de Borst
1e50b7b6bc Tweak enable/disable 2FA flow in web app including translations (#1029) 2025-07-22 11:47:23 +02:00
Leendert de Borst
3fce102471 Show correct breadcrumbs in admin (#995) 2025-07-22 11:39:43 +02:00
Leendert de Borst
297a7b4824 Update confirm modal z-index so it shows on top everywhere (#1026) 2025-07-22 11:39:33 +02:00
dependabot[bot]
9dc80be72a 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.8 to 1.18.9
- [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.8...v1.18.9)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-22 10:35:16 +02:00
Leendert de Borst
c585bd83d2 New Crowdin updates (#1023)
* New translations importexport.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations resetvault.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations resetvaultsection.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations importexport.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations resetvault.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations resetvaultsection.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations importexport.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations resetvault.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations resetvaultsection.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations passwordstep.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations quickvaultunlocksection.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations searchwidget.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations footer.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations setup.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations start.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations apps.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations creating.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations errorvaultdecrypt.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations resetvault.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations resetvaultsection.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations importexport.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations resetvault.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations resetvaultsection.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]
2025-07-21 19:03:28 +02:00
Leendert de Borst
0b81554b38 Update install.sh newline fix (#493) 2025-07-21 19:02:41 +02:00
Leendert de Borst
c93884c306 Update DataProtection config (#493) 2025-07-21 19:02:41 +02:00
Leendert de Borst
e8a40ea18e Update .NET DataProtection config to be resilient against container restarts (#493) 2025-07-21 19:02:41 +02:00
Leendert de Borst
80a9996a23 New translations view.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
7a300d5a46 New translations en.json (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
3985a9e5ab New translations deleteaccount.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
214c76b446 New translations apierrors.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
30a2b0557a New translations welcome.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
e63c198cce New translations importexport.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
1e33c22d32 New translations view.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
e253646c30 New translations home.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
6303924d01 New translations delete.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
42fff611d8 New translations addedit.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
4837d3d855 New translations unlock.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
0b461bd015 New translations createnewidentitywidget.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
a2c69bf36c New translations twofactorauthenticationsection.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
bfe08eada7 New translations showrecoverycodes.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
8361860db5 New translations recentauthlogssection.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
5a1e859185 New translations passwordchangesection.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
f9aa9005da New translations deleteaccountsection.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
de785d7e82 New translations activesessionssection.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
3aac3d9088 New translations importservices.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
ca2088fd7a New translations importservicecard.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
4e08d3f01c New translations editemailformrow.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
3bc2e47d76 New translations emailpreview.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
9546327575 New translations emailmodal.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
9dbfd3ea2b New translations totpviewer.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
b3101c5336 New translations totpcodes.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
454e005127 New translations usernamestep.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
4bde61a70f New translations termsandconditionsstep.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
bda0b11729 New translations login.en.resx (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
4cc0e66d93 New translations en.json (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
fc091c441c New translations en.json (Dutch)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
d6e510fad3 New translations view.en.resx (German)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
a3cad05cd3 New translations view.en.resx (Spanish)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
b9f3995f5d New translations view.en.resx (French)
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
1b1a5924c3 Update source file en.json
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
7b820ccda1 Update source file Welcome.en.resx
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
459616880e Update source file View.en.resx
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
62c23d34cf Update source file en.json
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
74e7635705 Update source file Welcome.en.resx
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
0a943a5066 Update source file View.en.resx
Update translations from Crowdin [ci skip]
2025-07-21 16:15:02 +02:00
Leendert de Borst
67d3519ff8 Update installation docs (#994) 2025-07-21 15:06:43 +02:00
Leendert de Borst
02f4b53670 Add 64-bit check to install.sh (#994) 2025-07-21 15:06:43 +02:00
Leendert de Borst
3bed56231a Update i18n for web app 2025-07-21 14:36:26 +02:00
Leendert de Borst
5204726bec Add reset vault E2E test, fix delete all scope (#1007) 2025-07-21 11:16:39 +02:00
Leendert de Borst
c5a0bad44d Hard delete all credentials on vault reset (#1007) 2025-07-21 11:16:39 +02:00
Leendert de Borst
f74a09e4bb Update reset vault and refactor into its own page (#1007) 2025-07-21 11:16:39 +02:00
Leendert de Borst
99e17d0792 Add scaffolding for vault reset with local password verify (#1007) 2025-07-21 11:16:39 +02:00
Leendert de Borst
5f7730a474 Update release docs 2025-07-18 17:57:59 +02:00
dependabot[bot]
f9a4937a3a Bump the npm_and_yarn group across 1 directory with 2 updates
Bumps the npm_and_yarn group with 2 updates in the /apps/mobile-app directory: [on-headers](https://github.com/jshttp/on-headers) and [compression](https://github.com/expressjs/compression).


Updates `on-headers` from 1.0.2 to 1.1.0
- [Release notes](https://github.com/jshttp/on-headers/releases)
- [Changelog](https://github.com/jshttp/on-headers/blob/master/HISTORY.md)
- [Commits](https://github.com/jshttp/on-headers/compare/v1.0.2...v1.1.0)

Updates `compression` from 1.8.0 to 1.8.1
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/1.8.0...v1.8.1)

---
updated-dependencies:
- dependency-name: on-headers
  dependency-version: 1.1.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: compression
  dependency-version: 1.8.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-18 15:54:16 +02:00
Leendert de Borst
468e7c8b66 Update README.md 2025-07-18 11:35:35 +02:00
Leendert de Borst
8d5d755fdf New Crowdin updates (#1012)
* New translations sharedresources.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations sharedresources.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations importservices.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations searchwidget.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations changepassword.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations deleteaccount.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations enable2fa.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations changepassword.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations deleteaccount.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations enable2fa.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations changepassword.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations deleteaccount.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations enable2fa.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations changepassword.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations deleteaccount.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations enable2fa.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations apierrors.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations changepassword.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations deleteaccount.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations enable2fa.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations validationmessages.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]
2025-07-17 08:53:08 +02:00
Leendert de Borst
64857bcbb4 Update AuthTests.cs 2025-07-16 19:55:58 +02:00
Leendert de Borst
66db3e0571 Update CONTRIBUTING.md 2025-07-16 17:11:16 +02:00
Leendert de Borst
4cbed21e67 Update E2E tests 2025-07-16 17:00:59 +02:00
Leendert de Borst
16f8eced09 Update Playwright timeout to allow tests more time 2025-07-16 16:27:18 +02:00
Leendert de Borst
547fa57cb6 Update ApiResponseUtility.cs (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
cbacd7486a Update username validation (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
3b413a79c9 Update E2E tests (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
a2b962bb44 Make topnav structure refresh on language change (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
95739f6758 Simplify WASM localize structure, fix re-render bug in searchwidget (#1006)
This reverts commit 32a2d13fcc.
2025-07-16 11:28:28 +02:00
Leendert de Borst
cc95779f48 Update i18n folder structure and read global config for language switcher (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
25ff5bf994 Refactor browser extension i18n to use single file structure (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
32e6ca597a Update mobile app extra locales (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
a39340262e Cleanup unused translation keys (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
58ed0bf156 Update login to localize error messages returned by API (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
77994d221e Localize UnlockSuccess.tsx (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
519fc5fb24 Refactor VaultMessageHandler.ts for translations (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
accc76d8a2 Add dynamic .json translations for content script (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
0c2de27f1a Catch ApiErrors and translate them in Login.tsx (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
53047cf3ad Update EmailPreview.tsx (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
0b7cdbce02 Update AuthSettings.tsx (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
a963064dc8 Cache localized strings for performance (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
f4c4962cb8 Localize Enable2Fa page (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
3c36020812 Update clickOutsideHandler.js to only listen on mouse outside and explicit escape key (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
9892430e59 Update import/export localization (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
1e3e542f92 Localize form model validations (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
c90c5a9f2f Update user registration flow to show correct error messages (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
7621be4cbe Update AuthController.cs (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
31868b7099 Update Unlock.razor (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
8213a81321 Add ApiErrors enum translations and implement to client login (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
df2ae22a99 Refactor API to output error codes instead of literal error texts (#1006) 2025-07-16 11:28:28 +02:00
Leendert de Borst
9999529d60 Add Crowdin initial language files (#1004)
* New translations emails.json (French)
Update translations from Crowdin [ci skip]

* New translations emails.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations emails.json (German)
Update translations from Crowdin [ci skip]

* New translations emails.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations emails.json (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations settings.json (French)
Update translations from Crowdin [ci skip]

* New translations settings.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations settings.json (German)
Update translations from Crowdin [ci skip]

* New translations settings.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations settings.json (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations en.json (French)
Update translations from Crowdin [ci skip]

* New translations en.json (Spanish)
Update translations from Crowdin [ci skip]

* New translations en.json (German)
Update translations from Crowdin [ci skip]

* New translations en.json (Dutch)
Update translations from Crowdin [ci skip]

* New translations en.json (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations login.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations register.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations register.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations register.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations register.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations register.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations passwordstep.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations passwordstep.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations passwordstep.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations passwordstep.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations passwordstep.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations termsandconditionsstep.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations termsandconditionsstep.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations termsandconditionsstep.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations termsandconditionsstep.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations termsandconditionsstep.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations usernamestep.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations usernamestep.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations usernamestep.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations usernamestep.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations usernamestep.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations totpcodes.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations totpcodes.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations totpcodes.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations totpcodes.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations totpcodes.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations totpviewer.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations totpviewer.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations totpviewer.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations totpviewer.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations totpviewer.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations emailmodal.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations emailmodal.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations emailmodal.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations emailmodal.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations emailmodal.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations emailpreview.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations emailpreview.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations emailpreview.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations emailpreview.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations emailpreview.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations emailrow.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations emailrow.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations emailrow.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations emailrow.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations emailrow.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations recentemails.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations recentemails.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations recentemails.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations recentemails.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations recentemails.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations copypasteformrow.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations copypasteformrow.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations copypasteformrow.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations copypasteformrow.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations copypasteformrow.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations editemailformrow.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations editemailformrow.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations editemailformrow.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations editemailformrow.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations editemailformrow.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations defaultpasswordsettings.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations defaultpasswordsettings.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations defaultpasswordsettings.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations defaultpasswordsettings.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations defaultpasswordsettings.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations importservicecard.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations importservicecard.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations importservicecard.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations importservicecard.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations importservicecard.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations importservices.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations importservices.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations importservices.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations importservices.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations importservices.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations passwordsettingspopup.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations passwordsettingspopup.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations passwordsettingspopup.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations passwordsettingspopup.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations passwordsettingspopup.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations activesessionssection.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations activesessionssection.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations activesessionssection.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations activesessionssection.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations activesessionssection.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations deleteaccountsection.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations deleteaccountsection.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations deleteaccountsection.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations deleteaccountsection.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations deleteaccountsection.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations passwordchangesection.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations passwordchangesection.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations passwordchangesection.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations passwordchangesection.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations passwordchangesection.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations quickvaultunlocksection.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations quickvaultunlocksection.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations quickvaultunlocksection.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations quickvaultunlocksection.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations quickvaultunlocksection.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations recentauthlogssection.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations recentauthlogssection.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations recentauthlogssection.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations recentauthlogssection.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations recentauthlogssection.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations showrecoverycodes.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations showrecoverycodes.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations showrecoverycodes.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations showrecoverycodes.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations showrecoverycodes.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations twofactorauthenticationsection.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations twofactorauthenticationsection.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations twofactorauthenticationsection.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations twofactorauthenticationsection.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations twofactorauthenticationsection.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations createnewidentitywidget.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations createnewidentitywidget.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations createnewidentitywidget.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations createnewidentitywidget.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations createnewidentitywidget.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations searchwidget.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations searchwidget.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations searchwidget.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations searchwidget.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations searchwidget.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations footer.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations footer.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations footer.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations footer.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations footer.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations topmenu.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations topmenu.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations topmenu.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations topmenu.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations topmenu.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations forgotpassword.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations forgotpassword.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations forgotpassword.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations forgotpassword.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations forgotpassword.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations logout.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations logout.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations logout.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations logout.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations logout.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations setup.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations setup.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations setup.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations setup.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations setup.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations start.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations start.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations start.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations start.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations start.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations unlock.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations unlock.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations unlock.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations unlock.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations unlock.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations addedit.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations addedit.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations addedit.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations addedit.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations addedit.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations delete.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations delete.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations delete.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations delete.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations delete.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations home.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations home.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations home.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations home.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations home.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations view.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations view.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations view.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations view.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations view.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations home.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations home.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations home.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations home.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations home.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations apps.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations apps.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations apps.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations apps.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations apps.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations general.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations general.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations general.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations general.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations general.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations importexport.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations importexport.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations importexport.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations importexport.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations importexport.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations security.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations security.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations security.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations security.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations security.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations creating.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations creating.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations creating.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations creating.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations creating.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations errorvaultdecrypt.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations errorvaultdecrypt.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations errorvaultdecrypt.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations errorvaultdecrypt.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations errorvaultdecrypt.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations vaultdecryptionprogress.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations vaultdecryptionprogress.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations vaultdecryptionprogress.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations vaultdecryptionprogress.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations vaultdecryptionprogress.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations sync.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations sync.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations sync.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations sync.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations sync.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]

* New translations welcome.en.resx (French)
Update translations from Crowdin [ci skip]

* New translations welcome.en.resx (Spanish)
Update translations from Crowdin [ci skip]

* New translations welcome.en.resx (German)
Update translations from Crowdin [ci skip]

* New translations welcome.en.resx (Dutch)
Update translations from Crowdin [ci skip]

* New translations welcome.en.resx (Ukrainian)
Update translations from Crowdin [ci skip]
2025-07-12 14:59:46 +02:00
Leendert de Borst
1df4884301 Update crowdin.yml 2025-07-12 02:04:45 +02:00
Leendert de Borst
185b7a0ad6 Update LanguageService.cs (#1000) 2025-07-11 23:44:45 +02:00
Leendert de Borst
c3dd77d6f8 Add translations documentation (#1000) 2025-07-11 23:44:45 +02:00
Leendert de Borst
c3ae769d11 Cleanup mobile app i18n config file (#1000) 2025-07-11 23:44:45 +02:00
Leendert de Borst
fc7f12471a Remove unused translation keys from browser extension (#1000) 2025-07-11 23:44:45 +02:00
Leendert de Borst
d36a3dba42 Update LanguageService.cs (#1000) 2025-07-11 23:44:45 +02:00
Leendert de Borst
9556e6dca9 Update Crowdin configuration file 2025-07-11 17:45:10 +02:00
Leendert de Borst
c0a63be92b Update crowdin.yml to use absolute paths 2025-07-11 17:44:44 +02:00
Leendert de Borst
2cf1ea2065 Update Crowdin configuration file 2025-07-11 17:33:49 +02:00
Leendert de Borst
df7d1560be Add preserve_translations flag 2025-07-11 15:34:37 +02:00
Leendert de Borst
a6a56ec9fb Update crowdin.yml 2025-07-11 15:27:29 +02:00
Leendert de Borst
3675454737 Create crowdin.yml 2025-07-11 14:59:18 +02:00
Leendert de Borst
da21565f1b Update pods, remove duplicate localizable files (#993) 2025-07-11 12:50:41 +02:00
Leendert de Borst
5b6a80a7b1 Localize Android native autofill component (#993) 2025-07-11 12:50:41 +02:00
Leendert de Borst
cb5cd1006c Update mobile app language setting configure for mobile app (#993) 2025-07-11 12:50:41 +02:00
Leendert de Borst
ca9b9e465c Add locale config for Android app (#993) 2025-07-11 12:50:41 +02:00
Leendert de Borst
9a6c86569d Bump android dependencies and fix build after adding expo-localization (#993) 2025-07-11 12:50:41 +02:00
Leendert de Borst
21177e9927 Add localization keys for context menu (#993) 2025-07-11 12:50:41 +02:00
Leendert de Borst
e7c79f2aa4 Localize vault setting subpages (#993) 2025-07-11 12:50:41 +02:00
Leendert de Borst
8e89673cc9 Localize credential and email tabs (#993) 2025-07-11 12:50:41 +02:00
Leendert de Borst
fc75532a0d Localize native iOS autofill component (#993) 2025-07-11 12:50:41 +02:00
Leendert de Borst
9eb913c692 Add english and dutch languages to iOS app settings (#993) 2025-07-11 12:50:41 +02:00
Leendert de Borst
e1497b74aa Mobile app i18n scaffolding (#993) 2025-07-11 12:50:41 +02:00
Leendert de Borst
2d85511ec5 Fix top level await issue (#992) 2025-07-09 11:42:45 +02:00
Leendert de Borst
7c26398e9c Refactor linting (#992) 2025-07-09 11:42:45 +02:00
Leendert de Borst
23052b375c Move language settings to top of auth settings (#992) 2025-07-09 11:42:45 +02:00
Leendert de Borst
406505035b Update login localization (#992) 2025-07-09 11:42:45 +02:00
Leendert de Borst
371ed93819 Use local:language setting (#992) 2025-07-09 11:42:45 +02:00
Leendert de Borst
e715454acb Localize layout, credential components, email page (#992) 2025-07-09 11:42:45 +02:00
Leendert de Borst
28c1869048 Localize main popup entrypoint pages (#992) 2025-07-09 11:42:45 +02:00
Leendert de Borst
bde0877168 Update Settings.tsx (#992) 2025-07-09 11:42:45 +02:00
Leendert de Borst
2f11b5507c Add i18n scaffolding to browser extension (#992) 2025-07-09 11:42:45 +02:00
Leendert de Borst
149a85dde9 Update DbUpgradeTests.cs (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
cdfe7c5a99 Update tests (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
23378368fb Refactor to prevent duplicate vault saves on vault creation (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
27fad07f92 Make languageswitcher show proper initial browser language (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
29b5501a01 Tweak E2E test flow (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
988c43ae20 Refactor SharedLocalizer to MainBase (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
f9e94c3059 Refactor (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
1969dd0b48 Add flag icon to language switcher (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
f7a0f3d29a Add dynamic language switcher via Blazor.WebAssembly.DynamicCulture.Loader (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
2464858b4e Localize index.template.html strings separately (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
f793510b1e Add language switcher to AliasVault.Client (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
e7644dc3fb Localize email components (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
67d4a0b8ff Localize all import/export subcomponents (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
0e37616ced Localize recentEmails, import, edit form (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
182e5d8d8d Localize security settings, footer, email (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
f19e288196 Localize vault sync messages (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
8bff55414c Localize forgot password, start, logout (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
63b18acbac Localize search widget, unlock, delete pages (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
49676bf1f4 Localize passwordstep and credential view page (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
db39a18ab5 Localize setup and settings (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
4d57f8dea3 Make topmenu and welcome localized (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
3160ad202a Use IStringLocalizerFactory to simplify structure (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
946a44a9a1 Make i18n work for login switching between en-US and nl-NL (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
4bba4c5911 Add i18n scaffolding to AliasVault.Client project (#820) 2025-07-07 16:35:05 +02:00
Leendert de Borst
50c401cee4 Merge branch 'main' of https://github.com/lanedirt/AliasVault
* 'main' of https://github.com/lanedirt/AliasVault:
  Revert image versions back to :latest (#986)
  Add docker-compose.yml check for latest version (#986)
2025-07-02 10:26:24 +02:00
Leendert de Borst
4e09912420 Bump version to 0.20.2 2025-07-02 10:26:22 +02:00
Leendert de Borst
6c8843dc5b Revert image versions back to :latest (#986) 2025-07-02 10:25:58 +02:00
Leendert de Borst
4c4aa4ba26 Add docker-compose.yml check for latest version (#986) 2025-07-02 10:25:58 +02:00
1294 changed files with 117516 additions and 10463 deletions

View File

@@ -14,91 +14,60 @@
# Docker containers to apply the changes.
# ----------------------------------------------------------------------------
# ===========================================
# NETWORK PORTS
# ===========================================
# Configure the network ports used by AliasVault by the `reverse-proxy` and `smtp` containers.
# You can change these if the defaults are in use on your system.
# After making changes, re-run the install script to apply them.
# You can change these if the defaults are already in use on your system.
# Requires a restart before taking effect.
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=
# ===========================================
# EMAIL SERVER CONFIGURATION
# ===========================================
# 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=
# ----------------------------------------------------------------------------
# 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
# To disable the private email domains feature, keep this empty.
PRIVATE_EMAIL_DOMAINS=
# Set whether TLS is enabled for SMTP.
# Enable TLS for SMTP.
# ⚠️ Requires valid TLS certificates on your mail server (not provided by the AliasVault installer).
# If set to true without proper certificates, the SMTP service will fail to start.
# For self-hosted setups, we recommend keeping this **false** unless you're sure how to configure it.
# Note: Disabling TLS does **not** impact email deliverability.
SMTP_TLS_ENABLED=false
# ----------------------------------------------------------------------------
# ===========================================
# Let's Encrypt configuration
# ----------------------------------------------------------------------------
# ===========================================
# Set whether Let's Encrypt is enabled. This is only supported through
# the install.sh script.
# the install.sh script and should be set to false for manual installations.
LETSENCRYPT_ENABLED=false
# ----------------------------------------------------------------------------
# Set the hostname that your AliasVault will be accessible at in order for LetsEncrypt
# to do its validation. This value is only required when LETSENCRYPT_ENABLED
# is set to true.
# Example: `aliasvault.mydomain.net`.
HOSTNAME=
# ===========================================
# Optional configuration settings
# ----------------------------------------------------------------------------
# ===========================================
# Enable or disable ability for new users to create an account via the web interface.
# Note: make sure you have created your (own) accounts before setting this to false.
PUBLIC_REGISTRATION_ENABLED=true
# Whether to enable IP logging for auth attempts. When set to true the last octet is
# always still anonymized, e.g. "127.0.0.1" becomes "127.0.0.xxx".
IP_LOGGING_ENABLED=true
# Set the support email address which is shown to users in the main web app.

View File

@@ -1,4 +1,4 @@
name: Docker Pull and Build
name: Docker Build Tests
on:
push:
@@ -11,125 +11,153 @@ concurrency:
cancel-in-progress: true
jobs:
docker-compose-pull:
name: Docker Compose Pull Test
docker-all-in-one-build:
name: Docker All-in-One Build Test
runs-on: ubuntu-latest
services:
docker:
image: docker:26.0.0
options: --privileged
steps:
- name: Get repository and branch information
id: repo-info
- uses: actions/checkout@v2
- name: Build all-in-one Docker image
run: |
if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.pull_request.head.repo.fork }}" = "true" ]; then
echo "REPO_FULL_NAME=lanedirt/AliasVault" >> $GITHUB_ENV
echo "BRANCH_NAME=main" >> $GITHUB_ENV
else
echo "REPO_FULL_NAME=${GITHUB_REPOSITORY}" >> $GITHUB_ENV
echo "BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_ENV
docker build -f dockerfiles/all-in-one/Dockerfile -t aliasvault-allinone:test .
echo "✅ All-in-one Docker image built successfully"
- name: Run all-in-one container
run: |
docker run -d \
--name aliasvault-test \
-p 8080:80 \
-p 8443:443 \
-p 2525:25 \
-p 2587:587 \
-v "$(pwd)/database:/database" \
-v "$(pwd)/certificates:/certificates" \
-v "$(pwd)/logs:/logs" \
-v "$(pwd)/secrets:/secrets" \
aliasvault-allinone:test
- name: Wait for services to be ready
run: |
echo "Waiting for services to initialize..."
for i in {1..60}; do
if docker exec aliasvault-test curl -f http://localhost:3001/api 2>/dev/null; then
echo "✅ API service is ready"
break
fi
echo "Waiting for services... ($i/60)"
sleep 5
done
- name: Check container logs if needed
if: failure()
run: docker logs aliasvault-test
- name: Test root endpoint
uses: nick-fields/retry@v3
with:
timeout_minutes: 2
max_attempts: 3
command: |
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" https://localhost:8443/)
if [ "$http_code" -ne 200 ]; then
echo "❌ Root endpoint (/) failed with HTTP $http_code"
docker logs aliasvault-test
exit 1
fi
echo "✅ Root endpoint (/) returned HTTP 200"
- name: Test API endpoint
uses: nick-fields/retry@v3
with:
timeout_minutes: 2
max_attempts: 3
command: |
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" https://localhost:8443/api)
if [ "$http_code" -ne 200 ]; then
echo "❌ API endpoint (/api) failed with HTTP $http_code"
docker logs aliasvault-test
exit 1
fi
echo "✅ API endpoint (/api) returned HTTP 200"
- name: Test Admin endpoint
uses: nick-fields/retry@v3
with:
timeout_minutes: 2
max_attempts: 3
command: |
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" https://localhost:8443/admin/user/login)
if [ "$http_code" -ne 200 ]; then
echo "❌ Admin endpoint (/admin) failed with HTTP $http_code"
docker logs aliasvault-test
exit 1
fi
echo "✅ Admin endpoint (/admin) returned HTTP 200"
- name: Verify admin password hash file does not exist initially
run: |
if [ -f "./secrets/admin_password_hash" ]; then
echo "❌ Admin password hash file should not exist initially"
cat ./secrets/admin_password_hash
exit 1
fi
echo "✅ Admin password hash file correctly does not exist initially"
- name: Download install script from current branch
- name: Test admin password reset flow
run: |
INSTALL_SCRIPT_URL="https://raw.githubusercontent.com/$REPO_FULL_NAME/$BRANCH_NAME/install.sh"
echo "Downloading install script from: $INSTALL_SCRIPT_URL"
curl -f -o install.sh "$INSTALL_SCRIPT_URL"
echo "🔧 Testing admin password reset flow..."
- name: Create .env file with custom SMTP port
run: echo "SMTP_PORT=2525" > .env
# Run the reset password script with auto-confirm
echo "Running reset-admin-password.sh script..."
password_output=$(docker exec aliasvault-test reset-admin-password.sh -y 2>&1)
echo "Script output:"
echo "$password_output"
- name: Set permissions and run install.sh (install)
id: install_script
run: |
chmod +x install.sh
{
./install.sh install --verbose
exit_code=$?
if [ $exit_code -eq 2 ]; then
echo "skip_remaining=true" >> $GITHUB_OUTPUT
true
elif [ $exit_code -ne 0 ]; then
false
fi
} || {
if [ $exit_code -eq 2 ]; then
echo "skip_remaining=true" >> $GITHUB_OUTPUT
true
else
exit $exit_code
fi
}
# Extract the generated password from the output
generated_password=$(echo "$password_output" | grep -E "^Password: " | sed 's/Password: //')
if [ -z "$generated_password" ]; then
echo "❌ Failed to extract generated password from script output"
echo "Full output was:"
echo "$password_output"
exit 1
fi
echo "✅ Generated password extracted: $generated_password"
- name: Run docker compose up
if: ${{ !steps.install_script.outputs.skip_remaining }}
run: docker compose -f docker-compose.yml up -d
# Verify that the admin_password_hash file now exists in the container
if ! docker exec aliasvault-test test -f /secrets/admin_password_hash; then
echo "❌ Admin password hash file was not created in container"
docker exec aliasvault-test ls -la /secrets/
exit 1
fi
echo "✅ Admin password hash file created in container"
- name: Wait for services
if: ${{ !steps.install_script.outputs.skip_remaining }}
run: sleep 10
# Verify that the admin_password_hash file exists locally (mounted volume)
if [ ! -f "./secrets/admin_password_hash" ]; then
echo "❌ Admin password hash file not found in local secrets folder"
ls -la ./secrets/
exit 1
fi
echo "✅ Admin password hash file exists in local secrets folder"
- name: Test WASM App
if: ${{ !steps.install_script.outputs.skip_remaining }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 2
max_attempts: 3
command: |
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" https://localhost:443)
if [ "$http_code" -ne 200 ]; then
echo "WASM app failed with $http_code"
exit 1
fi
- name: Test WebApi
if: ${{ !steps.install_script.outputs.skip_remaining }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 2
max_attempts: 3
command: |
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" https://localhost:443/api)
if [ "$http_code" -ne 200 ]; then
echo "WebApi failed with $http_code"
exit 1
fi
- name: Test Admin App
if: ${{ !steps.install_script.outputs.skip_remaining }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 2
max_attempts: 3
command: |
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" https://localhost:443/admin/user/login)
if [ "$http_code" -ne 200 ]; then
echo "Admin app failed with $http_code"
exit 1
fi
- name: Test SMTP
if: ${{ !steps.install_script.outputs.skip_remaining }}
- name: Test SMTP port
uses: nick-fields/retry@v3
with:
timeout_minutes: 2
max_attempts: 3
command: |
if ! nc -zv localhost 2525 2>&1 | grep -q 'succeeded'; then
echo "SMTP failed"
echo "SMTP port 2525 is not accessible"
docker logs aliasvault-test
exit 1
fi
echo "✅ SMTP port 2525 is accessible"
- name: Test reset-admin-password output
if: ${{ !steps.install_script.outputs.skip_remaining }}
- name: Cleanup
if: always()
run: |
output=$(./install.sh reset-admin-password | sed 's/\x1b\[[0-9;]*m//g')
if ! echo "$output" | grep -Eq '^\s*Password: [A-Za-z0-9+/=]{8,}'; then
echo "Invalid reset-admin-password output"
exit 1
fi
docker stop aliasvault-test || true
docker rm aliasvault-test || true
docker-compose-build:
name: Docker Compose Build Test
@@ -143,6 +171,21 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Check local docker-compose.yml for :latest tags
run: |
# Check for explicit version tags instead of :latest
if grep -E "ghcr\.io/lanedirt/aliasvault-[^:]+:[0-9]+\.[0-9]+\.[0-9]+" docker-compose.yml; then
echo "❌ Error: docker-compose.yml contains explicit version tags instead of :latest"
echo "Found the following explicit versions:"
grep -E "ghcr\.io/lanedirt/aliasvault-[^:]+:[0-9]+\.[0-9]+\.[0-9]+" docker-compose.yml
echo ""
echo "All AliasVault images in docker-compose.yml must use ':latest' tags, not explicit versions."
echo "Please update docker-compose.yml to use ':latest' for all AliasVault images."
exit 1
fi
echo "✅ docker-compose.yml correctly uses :latest tags for all AliasVault images"
- name: Create .env file with custom SMTP port
run: echo "SMTP_PORT=2525" > .env

View File

@@ -4,6 +4,27 @@ on:
release:
types: [published]
workflow_dispatch:
inputs:
build_browser_extensions:
description: 'Build browser extensions'
required: false
default: true
type: boolean
build_mobile_apps:
description: 'Build mobile apps'
required: false
default: true
type: boolean
build_multi_container:
description: 'Build and push multi-container images'
required: false
default: true
type: boolean
build_all_in_one:
description: 'Build and push all-in-one image'
required: false
default: true
type: boolean
env:
REGISTRY: ghcr.io
@@ -19,12 +40,14 @@ jobs:
uses: actions/checkout@v4
- name: Upload install.sh to release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v2
with:
files: install.sh
token: ${{ secrets.GITHUB_TOKEN }}
build-chrome-extension:
if: github.event_name == 'release' || inputs.build_browser_extensions
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -34,11 +57,12 @@ jobs:
uses: ./.github/actions/build-browser-extension
with:
browser: chrome
upload_to_release: true
upload_to_release: ${{ github.event_name == 'release' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-firefox-extension:
if: github.event_name == 'release' || inputs.build_browser_extensions
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -48,11 +72,12 @@ jobs:
uses: ./.github/actions/build-browser-extension
with:
browser: firefox
upload_to_release: true
upload_to_release: ${{ github.event_name == 'release' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-edge-extension:
if: github.event_name == 'release' || inputs.build_browser_extensions
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -62,11 +87,12 @@ jobs:
uses: ./.github/actions/build-browser-extension
with:
browser: edge
upload_to_release: true
upload_to_release: ${{ github.event_name == 'release' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-android-release:
if: github.event_name == 'release' || inputs.build_mobile_apps
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -76,7 +102,7 @@ jobs:
uses: ./.github/actions/build-android-app
with:
signed: true
upload_to_release: true
upload_to_release: ${{ github.event_name == 'release' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
@@ -84,7 +110,8 @@ jobs:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
build-and-push-docker:
build-and-push-docker-multi-container:
if: github.event_name == 'release' || inputs.build_multi_container
runs-on: ubuntu-latest
permissions:
contents: read
@@ -111,11 +138,32 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
- name: Extract metadata for multi-container images
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}
tags: |
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
type=ref,event=branch,enable=${{ github.ref_type == 'branch' }}
type=raw,value={{branch}}-{{sha}},enable=${{ github.ref_type == 'branch' }}
- name: Generate tags for containers
id: tags
run: |
# Transform base tags to include suffixes for each container
TAGS="${{ steps.meta.outputs.tags }}"
# Generate tags for each container by replacing the base image name with suffixed versions
echo "postgres=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-postgres|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
echo "api=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-api|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
echo "client=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-client|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
echo "admin=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-admin|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
echo "reverse-proxy=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-reverse-proxy|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
echo "smtp=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-smtp|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
echo "task-runner=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-task-runner|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
echo "installcli=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-installcli|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
- name: Build and push Postgres image
uses: docker/build-push-action@v5
@@ -124,7 +172,8 @@ jobs:
file: apps/server/Databases/AliasServerDb/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-postgres:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-postgres:${{ github.ref_name }}
tags: ${{ steps.tags.outputs.postgres }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push API image
uses: docker/build-push-action@v5
@@ -133,7 +182,8 @@ jobs:
file: apps/server/AliasVault.Api/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-api:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-api:${{ github.ref_name }}
tags: ${{ steps.tags.outputs.api }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push Client image
uses: docker/build-push-action@v5
@@ -142,7 +192,8 @@ jobs:
file: apps/server/AliasVault.Client/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-client:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-client:${{ github.ref_name }}
tags: ${{ steps.tags.outputs.client }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push Admin image
uses: docker/build-push-action@v5
@@ -151,7 +202,8 @@ jobs:
file: apps/server/AliasVault.Admin/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-admin:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-admin:${{ github.ref_name }}
tags: ${{ steps.tags.outputs.admin }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push Reverse Proxy image
uses: docker/build-push-action@v5
@@ -160,7 +212,8 @@ jobs:
file: apps/server/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-reverse-proxy:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-reverse-proxy:${{ github.ref_name }}
tags: ${{ steps.tags.outputs.reverse-proxy }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push SMTP image
uses: docker/build-push-action@v5
@@ -169,7 +222,8 @@ jobs:
file: apps/server/Services/AliasVault.SmtpService/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-smtp:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-smtp:${{ github.ref_name }}
tags: ${{ steps.tags.outputs.smtp }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push TaskRunner image
uses: docker/build-push-action@v5
@@ -178,7 +232,8 @@ jobs:
file: apps/server/Services/AliasVault.TaskRunner/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-task-runner:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-task-runner:${{ github.ref_name }}
tags: ${{ steps.tags.outputs.task-runner }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push InstallCli image
uses: docker/build-push-action@v5
@@ -187,4 +242,65 @@ jobs:
file: apps/server/Utilities/AliasVault.InstallCli/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-installcli:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-installcli:${{ github.ref_name }}
tags: ${{ steps.tags.outputs.installcli }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-docker-all-in-one:
if: github.event_name == 'release' || inputs.build_all_in_one
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Convert repository name to lowercase
run: |
echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for all-in-one image
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ env.REPO_LOWER }}
aliasvault/aliasvault
tags: |
# For release events with latest tag (only for non-prerelease)
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
# semver tags for releases (works for prerelease and normal release)
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
# For branches, use branch name and branch name + short SHA for uniqueness
type=ref,event=branch,enable=${{ github.ref_type == 'branch' }}
type=raw,value={{branch}}-{{sha}},enable=${{ github.ref_type == 'branch' }}
- name: Build and push all-in-one image
uses: docker/build-push-action@v5
with:
context: .
file: dockerfiles/all-in-one/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -10,6 +10,11 @@ on:
pull_request_target:
types: [opened, synchronize, reopened]
# Cancel in-progress jobs when new commits are pushed
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref_name }}
cancel-in-progress: true
jobs:
build:
name: Build and analyze

4
.gitignore vendored
View File

@@ -406,6 +406,9 @@ certificates/**/*.pfx
certificates/**/*.pem
certificates/letsencrypt/**
# Secrets
secrets/**
# Docs
docs/_site
docs/vendor
@@ -417,6 +420,7 @@ database/postgres-dev
# Temp files
temp
*.zip
# Don't check in .js.map or .mjs.map files. These are generated by the build process in the shared
# libraries and copied to the application so they can be used for debugging, but we don't need

14
.vscode/tasks.json vendored
View File

@@ -57,6 +57,20 @@
"cwd": "${workspaceFolder}/apps/server/Services/AliasVault.SmtpService"
}
},
{
"label": "Build and watch TaskRunner",
"type": "shell",
"command": "dotnet watch",
"args": [],
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
},
"options": {
"cwd": "${workspaceFolder}/apps/server/Services/AliasVault.TaskRunner"
}
},
{
"label": "Build and watch Client CSS",
"type": "shell",

View File

@@ -2,26 +2,39 @@
Thanks for your interest in contributing to the AliasVault project! There are a lot of ways to help out.
## Community Engagement
## Table of Contents
Become active in AliasVault's community, helping by:
1. [Help spread the word](#1-help-spread-the-word)
2. [Contributing to Translations](#2-contributing-to-translations)
3. [Contributing to the Documentation](#3-contributing-to-the-documentation)
4. [Contributing to the Main Codebase](#4-contributing-to-the-main-codebase)
- [4.1 Get in contact](#41-get-in-contact)
- [4.2 Set up your local development environment](#42-set-up-your-local-development-environment)
5. [License and Contributions](#5-license-and-contributions)
- **Answering questions** in our [Discord community](https://discord.gg/DsaXMTEtpF)
- **Helping users** with self-hosting setup and troubleshooting
- **Reporting bugs** and suggesting improvements
- **Participating in discussions** about features and improvements
---
## Spreading the Word
## 1. Help spread the word
Getting the word out about AliasVault is important so we can reach and help more people to improve their privacy. You can help by:
Help grow the AliasVault community by:
- **Sharing on social media** (X, Reddit, Mastodon, etc.)
- **Writing blog posts** about your AliasVault experience
- **Creating video tutorials** or walkthroughs
- **Mentioning AliasVault** in privacy/self-hosting discussions
- **Telling friends and colleagues** about the project
- Answering questions and helping users in our [Discord](https://discord.gg/DsaXMTEtpF)
- Reporting bugs and suggesting improvements
- Sharing on social media and writing about your experience
- Creating tutorials and documentation
- Spreading the word about privacy and self-hosting
## Contributing to the Documentation
## 2. Contributing to Translations
Help make AliasVault accessible to users worldwide by contributing translations! AliasVault is currently available in English and Dutch, but we're looking for volunteers to help translate it into other languages such as German, French, Spanish, Ukrainian, Italian, and more.
AliasVault translations are managed through [Crowdin](https://crowdin.com/), an online translation platform. If youd like to help translate AliasVault into your native language, please [request access to the Crowdin project](https://crowdin.com/project/aliasvault).
If you're willing to help, we also encourage you to get in contact via [Discord](https://discord.gg/DsaXMTEtpF) to chat (quickest), or contact us via email at [contact@support.aliasvault.net](mailto:contact@support.aliasvault.net) to discuss the language(s) you are willing to contribute to, and so we can answer any technical questions you might have.
Your translation contributions will help make AliasVault more accessible to privacy-conscious users around the world!
## 3. Contributing to the Documentation
The docs are built using Jekyll and automatically deploy to GitHub Pages via GitHub Actions. You can build the docs locally by running `docker compose up` in the `./docs` folder.
@@ -29,15 +42,16 @@ The docs site is based on the open-source template called Just The Docs. Find mo
To make changes to the AliasVault documentation please make a PR that directly edits the `docs` markdown files in this repository.
## Contributing to the Main Codebase
### Get in contact
If youre planning to work on a new feature or improvement for AliasVault, we strongly encourage you to get in touch with us first. This ensures that your proposed changes align with the project's direction and increases the likelihood of your work being accepted into the official repository. You can reach us through:
## 4. Contributing to the Main Codebase
### 4.1 Get in contact
If you're planning to work on a new feature or improvement for AliasVault, we strongly encourage you to get in touch with us first. This ensures that your proposed changes align with the project's direction and increases the likelihood of your work being accepted into the official repository. You can reach us through:
- Opening an issue on GitHub to discuss your proposed changes
- Reaching out via Discord or email
- Contacting the maintainers directly
### Set up your local development environment
### 4.2 Set up your local development environment
You can find instructions on how to get your local development environment setup for the different parts of the AliasVault codebase here:
https://docs.aliasvault.net/misc/dev/
@@ -46,7 +60,7 @@ https://docs.aliasvault.net/misc/dev/
If you run into any issues, feel free to join our [Discord](https://discord.gg/DsaXMTEtpF) to chat with the maintainers and author.
## License and Contributions
## 5. License and Contributions
AliasVault is licensed under the GNU Affero General Public License v3.0 (AGPLv3). By submitting code, documentation, or other contributions to this project, you agree that:

View File

@@ -4,11 +4,14 @@ AliasVault is a privacy-first password and email alias manager. Create unique id
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github&label=Release">](https://github.com/lanedirt/AliasVault/releases)
[![.NET E2E Tests (with Sharding)](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-e2e-tests.yml/badge.svg)](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-e2e-tests.yml)
[<img src="https://img.shields.io/sonar/quality_gate/lanedirt_AliasVault?server=https%3A%2F%2Fsonarcloud.io&label=Sonarcloud&logo=sonarcloud">](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault)
[<img src="https://badges.crowdin.net/aliasvault/localized.svg">](https://crowdin.com/project/aliasvault)
[<img alt="Discord" src="https://img.shields.io/discord/1309300619026235422?logo=discord&logoColor=%237289da&label=Discord&color=%237289da">](https://discord.gg/DsaXMTEtpF)
<a href="https://app.aliasvault.net">Try the cloud version 🔥</a> | <a href="https://aliasvault.net?utm_source=gh-readme">Website </a> | <a href="https://docs.aliasvault.net?utm_source=gh-readme">Documentation </a> | <a href="#self-hosting">Self-host instructions</a>
⭐ Star us on GitHub it motivates us a lot!
**⭐ Star us on GitHub, it motivates us a lot!**
If you enjoy using AliasVault, please also consider leaving a review on our apps or browser extensions, and share it with your friends or colleagues. Your support helps others discover a privacy-first alternative to traditional & closed-source password managers.
## About
Built on 15 years of experience, AliasVault is independent, open-source, self-hostable and community-driven. Its the response to a web that tracks everything: a way to take back control of your digital privacy and help you stay secure online.
@@ -57,6 +60,7 @@ AliasVault is available on:
<p>
<a href="https://apps.apple.com/app/id6745490915" style="display: inline-block; margin-right: 20px;"><img src="https://github.com/user-attachments/assets/bad09b85-2635-4e3e-b154-9f348b88f6d6" style="height: 40px;margin-right:10px;" alt="Download on the App Store"></a>
<a href="https://play.google.com/store/apps/details?id=net.aliasvault.app" style="display: inline-block;"><img src="https://github.com/user-attachments/assets/b28979c9-f4b8-4090-8735-e384a7fdaa47" style="height: 40px;" alt="Get it on Google Play"></a>
<a href="https://f-droid.org/packages/net.aliasvault.app" style="display: inline-block;"><img src="https://github.com/user-attachments/assets/0fb25df1-0ea2-46a6-bfee-a9d70f22a02a" style="height: 40px;" alt="Get it on F-Droid"></a>
</p>
[<img width="700" alt="Screenshot of AliasVault" src="docs/assets/img/screenshot.png">](https://app.aliasvault.net)
@@ -68,11 +72,9 @@ For full control over your own data you can self-host and install AliasVault on
This method uses pre-built Docker images and works on minimal hardware specifications:
- Linux VM with root access (Ubuntu/AlmaLinux recommended) or Raspberry Pi
- 1 vCPU
- 1GB RAM
- 16GB disk space
- Docker (20.10+) and Docker Compose (2.0+)
- 64-bit Linux VM (Ubuntu/AlmaLinux) or Raspberry Pi, with root access
- Minimum: 1 vCPU, 1GB RAM, 16GB disk
- Docker ≥ 20.10 and Docker Compose ≥ 2.0
```bash
# Download install script from latest stable release
@@ -126,6 +128,7 @@ Core features that are being worked on:
- [x] iOS native app
- [x] Android native app
- [x] Editing in browser extension
- [x] Multi-language support across all client applications
- [ ] Data model and usability improvements (more flexible aliases and credential types, folder support, bulk selecting etc.)
- [ ] Support for FIDO2/WebAuthn hardware keys and passkeys
- [ ] Adding support for family/team sharing (organization features)

View File

@@ -1,22 +1,24 @@
{
"name": "aliasvault-browser-extension",
"version": "0.18.1",
"version": "0.21.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aliasvault-browser-extension",
"version": "0.18.1",
"version": "0.21.2",
"hasInstallScript": true,
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"argon2-browser": "^1.18.0",
"buffer": "^6.0.3",
"globals": "^16.0.0",
"i18next": "^25.3.1",
"otpauth": "^9.3.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.57.0",
"react-i18next": "^15.6.0",
"react-router-dom": "^7.5.2",
"secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0",
"sql.js": "^1.12.0",
@@ -47,7 +49,7 @@
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"vite-plugin-static-copy": "^2.2.0",
"vite-plugin-static-copy": "^2.3.2",
"wxt": "^0.20.6"
}
},
@@ -7014,6 +7016,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/htmlparser2": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz",
@@ -7079,6 +7090,46 @@
"node": ">= 14"
}
},
"node_modules/i18next": {
"version": "25.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz",
"integrity": "sha512-S4CPAx8LfMOnURnnJa8jFWvur+UX/LWcl6+61p9VV7SK2m0445JeBJ6tLD0D5SR0H29G4PYfWkEhivKG5p4RDg==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next/node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -10790,6 +10841,41 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-i18next": {
"version": "15.6.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.0.tgz",
"integrity": "sha512-W135dB0rDfiFmbMipC17nOhGdttO5mzH8BivY+2ybsQBbXvxWIwl3cmeH3T9d+YPBSJu/ouyJKFJTtkK7rJofw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-i18next/node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -12676,7 +12762,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -13074,9 +13160,9 @@
}
},
"node_modules/vite-plugin-static-copy": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-2.3.1.tgz",
"integrity": "sha512-EfsPcBm3ewg3UMG8RJaC0ADq6/qnUZnokXx4By4+2cAcipjT9i0Y0owIJGqmZI7d6nxk4qB1q5aXOwNuSyPdyA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-2.3.2.tgz",
"integrity": "sha512-iwrrf+JupY4b9stBttRWzGHzZbeMjAHBhkrn67MNACXJVjEMRpCI10Q3AkxdBkl45IHaTfw/CNVevzQhP7yTwg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -13189,6 +13275,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",

View File

@@ -2,7 +2,7 @@
"name": "aliasvault-browser-extension",
"description": "AliasVault Browser Extension",
"private": true,
"version": "0.20.1",
"version": "0.22.0",
"type": "module",
"scripts": {
"dev:chrome": "wxt -b chrome",
@@ -30,10 +30,12 @@
"argon2-browser": "^1.18.0",
"buffer": "^6.0.3",
"globals": "^16.0.0",
"i18next": "^25.3.1",
"otpauth": "^9.3.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.57.0",
"react-i18next": "^15.6.0",
"react-router-dom": "^7.5.2",
"secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0",
"sql.js": "^1.12.0",
@@ -64,7 +66,7 @@
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"vite-plugin-static-copy": "^2.2.0",
"vite-plugin-static-copy": "^2.3.2",
"wxt": "^0.20.6"
}
}

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>AliasVault Offscreen Document</title>
<!-- The offscreen.html is used for clipboard clearing. It is a hidden document that runs in a hidden context with access to clipboard operations. -->
</head>
<body>
<textarea id="text"></textarea>
<script src="offscreen.js"></script>
</body>
</html>

View File

@@ -0,0 +1,37 @@
/**
* Offscreen document for clipboard operations.
* This document runs in a hidden context with access to clipboard operations.
*/
// Listen for messages from the service worker
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'CLEAR_CLIPBOARD') {
clearClipboard()
.then(() => {
sendResponse({ success: true, message: 'Clipboard cleared successfully' });
})
.catch((error) => {
console.error('[OFFSCREEN] Failed to clear clipboard:', error);
sendResponse({ success: false, message: error.message });
});
// Return true to indicate we'll send response asynchronously
return true;
}
});
const textEl = document.querySelector('#text');
/**
* Clear the clipboard by writing a space using execCommand.
*/
async function clearClipboard() {
try {
// Use execCommand to clear clipboard
textEl.value = '\n';
textEl.select();
document.execCommand('copy');
} catch (error) {
console.error('[OFFSCREEN] Error clearing clipboard:', error);
throw error;
}
}

View File

@@ -447,7 +447,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
CURRENT_PROJECT_VERSION = 220001;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -460,7 +460,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 0.20.1;
MARKETING_VERSION = 0.22.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -479,7 +479,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "AliasVault Extension/AliasVault_Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
CURRENT_PROJECT_VERSION = 220001;
DEVELOPMENT_TEAM = 8PHW4HN3F7;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -492,7 +492,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 0.20.1;
MARKETING_VERSION = 0.22.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -515,7 +515,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 23;
CURRENT_PROJECT_VERSION = 220001;
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.20.1;
MARKETING_VERSION = 0.22.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -554,7 +554,7 @@
CODE_SIGN_ENTITLEMENTS = AliasVault/AliasVault.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 23;
CURRENT_PROJECT_VERSION = 220001;
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.20.1;
MARKETING_VERSION = 0.22.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,

View File

@@ -1,10 +1,13 @@
import { onMessage, sendMessage } from "webext-bridge/background";
import { handleResetAutoLockTimer, handlePopupHeartbeat, handleSetAutoLockTimeout } from '@/entrypoints/background/AutolockTimeoutHandler';
import { handleClipboardCopied, handleCancelClipboardClear, handleGetClipboardClearTimeout, handleSetClipboardClearTimeout, handleGetClipboardCountdownState } from '@/entrypoints/background/ClipboardClearHandler';
import { setupContextMenus } from '@/entrypoints/background/ContextMenu';
import { handleOpenPopup, handlePopupWithCredential, handleToggleContextMenu } from '@/entrypoints/background/PopupMessageHandler';
import { handleCheckAuthStatus, handleClearPersistedFormValues, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDefaultIdentitySettings, handleGetDerivedKey, handleGetPasswordSettings, handleGetPersistedFormValues, handleGetVault, handlePersistFormValues, handleStoreVault, handleSyncVault, handleUploadVault } from '@/entrypoints/background/VaultMessageHandler';
import { handleCheckAuthStatus, handleClearPersistedFormValues, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDefaultIdentitySettings, handleGetEncryptionKey, handleGetEncryptionKeyDerivationParams, handleGetPasswordSettings, handleGetPersistedFormValues, handleGetVault, handlePersistFormValues, handleStoreEncryptionKey, handleStoreEncryptionKeyDerivationParams, handleStoreVault, handleSyncVault, handleUploadVault } from '@/entrypoints/background/VaultMessageHandler';
import { GLOBAL_CONTEXT_MENU_ENABLED_KEY } from '@/utils/Constants';
import type { EncryptionKeyDerivationParams } from '@/utils/dist/shared/models/metadata';
import { defineBackground, storage, browser } from '#imports';
@@ -15,17 +18,26 @@ export default defineBackground({
async main() {
// Listen for messages using webext-bridge
onMessage('CHECK_AUTH_STATUS', () => handleCheckAuthStatus());
onMessage('STORE_VAULT', ({ data }) => handleStoreVault(data));
onMessage('UPLOAD_VAULT', ({ data }) => handleUploadVault(data));
onMessage('SYNC_VAULT', () => handleSyncVault());
onMessage('GET_ENCRYPTION_KEY', () => handleGetEncryptionKey());
onMessage('GET_ENCRYPTION_KEY_DERIVATION_PARAMS', () => handleGetEncryptionKeyDerivationParams());
onMessage('GET_VAULT', () => handleGetVault());
onMessage('CLEAR_VAULT', () => handleClearVault());
onMessage('GET_CREDENTIALS', () => handleGetCredentials());
onMessage('CREATE_IDENTITY', ({ data }) => handleCreateIdentity(data));
onMessage('GET_DEFAULT_EMAIL_DOMAIN', () => handleGetDefaultEmailDomain());
onMessage('GET_DEFAULT_IDENTITY_SETTINGS', () => handleGetDefaultIdentitySettings());
onMessage('GET_PASSWORD_SETTINGS', () => handleGetPasswordSettings());
onMessage('GET_DERIVED_KEY', () => handleGetDerivedKey());
onMessage('STORE_VAULT', ({ data }) => handleStoreVault(data));
onMessage('STORE_ENCRYPTION_KEY', ({ data }) => handleStoreEncryptionKey(data as string));
onMessage('STORE_ENCRYPTION_KEY_DERIVATION_PARAMS', ({ data }) => handleStoreEncryptionKeyDerivationParams(data as EncryptionKeyDerivationParams));
onMessage('CREATE_IDENTITY', ({ data }) => handleCreateIdentity(data));
onMessage('UPLOAD_VAULT', ({ data }) => handleUploadVault(data));
onMessage('SYNC_VAULT', () => handleSyncVault());
onMessage('CLEAR_VAULT', () => handleClearVault());
onMessage('OPEN_POPUP', () => handleOpenPopup());
onMessage('OPEN_POPUP_WITH_CREDENTIAL', ({ data }) => handlePopupWithCredential(data));
onMessage('TOGGLE_CONTEXT_MENU', ({ data }) => handleToggleContextMenu(data));
@@ -34,10 +46,25 @@ export default defineBackground({
onMessage('GET_PERSISTED_FORM_VALUES', () => handleGetPersistedFormValues());
onMessage('CLEAR_PERSISTED_FORM_VALUES', () => handleClearPersistedFormValues());
// Clipboard management messages
onMessage('CLIPBOARD_COPIED', () => handleClipboardCopied());
onMessage('CANCEL_CLIPBOARD_CLEAR', () => handleCancelClipboardClear());
onMessage('GET_CLIPBOARD_CLEAR_TIMEOUT', () => handleGetClipboardClearTimeout());
onMessage('SET_CLIPBOARD_CLEAR_TIMEOUT', ({ data }) => handleSetClipboardClearTimeout(data as number));
onMessage('GET_CLIPBOARD_COUNTDOWN_STATE', () => handleGetClipboardCountdownState());
// Auto-lock management messages
onMessage('RESET_AUTO_LOCK_TIMER', () => handleResetAutoLockTimer());
onMessage('SET_AUTO_LOCK_TIMEOUT', ({ data }) => handleSetAutoLockTimeout(data as number));
onMessage('POPUP_HEARTBEAT', () => handlePopupHeartbeat());
// Handle clipboard copied from context menu
onMessage('CLIPBOARD_COPIED_FROM_CONTEXT', () => handleClipboardCopied());
// Setup context menus
const isContextMenuEnabled = await storage.getItem(GLOBAL_CONTEXT_MENU_ENABLED_KEY) ?? true;
if (isContextMenuEnabled) {
setupContextMenus();
await setupContextMenus();
}
// Listen for custom commands
@@ -77,4 +104,4 @@ function getActiveElementIdentifier() : string {
return target.id || target.name || '';
}
return '';
}
}

View File

@@ -0,0 +1,113 @@
import { storage } from 'wxt/utils/storage';
import { handleClearVault } from '@/entrypoints/background/VaultMessageHandler';
import { AUTO_LOCK_TIMEOUT_KEY } from '@/utils/Constants';
let autoLockTimer: NodeJS.Timeout | null = null;
/**
* Reset the auto-lock timer.
*/
export function handleResetAutoLockTimer(): void {
resetAutoLockTimer();
}
/**
* Handle popup heartbeat - extend auto-lock timer.
*/
export function handlePopupHeartbeat(): void {
extendAutoLockTimer();
}
/**
* Set the auto-lock timeout setting.
*/
export async function handleSetAutoLockTimeout(timeout: number): Promise<boolean> {
await storage.setItem(AUTO_LOCK_TIMEOUT_KEY, timeout);
resetAutoLockTimer();
return true;
}
/**
* Reset the auto-lock timer based on current settings.
*/
async function resetAutoLockTimer(): Promise<void> {
// Clear existing timer
if (autoLockTimer) {
clearTimeout(autoLockTimer);
autoLockTimer = null;
}
// Get timeout setting
const timeout = await storage.getItem(AUTO_LOCK_TIMEOUT_KEY) as number ?? 0;
// Don't set timer if timeout is 0 (disabled) or if vault is already locked
if (timeout === 0) {
return;
}
// Check if vault is unlocked before setting timer
const encryptionKey = await storage.getItem('session:encryptionKey') as string | null;
if (!encryptionKey) {
// Vault is already locked, don't start timer
return;
}
// Set new timer
autoLockTimer = setTimeout(async () => {
try {
// Lock the vault using the existing handler
handleClearVault();
console.info('[AUTO_LOCK] Vault locked due to inactivity');
autoLockTimer = null;
} catch (error) {
console.error('[AUTO_LOCK] Error locking vault:', error);
}
}, timeout * 1000);
}
/**
* Extend the auto-lock timer by the full timeout period.
* This is called by popup heartbeats to prevent locking while popup is active.
*/
async function extendAutoLockTimer(): Promise<void> {
// Get timeout setting
const timeout = await storage.getItem(AUTO_LOCK_TIMEOUT_KEY) as number ?? 0;
// Don't extend timer if timeout is 0 (disabled)
if (timeout === 0) {
return;
}
// Check if vault is unlocked
const encryptionKey = await storage.getItem('session:encryptionKey') as string | null;
if (!encryptionKey) {
// Vault is already locked, don't extend timer
return;
}
// Clear existing timer and start a new one
if (autoLockTimer) {
clearTimeout(autoLockTimer);
autoLockTimer = null;
}
// Set new timer
autoLockTimer = setTimeout(async () => {
try {
// Lock the vault using the existing handler
handleClearVault();
console.info('[AUTO_LOCK] Vault locked due to inactivity');
autoLockTimer = null;
} catch (error) {
console.error('[AUTO_LOCK] Error locking vault:', error);
}
}, timeout * 1000);
console.info(`[AUTO_LOCK] Timer extended (popup heartbeat)`);
}

View File

@@ -0,0 +1,219 @@
import { sendMessage } from 'webext-bridge/background';
import { storage } from 'wxt/utils/storage';
import { CLIPBOARD_CLEAR_TIMEOUT_KEY } from '@/utils/Constants';
let clipboardClearTimer: NodeJS.Timeout | null = null;
let countdownInterval: NodeJS.Timeout | null = null;
let remainingTime = 0;
let currentCountdownId = 0;
let totalCountdownTime = 0;
let countdownStartTime = 0;
let offscreenDocumentCreated = false;
/**
* Create offscreen document if it doesn't exist.
*/
async function createOffscreenDocument(): Promise<void> {
if (offscreenDocumentCreated) {
return;
}
try {
// Check if chrome.offscreen API is available (Chrome 109+)
if (!chrome.offscreen) {
console.warn('[CLIPBOARD] Offscreen API not available, falling back to direct clipboard access');
return;
}
// Check if offscreen document already exists
if (chrome.runtime.getContexts) {
const existingContexts = await chrome.runtime.getContexts({
contextTypes: ['OFFSCREEN_DOCUMENT'],
documentUrls: [chrome.runtime.getURL('offscreen.html')]
});
if (existingContexts.length > 0) {
offscreenDocumentCreated = true;
return;
}
}
// Create offscreen document
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: [chrome.offscreen.Reason.CLIPBOARD],
justification: 'Clear clipboard after timeout for security'
});
offscreenDocumentCreated = true;
} catch (error) {
console.error('[CLIPBOARD] Failed to create offscreen document:', error);
offscreenDocumentCreated = false;
}
}
/**
* Clear clipboard using offscreen document or fallback method.
*/
async function clearClipboardContent(): Promise<void> {
if (import.meta.env.CHROME || import.meta.env.EDGE) {
/*
* Chrome and Edge use mv3 and do not have direct access to clipboard
* so we use an offscreen document to clear the clipboard.
*/
await createOffscreenDocument();
// Send message to offscreen document to clear clipboard
const response = await chrome.runtime.sendMessage({ type: 'CLEAR_CLIPBOARD' });
if (response?.success) {
console.info('[CLIPBOARD] Clipboard cleared via offscreen document');
} else {
throw new Error(response?.message || 'Failed to clear clipboard via offscreen');
}
} else {
// Firefox and Safari use mv2 and can use direct clipboard access.
await navigator.clipboard.writeText('');
}
}
/**
* Handle clipboard copied event - starts countdown and timer to clear clipboard.
*/
export async function handleClipboardCopied() : Promise<void> {
const timeout = await storage.getItem(CLIPBOARD_CLEAR_TIMEOUT_KEY) as number ?? 10;
// Clear any existing timer
if (clipboardClearTimer) {
clearTimeout(clipboardClearTimer);
clipboardClearTimer = null;
}
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
// Don't set timer if timeout is 0 (disabled)
if (timeout === 0) {
return;
}
// Generate new countdown ID
currentCountdownId = Date.now();
const thisCountdownId = currentCountdownId;
countdownStartTime = Date.now();
totalCountdownTime = timeout;
remainingTime = timeout;
// Send initial countdown immediately with ID
sendMessage('CLIPBOARD_COUNTDOWN', { remaining: remainingTime, total: timeout, id: thisCountdownId }, 'popup').catch(() => {});
// Send countdown updates to popup every 100ms for smooth animation
let elapsed = 0;
countdownInterval = setInterval(() => {
// Check if this countdown is still active
if (thisCountdownId !== currentCountdownId) {
if (countdownInterval) {
clearInterval(countdownInterval);
}
return;
}
elapsed += 0.1;
remainingTime = Math.max(0, timeout - elapsed);
sendMessage('CLIPBOARD_COUNTDOWN', { remaining: remainingTime, total: timeout, id: thisCountdownId }, 'popup').catch(() => {});
if (elapsed >= timeout && countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
}, 100);
// Set timer to clear clipboard
clipboardClearTimer = setTimeout(async () => {
try {
// Clear clipboard using offscreen document or fallback
await clearClipboardContent();
// Clean up regardless of success/failure
clipboardClearTimer = null;
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
// Reset countdown tracking
currentCountdownId = 0;
countdownStartTime = 0;
totalCountdownTime = 0;
sendMessage('CLIPBOARD_CLEARED', {}, 'popup').catch(() => {});
} catch (error) {
console.error('[CLIPBOARD] Error during clipboard clear:', error);
// Clean up even on error
clipboardClearTimer = null;
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
currentCountdownId = 0;
countdownStartTime = 0;
totalCountdownTime = 0;
sendMessage('CLIPBOARD_CLEARED', {}, 'popup').catch(() => {});
}
}, timeout * 1000);
}
/**
* Cancel clipboard clear countdown and timer.
*/
export function handleCancelClipboardClear(): void {
if (clipboardClearTimer) {
clearTimeout(clipboardClearTimer);
clipboardClearTimer = null;
}
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
sendMessage('CLIPBOARD_COUNTDOWN_CANCELLED', {}, 'popup').catch(() => {});
}
/**
* Get the clipboard clear timeout setting.
*/
export async function handleGetClipboardClearTimeout(): Promise<number> {
const timeout = await storage.getItem(CLIPBOARD_CLEAR_TIMEOUT_KEY) as number ?? 10;
return timeout;
}
/**
* Set the clipboard clear timeout setting.
*/
export async function handleSetClipboardClearTimeout(data: number): Promise<boolean> {
await storage.setItem(CLIPBOARD_CLEAR_TIMEOUT_KEY, data);
return true;
}
/**
* Get the current clipboard countdown state.
*/
export function handleGetClipboardCountdownState(): { remaining: number; total: number; id: number } | null {
// Calculate actual remaining time based on elapsed time
if (currentCountdownId && countdownStartTime && totalCountdownTime) {
const elapsed = (Date.now() - countdownStartTime) / 1000;
const actualRemaining = Math.max(0, totalCountdownTime - elapsed);
if (actualRemaining > 0) {
return {
remaining: actualRemaining,
total: totalCountdownTime,
id: currentCountdownId
};
}
}
return null;
}

View File

@@ -3,12 +3,14 @@ import { sendMessage } from 'webext-bridge/background';
import { PasswordGenerator } from '@/utils/dist/shared/password-generator';
import { t } from '@/i18n/StandaloneI18n';
import { browser } from "#imports";
/**
* Setup the context menus.
*/
export function setupContextMenus() : void {
export async function setupContextMenus() : Promise<void> {
// Create root menu
browser.contextMenus.create({
id: "aliasvault-root",
@@ -20,7 +22,7 @@ export function setupContextMenus() : void {
browser.contextMenus.create({
id: "aliasvault-activate-form",
parentId: "aliasvault-root",
title: "Autofill with AliasVault",
title: await t('content.autofillWithAliasVault'),
contexts: ["editable"],
});
@@ -36,7 +38,7 @@ export function setupContextMenus() : void {
browser.contextMenus.create({
id: "aliasvault-generate-password",
parentId: "aliasvault-root",
title: "Generate random password (copy to clipboard)",
title: await t('content.generateRandomPassword'),
contexts: ["all"]
});
@@ -56,10 +58,13 @@ export function handleContextMenuClick(info: Browser.contextMenus.OnClickData, t
// Use browser.scripting to write password to clipboard from active tab
if (tab?.id) {
browser.scripting.executeScript({
target: { tabId: tab.id },
func: copyPasswordToClipboard,
args: [password]
// Get confirm text translation.
t('content.passwordCopiedToClipboard').then((message) => {
browser.scripting.executeScript({
target: { tabId: tab.id },
func: copyPasswordToClipboard,
args: [message, password]
});
});
}
} else if (info.menuItemId === "aliasvault-activate-form" && tab?.id) {
@@ -80,9 +85,9 @@ export function handleContextMenuClick(info: Browser.contextMenus.OnClickData, t
/**
* Copy provided password to clipboard.
*/
function copyPasswordToClipboard(generatedPassword: string) : void {
function copyPasswordToClipboard(message: string, generatedPassword: string) : void {
navigator.clipboard.writeText(generatedPassword).then(() => {
showToast('Password copied to clipboard');
showToast(message);
});
/**

View File

@@ -45,7 +45,7 @@ export function handleToggleContextMenu(message: any) : Promise<BoolResponse> {
if (!message.enabled) {
browser.contextMenus.removeAll();
} else {
setupContextMenus();
await setupContextMenus();
}
return { success: true };
})();

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { storage } from 'wxt/utils/storage';
import type { EncryptionKeyDerivationParams } from '@/utils/dist/shared/models/metadata';
import type { Vault, VaultResponse, VaultPostResponse } from '@/utils/dist/shared/models/webapi';
import { EncryptionUtility } from '@/utils/EncryptionUtility';
import { SqliteClient } from '@/utils/SqliteClient';
@@ -14,6 +15,8 @@ import { VaultResponse as messageVaultResponse } from '@/utils/types/messaging/V
import { VaultUploadResponse as messageVaultUploadResponse } from '@/utils/types/messaging/VaultUploadResponse';
import { WebApiService } from '@/utils/WebApiService';
import { t } from '@/i18n/StandaloneI18n';
/**
* Check if the user is logged in and if the vault is locked, and also check for pending migrations.
*/
@@ -58,7 +61,7 @@ export async function handleCheckAuthStatus() : Promise<{ isLoggedIn: boolean, i
isLoggedIn,
isVaultLocked,
hasPendingMigrations: false,
error: error instanceof Error ? error.message : 'An unknown error occurred'
error: error instanceof Error ? error.message : await t('common.errors.unknownError')
};
}
}
@@ -80,11 +83,6 @@ export async function handleStoreVault(
* Some updates, e.g. when mutating local database, these values will not be set.
*/
// Store derived key in session storage (if it has a value)
if (vaultRequest.derivedKey) {
await storage.setItem('session:derivedKey', vaultRequest.derivedKey);
}
if (vaultRequest.publicEmailDomainList) {
await storage.setItem('session:publicEmailDomains', vaultRequest.publicEmailDomainList);
}
@@ -100,7 +98,37 @@ export async function handleStoreVault(
return { success: true };
} catch (error) {
console.error('Failed to store vault:', error);
return { success: false, error: 'Failed to store vault' };
return { success: false, error: await t('common.errors.failedToStoreVault') };
}
}
/**
* Store the encryption key (derived key) in browser storage.
*/
export async function handleStoreEncryptionKey(
encryptionKey: string,
) : Promise<messageBoolResponse> {
try {
await storage.setItem('session:encryptionKey', encryptionKey);
return { success: true };
} catch (error) {
console.error('Failed to store encryption key:', error);
return { success: false, error: await t('common.errors.failedToStoreEncryptionKey') };
}
}
/**
* Store the encryption key derivation parameters in browser storage.
*/
export async function handleStoreEncryptionKeyDerivationParams(
params: EncryptionKeyDerivationParams,
) : Promise<messageBoolResponse> {
try {
await storage.setItem('session:encryptionKeyDerivationParams', params);
return { success: true };
} catch (error) {
console.error('Failed to store encryption key derivation params:', error);
return { success: false, error: await t('common.errors.failedToStoreEncryptionParams') };
}
}
@@ -113,7 +141,7 @@ export async function handleSyncVault(
const statusResponse = await webApi.getStatus();
const statusError = webApi.validateStatusResponse(statusResponse);
if (statusError !== null) {
return { success: false, error: statusError };
return { success: false, error: await t('common.errors.' + statusError) };
}
const vaultRevisionNumber = await storage.getItem('session:vaultRevisionNumber') as number;
@@ -139,20 +167,26 @@ export async function handleSyncVault(
export async function handleGetVault(
) : Promise<messageVaultResponse> {
try {
const encryptionKey = await handleGetEncryptionKey();
const encryptedVault = await storage.getItem('session:encryptedVault') as string;
const derivedKey = await storage.getItem('session:derivedKey') as string;
const publicEmailDomains = await storage.getItem('session:publicEmailDomains') as string[];
const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[];
const vaultRevisionNumber = await storage.getItem('session:vaultRevisionNumber') as number;
if (!encryptedVault) {
console.error('Vault not available');
return { success: false, error: 'Vault not available' };
return { success: false, error: await t('common.errors.vaultNotAvailable') };
}
if (!encryptionKey) {
console.error('Encryption key not available');
return { success: false, error: await t('common.errors.vaultIsLocked') };
}
const decryptedVault = await EncryptionUtility.symmetricDecrypt(
encryptedVault,
derivedKey
encryptionKey
);
return {
@@ -164,7 +198,7 @@ export async function handleGetVault(
};
} catch (error) {
console.error('Failed to get vault:', error);
return { success: false, error: 'Failed to get vault' };
return { success: false, error: await t('common.errors.failedToRetrieveData') };
}
}
@@ -175,7 +209,10 @@ export function handleClearVault(
) : messageBoolResponse {
storage.removeItems([
'session:encryptedVault',
'session:encryptionKey',
// TODO: the derivedKey clear can be removed some period of time after 0.22.0 is released.
'session:derivedKey',
'session:encryptionKeyDerivationParams',
'session:publicEmailDomains',
'session:privateEmailDomains',
'session:vaultRevisionNumber'
@@ -189,10 +226,10 @@ export function handleClearVault(
*/
export async function handleGetCredentials(
) : Promise<messageCredentialsResponse> {
const derivedKey = await storage.getItem('session:derivedKey') as string;
const encryptionKey = await handleGetEncryptionKey();
if (!derivedKey) {
return { success: false, error: 'Vault is locked' };
if (!encryptionKey) {
return { success: false, error: await t('common.errors.vaultIsLocked') };
}
try {
@@ -201,7 +238,7 @@ export async function handleGetCredentials(
return { success: true, credentials: credentials };
} catch (error) {
console.error('Error getting credentials:', error);
return { success: false, error: 'Failed to get credentials' };
return { success: false, error: await t('common.errors.failedToRetrieveData') };
}
}
@@ -211,17 +248,17 @@ export async function handleGetCredentials(
export async function handleCreateIdentity(
message: any,
) : Promise<messageBoolResponse> {
const derivedKey = await storage.getItem('session:derivedKey') as string;
const encryptionKey = await handleGetEncryptionKey();
if (!derivedKey) {
return { success: false, error: 'Vault is locked' };
if (!encryptionKey) {
return { success: false, error: await t('common.errors.vaultIsLocked') };
}
try {
const sqliteClient = await createVaultSqliteClient();
// Add the new credential to the vault/database.
sqliteClient.createCredential(message.credential);
await sqliteClient.createCredential(message.credential, message.attachments || []);
// Upload the new vault to the server.
await uploadNewVaultToServer(sqliteClient);
@@ -229,7 +266,7 @@ export async function handleCreateIdentity(
return { success: true };
} catch (error) {
console.error('Failed to create identity:', error);
return { success: false, error: 'Failed to create identity' };
return { success: false, error: await t('common.errors.unknownError') };
}
}
@@ -271,7 +308,7 @@ export function handleGetDefaultEmailDomain(): Promise<stringResponse> {
return { success: true, value: defaultEmailDomain ?? undefined };
} catch (error) {
console.error('Error getting default email domain:', error);
return { success: false, error: 'Failed to get default email domain' };
return { success: false, error: await t('common.errors.failedToRetrieveData') };
}
})();
}
@@ -295,7 +332,7 @@ export async function handleGetDefaultIdentitySettings(
};
} catch (error) {
console.error('Error getting default identity settings:', error);
return { success: false, error: 'Failed to get default identity settings' };
return { success: false, error: await t('common.errors.failedToRetrieveData') };
}
}
@@ -311,17 +348,34 @@ export async function handleGetPasswordSettings(
return { success: true, settings: passwordSettings };
} catch (error) {
console.error('Error getting password settings:', error);
return { success: false, error: 'Failed to get password settings' };
return { success: false, error: await t('common.errors.failedToRetrieveData') };
}
}
/**
* Get the derived key for the encrypted vault.
* Get the encryption key for the encrypted vault.
*/
export async function handleGetDerivedKey(
) : Promise<string> {
const derivedKey = await storage.getItem('session:derivedKey') as string;
return derivedKey;
export async function handleGetEncryptionKey(
) : Promise<string | null> {
// Try the current key name first (since 0.22.0)
let encryptionKey = await storage.getItem('session:encryptionKey') as string | null;
// Fall back to the legacy key name if not found
if (!encryptionKey) {
// TODO: this check can be removed some period of time after 0.22.0 is released.
encryptionKey = await storage.getItem('session:derivedKey') as string | null;
}
return encryptionKey;
}
/**
* Get the encryption key derivation parameters for password change detection and offline mode.
*/
export async function handleGetEncryptionKeyDerivationParams(
) : Promise<EncryptionKeyDerivationParams | null> {
const params = await storage.getItem('session:encryptionKeyDerivationParams') as EncryptionKeyDerivationParams | null;
return params;
}
/**
@@ -342,7 +396,7 @@ export async function handleUploadVault(
return { success: true, status: response.status, newRevisionNumber: response.newRevisionNumber };
} catch (error) {
console.error('Failed to upload vault:', error);
return { success: false, error: 'Failed to upload vault' };
return { success: false, error: await t('common.errors.failedToUploadVault') };
}
}
@@ -351,16 +405,16 @@ export async function handleUploadVault(
* Data is encrypted using the derived key for additional security.
*/
export async function handlePersistFormValues(data: any): Promise<void> {
const derivedKey = await storage.getItem('session:derivedKey') as string;
if (!derivedKey) {
throw new Error('No derived key available for encryption');
const encryptionKey = await handleGetEncryptionKey();
if (!encryptionKey) {
throw new Error(await t('common.errors.unknownError'));
}
// Always stringify the data properly
const serializedData = JSON.stringify(data);
const encryptedData = await EncryptionUtility.symmetricEncrypt(
serializedData,
derivedKey
encryptionKey
);
await storage.setItem('session:persistedFormValues', encryptedData);
}
@@ -370,17 +424,17 @@ export async function handlePersistFormValues(data: any): Promise<void> {
* Data is decrypted using the derived key.
*/
export async function handleGetPersistedFormValues(): Promise<any | null> {
const derivedKey = await storage.getItem('session:derivedKey') as string;
const encryptionKey = await handleGetEncryptionKey();
const encryptedData = await storage.getItem('session:persistedFormValues') as string | null;
if (!encryptedData || !derivedKey) {
if (!encryptedData || !encryptionKey) {
return null;
}
try {
const decryptedData = await EncryptionUtility.symmetricDecrypt(
encryptedData,
derivedKey
encryptionKey
);
return JSON.parse(decryptedData);
} catch (error) {
@@ -401,11 +455,15 @@ export async function handleClearPersistedFormValues(): Promise<void> {
*/
async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<VaultPostResponse> {
const updatedVaultData = sqliteClient.exportToBase64();
const derivedKey = await storage.getItem('session:derivedKey') as string;
const encryptionKey = await handleGetEncryptionKey();
if (!encryptionKey) {
throw new Error(await t('common.errors.vaultIsLocked'));
}
const encryptedVault = await EncryptionUtility.symmetricEncrypt(
updatedVaultData,
derivedKey
encryptionKey
);
await storage.setItems([
@@ -441,7 +499,7 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<Vaul
if (response.status === 0) {
await storage.setItem('session:vaultRevisionNumber', response.newRevisionNumber);
} else {
throw new Error('Failed to upload new vault to server');
throw new Error(await t('common.errors.failedToUploadVault'));
}
return response;
@@ -452,15 +510,15 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<Vaul
*/
async function createVaultSqliteClient() : Promise<SqliteClient> {
const encryptedVault = await storage.getItem('session:encryptedVault') as string;
const derivedKey = await storage.getItem('session:derivedKey') as string;
if (!encryptedVault || !derivedKey) {
throw new Error('No vault or derived key found');
const encryptionKey = await handleGetEncryptionKey();
if (!encryptedVault || !encryptionKey) {
throw new Error(await t('common.errors.unknownError'));
}
// Decrypt the vault.
const decryptedVault = await EncryptionUtility.symmetricDecrypt(
encryptedVault,
derivedKey
encryptionKey
);
// Initialize the SQLite client with the decrypted vault.

View File

@@ -7,8 +7,9 @@ import { isAutoShowPopupEnabled, openAutofillPopup, removeExistingPopup, createU
import { FormDetector } from '@/utils/formDetector/FormDetector';
import { BoolResponse as messageBoolResponse } from '@/utils/types/messaging/BoolResponse';
import { defineContentScript } from '#imports';
import { createShadowRootUi } from '#imports';
import { t } from '@/i18n/StandaloneI18n';
import { defineContentScript, createShadowRootUi } from '#imports';
export default defineContentScript({
matches: ['<all_urls>'],
@@ -33,6 +34,7 @@ export default defineContentScript({
name: 'aliasvault-ui',
position: 'inline',
anchor: 'body',
mode: 'closed',
/**
* Handle mount.
*/
@@ -159,13 +161,13 @@ export default defineContentScript({
if (authStatus.hasPendingMigrations) {
// Show upgrade required popup
createUpgradeRequiredPopup(inputElement, container, 'Vault upgrade required.');
await createUpgradeRequiredPopup(inputElement, container, await t('content.vaultUpgradeRequired'));
return;
}
if (authStatus.error) {
// Show upgrade required popup for version-related errors
createUpgradeRequiredPopup(inputElement, container, authStatus.error);
await createUpgradeRequiredPopup(inputElement, container, authStatus.error);
return;
}

View File

@@ -1,108 +1,212 @@
import type { Credential } from '@/utils/dist/shared/models/vault';
import { CombinedStopWords } from '@/utils/formDetector/FieldPatterns';
export enum AutofillMatchingMode {
DEFAULT = 'default',
URL_EXACT = 'url_exact',
URL_SUBDOMAIN = 'url_subdomain'
}
type CredentialWithPriority = Credential & {
priority: number;
}
/**
* Filter credentials based on current URL and page context to determine which credentials to show
* 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)
* Extract domain from URL, handling both full URLs and partial domains
* @param url - URL or domain string
* @returns Normalized domain without protocol or www
*/
export function filterCredentials(credentials: Credential[], currentUrl: string, pageTitle: string): Credential[] {
const urlObject = new URL(currentUrl);
const baseUrl = `${urlObject.protocol}//${urlObject.hostname}`;
const filtered: CredentialWithPriority[] = [];
const sanitizedCurrentUrl = currentUrl.toLowerCase().replace('www.', '');
// 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;
function extractDomain(url: string): string {
if (!url) {
return '';
}
// Prepare page title words for matching
const titleWords = pageTitle.length > 0
? pageTitle.toLowerCase()
.split(/\s+/)
.filter(word =>
word.length > 3 &&
!CombinedStopWords.has(word.toLowerCase())
)
: [];
// Remove protocol if present
let domain = url.toLowerCase().trim();
domain = domain.replace(/^https?:\/\//, '');
// Check for base URL matches and page title matches
// Remove www. prefix
domain = domain.replace(/^www\./, '');
// Remove path, query, and fragment
domain = domain.split('/')[0];
domain = domain.split('?')[0];
domain = domain.split('#')[0];
return domain;
}
/**
* Check if two domains match, supporting partial matches
* @param domain1 - First domain
* @param domain2 - Second domain
* @returns True if domains match (including partial matches)
*/
function domainsMatch(domain1: string, domain2: string): boolean {
if (!domain1 || !domain2) {
return false;
}
const d1 = extractDomain(domain1);
const d2 = extractDomain(domain2);
// Exact match
if (d1 === d2) {
return true;
}
// Check if one domain contains the other (for subdomain matching)
if (d1.includes(d2) || d2.includes(d1)) {
return true;
}
// Extract root domains for comparison
const d1Parts = d1.split('.');
const d2Parts = d2.split('.');
// Get the last 2 parts (domain.tld) for comparison
const d1Root = d1Parts.slice(-2).join('.');
const d2Root = d2Parts.slice(-2).join('.');
return d1Root === d2Root;
}
/**
* Extract meaningful words from text, removing punctuation and filtering stop words
* @param text - Text to extract words from
* @returns Array of filtered words
*/
function extractWords(text: string): string[] {
if (!text || text.length === 0) {
return [];
}
return text.toLowerCase()
// Replace common separators and punctuation with spaces
.replace(/[|,;:\-–—/\\()[\]{}'"`~!@#$%^&*+=<>?]/g, ' ')
// Split on whitespace and filter
.split(/\s+/)
.filter(word =>
word.length > 3 &&
!CombinedStopWords.has(word)
);
}
/**
* Filter credentials based on current URL and page context with anti-phishing protection.
*
* **Security Note**: When searching with a URL, text search fallback only applies to
* credentials with no service URL defined. This prevents phishing attacks where a
* malicious site might match credentials intended for the legitimate site.
*
* Credentials are sorted by priority:
* 1. Exact domain match (priority 1 - highest)
* 2. Partial/subdomain match (priority 2)
* 3. Service name fallback match (priority 5 - lowest, only for credentials without URLs)
*/
export function filterCredentials(credentials: Credential[], currentUrl: string, pageTitle: string, matchingMode: AutofillMatchingMode = AutofillMatchingMode.DEFAULT): Credential[] {
const filtered: CredentialWithPriority[] = [];
const currentDomain = extractDomain(currentUrl);
// Determine feature flags based on matching mode
let enableExactMatch = false;
let enableSubdomainMatch = false;
let enableServiceNameFallback = false;
switch (matchingMode) {
case AutofillMatchingMode.URL_EXACT:
enableExactMatch = true;
enableSubdomainMatch = false;
enableServiceNameFallback = false;
break;
case AutofillMatchingMode.URL_SUBDOMAIN:
enableExactMatch = true;
enableSubdomainMatch = true;
enableServiceNameFallback = false;
break;
case AutofillMatchingMode.DEFAULT:
enableExactMatch = true;
enableSubdomainMatch = true;
enableServiceNameFallback = true;
break;
}
// Process credentials with service URLs
credentials.forEach(cred => {
if (!cred.ServiceUrl || filtered.some(f => f.Id === cred.Id)) {
if (!cred.ServiceUrl || cred.ServiceUrl.length === 0) {
return; // Handle these in service name fallback
}
const credDomain = extractDomain(cred.ServiceUrl);
// Check for exact match (priority 1)
if (enableExactMatch && currentDomain === credDomain) {
filtered.push({ ...cred, priority: 1 });
return;
}
let hasBaseUrlMatch = false;
let hasTitleMatch = false;
// Check base URL match
try {
const credUrlObject = new URL(cred.ServiceUrl);
const currentUrlObject = new URL(baseUrl);
const credDomainParts = credUrlObject.hostname.toLowerCase().split('.');
const currentDomainParts = currentUrlObject.hostname.toLowerCase().split('.');
const credRootDomain = credDomainParts.slice(-2).join('.');
const currentRootDomain = currentDomainParts.slice(-2).join('.');
if (credUrlObject.protocol === currentUrlObject.protocol &&
credRootDomain === currentRootDomain) {
hasBaseUrlMatch = true;
}
} catch {
// Invalid URL, skip
}
// 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))
);
}
// Assign priority based on matches
if (hasBaseUrlMatch && hasTitleMatch) {
// Check for subdomain/partial match (priority 2)
if (enableSubdomainMatch && domainsMatch(currentDomain, credDomain)) {
filtered.push({ ...cred, priority: 2 });
} else if (hasBaseUrlMatch) {
filtered.push({ ...cred, priority: 3 });
} else if (hasTitleMatch) {
filtered.push({ ...cred, priority: 4 });
return;
}
});
// Sort by priority and then take unique credentials
// Service name fallback for credentials without URLs (priority 5)
if (enableServiceNameFallback) {
/*
* SECURITY: Service name matching only applies to credentials with no service URL.
* This prevents phishing attacks where a malicious site might match credentials
* intended for a legitimate site.
*/
// Extract words from page title
const titleWords = extractWords(pageTitle);
if (titleWords.length > 0) {
credentials.forEach(cred => {
// CRITICAL: Only check credentials that have NO service URL defined
if (cred.ServiceUrl && cred.ServiceUrl.length > 0) {
return;
}
// Skip if already in filtered list
if (filtered.some(f => f.Id === cred.Id)) {
return;
}
// Check page title match with service name
if (cred.ServiceName) {
const credNameWords = extractWords(cred.ServiceName);
/*
* Match only complete words, not substrings
* For example: "Express" should match "My Express Account" but not "AliExpress"
*/
const hasTitleMatch = titleWords.some(titleWord =>
credNameWords.some(credWord =>
titleWord === credWord // Exact word match only
)
);
if (hasTitleMatch) {
filtered.push({ ...cred, priority: 5 });
}
}
});
}
}
// Sort by priority and return unique credentials (max 3)
const uniqueCredentials = Array.from(
new Map(filtered
.sort((a, b) => a.priority - b.priority)
.map(cred => [cred.Id, cred]))
.values()
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

@@ -1,8 +1,11 @@
import { sendMessage } from 'webext-bridge/content-script';
import { openAutofillPopup } from '@/entrypoints/contentScript/Popup';
import type { Credential } from '@/utils/dist/shared/models/vault';
import { FormDetector } from '@/utils/formDetector/FormDetector';
import { FormFiller } from '@/utils/formDetector/FormFiller';
import { ClickValidator } from '@/utils/security/ClickValidator';
/**
* Global timestamp to track popup debounce time.
@@ -12,6 +15,11 @@ import { FormFiller } from '@/utils/formDetector/FormFiller';
*/
let popupDebounceTime = 0;
/**
* ClickValidator instance for form security validation
*/
const clickValidator = ClickValidator.getInstance();
/**
* Check if popup can be shown based on debounce time.
*/
@@ -32,6 +40,8 @@ export function hidePopupFor(ms: number) : void {
/**
* Validates if an element is a supported input field that can be processed for autofill.
* This function supports regular input elements, custom elements with type attributes,
* and custom web components that may contain shadow DOM.
* @param element The element to validate
* @returns An object containing validation result and the element cast as HTMLInputElement if valid
*/
@@ -42,14 +52,30 @@ export function validateInputField(element: Element | null): { isValid: boolean;
const textInputTypes = ['text', 'email', 'tel', 'password', 'search', 'url', 'number'];
const elementType = element.getAttribute('type');
const isInputElement = element.tagName.toLowerCase() === 'input';
const tagName = element.tagName.toLowerCase();
const isInputElement = tagName === 'input';
// Check if element has shadow DOM with input elements
const elementWithShadow = element as HTMLElement & { shadowRoot?: ShadowRoot };
const hasShadowDOMInput = elementWithShadow.shadowRoot &&
elementWithShadow.shadowRoot.querySelector('input, textarea');
// Check if it's a custom element that might be an input
const isLikelyCustomInputElement = tagName.includes('-') && (
tagName.includes('input') ||
tagName.includes('field') ||
tagName.includes('text') ||
hasShadowDOMInput
);
// 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()))
(!isInputElement && elementType && textInputTypes.includes(elementType.toLowerCase())) ||
// Case 3: It's a custom element that likely contains an input
(isLikelyCustomInputElement)
) as boolean;
return {
@@ -64,10 +90,15 @@ export function validateInputField(element: Element | null): { isValid: boolean;
* @param credential - The credential to fill.
* @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 {
export async function fillCredential(credential: Credential, input: HTMLInputElement): Promise<void> {
// Set debounce time to 300ms to prevent the popup from being shown again within 300ms because of autofill events.
hidePopupFor(300);
// Reset auto-lock timer when autofilling
sendMessage('RESET_AUTO_LOCK_TIMER', {}, 'background').catch(() => {
// Ignore errors as background script might not be ready
});
const formDetector = new FormDetector(document, input);
const form = formDetector.getForm();
@@ -77,7 +108,7 @@ export function fillCredential(credential: Credential, input: HTMLInputElement)
}
const formFiller = new FormFiller(form, triggerInputEvents);
formFiller.fillFields(credential);
await formFiller.fillFields(credential);
}
/**
@@ -98,7 +129,7 @@ function findActualInput(element: HTMLElement): HTMLInputElement {
return element as HTMLInputElement;
}
// Try to find a visible child input
// Try to find a visible child input in regular DOM
const childInput = element.querySelector('input');
if (childInput) {
const style = window.getComputedStyle(childInput);
@@ -107,6 +138,17 @@ function findActualInput(element: HTMLElement): HTMLInputElement {
}
}
// Try to find input in shadow DOM if element has shadowRoot
if (element.shadowRoot) {
const shadowInput = element.shadowRoot.querySelector('input');
if (shadowInput) {
const style = window.getComputedStyle(shadowInput);
if (style.display !== 'none' && style.visibility !== 'hidden') {
return shadowInput as HTMLInputElement;
}
}
}
// Fallback to the provided element if no child input found
return element as HTMLInputElement;
}
@@ -179,9 +221,16 @@ export function injectIcon(input: HTMLInputElement, container: HTMLElement): voi
window.addEventListener('resize', updateIconPosition);
// Add click event to trigger the autofill popup and refocus the input
icon.addEventListener('click', (e: MouseEvent) => {
icon.addEventListener('click', async (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Validate the click for security
if (!await clickValidator.validateClick(e)) {
console.warn('[AliasVault Security] Blocked autofill popup opening due to security validation failure');
return;
}
setTimeout(() => actualInput.focus(), 0);
openAutofillPopup(actualInput, container);
});

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,347 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type { Credential } from '@/utils/dist/shared/models/vault';
import { filterCredentials } from '../Filter';
describe('Filter - Credential URL Matching', () => {
let testCredentials: Credential[];
beforeEach(() => {
// Create test credentials using shared test data structure
testCredentials = createSharedTestCredentials();
});
// [#1] - Exact URL match
it('should match exact URL', () => {
const matches = filterCredentials(
testCredentials,
'www.coolblue.nl',
''
);
expect(matches).toHaveLength(1);
expect(matches[0].ServiceName).toBe('Coolblue');
});
// [#2] - Base URL with path match
it('should match base URL with path', () => {
const matches = filterCredentials(
testCredentials,
'https://gmail.com/signin',
''
);
expect(matches).toHaveLength(1);
expect(matches[0].ServiceName).toBe('Gmail');
});
// [#3] - Root domain with subdomain match
it('should match root domain with subdomain', () => {
const matches = filterCredentials(
testCredentials,
'https://mail.google.com',
''
);
expect(matches).toHaveLength(1);
expect(matches[0].ServiceName).toBe('Google');
});
// [#4] - No matches for non-existent domain
it('should return empty array for no matches', () => {
const matches = filterCredentials(
testCredentials,
'https://nonexistent.com',
''
);
expect(matches).toHaveLength(0);
});
// [#5] - Partial URL stored matches full URL search
it('should match partial URL with full URL - dumpert.nl case', () => {
// Test case: stored URL is "dumpert.nl", search with full URL
const matches = filterCredentials(
testCredentials,
'https://www.dumpert.nl',
''
);
expect(matches).toHaveLength(1);
expect(matches[0].ServiceName).toBe('Dumpert');
});
// [#6] - Full URL stored matches partial URL search
it('should match full URL with partial URL', () => {
const matches = filterCredentials(
testCredentials,
'coolblue.nl',
''
);
expect(matches).toHaveLength(1);
expect(matches[0].ServiceName).toBe('Coolblue');
});
// [#7] - Protocol variations (http/https/none) match
it('should handle protocol variations correctly', () => {
// Test that http and https variations match
const httpsMatches = filterCredentials(
testCredentials,
'https://github.com',
''
);
const httpMatches = filterCredentials(
testCredentials,
'http://github.com',
''
);
const noProtocolMatches = filterCredentials(
testCredentials,
'https://github.com', // Converting no-protocol to https for test
''
);
expect(httpsMatches).toHaveLength(1);
expect(httpMatches).toHaveLength(1);
expect(noProtocolMatches).toHaveLength(1);
expect(httpsMatches[0].ServiceName).toBe('GitHub');
expect(httpMatches[0].ServiceName).toBe('GitHub');
expect(noProtocolMatches[0].ServiceName).toBe('GitHub');
});
// [#8] - WWW prefix variations match
it('should handle www variations correctly', () => {
// Test that www variations match
const withWww = filterCredentials(
testCredentials,
'https://www.dumpert.nl',
''
);
const withoutWww = filterCredentials(
testCredentials,
'https://dumpert.nl',
''
);
expect(withWww).toHaveLength(1);
expect(withoutWww).toHaveLength(1);
expect(withWww[0].ServiceName).toBe('Dumpert');
expect(withoutWww[0].ServiceName).toBe('Dumpert');
});
// [#9] - Subdomain matching
it('should handle subdomain matching', () => {
// Test subdomain matching
const appSubdomain = filterCredentials(
testCredentials,
'https://app.example.com',
''
);
const wwwSubdomain = filterCredentials(
testCredentials,
'https://www.example.com',
''
);
const noSubdomain = filterCredentials(
testCredentials,
'https://example.com',
''
);
expect(appSubdomain).toHaveLength(1);
expect(appSubdomain[0].ServiceName).toBe('Subdomain Example');
expect(wwwSubdomain).toHaveLength(1);
expect(wwwSubdomain[0].ServiceName).toBe('Subdomain Example');
expect(noSubdomain).toHaveLength(1);
expect(noSubdomain[0].ServiceName).toBe('Subdomain Example');
});
// [#10] - Paths and query strings ignored
it('should ignore paths and query strings', () => {
// Test that paths and query strings are ignored
const withPath = filterCredentials(
testCredentials,
'https://github.com/user/repo',
''
);
const withQuery = filterCredentials(
testCredentials,
'https://stackoverflow.com/questions?tab=newest',
''
);
const withFragment = filterCredentials(
testCredentials,
'https://gmail.com#inbox',
''
);
expect(withPath).toHaveLength(1);
expect(withPath[0].ServiceName).toBe('GitHub');
expect(withQuery).toHaveLength(1);
expect(withQuery[0].ServiceName).toBe('Stack Overflow');
expect(withFragment).toHaveLength(1);
expect(withFragment[0].ServiceName).toBe('Gmail');
});
// [#11] - Complex URL variations
it('should handle complex URL variations', () => {
// Test complex URL matching scenario
const complexUrl = filterCredentials(
testCredentials,
'https://www.coolblue.nl/product/12345?ref=google',
''
);
expect(complexUrl).toHaveLength(1);
expect(complexUrl[0].ServiceName).toBe('Coolblue');
});
// [#12] - Priority ordering
it('should handle priority ordering', () => {
const matches = filterCredentials(
testCredentials,
'coolblue.nl',
''
);
expect(matches).toHaveLength(1);
expect(matches[0].ServiceName).toBe('Coolblue');
});
// [#13] - Title-only matching
it('should match title only', () => {
const matches = filterCredentials(
testCredentials,
'https://nomatch.com',
'newyorktimes'
);
expect(matches).toHaveLength(1);
expect(matches[0].ServiceName).toBe('Title Only newyorktimes');
});
/* [#14] - Domain name part matching */
it('should handle domain name part matching', () => {
const matches = filterCredentials(
testCredentials,
'https://coolblue.be',
''
);
expect(matches).toHaveLength(0);
});
// [#15] - Package name matching
it('should handle package name matching', () => {
const matches = filterCredentials(
testCredentials,
'com.coolblue.app',
''
);
expect(matches).toHaveLength(1);
expect(matches[0].ServiceName).toBe('Coolblue App');
});
// [#16] - Invalid URL handling
it('should handle invalid URL', () => {
const matches = filterCredentials(
testCredentials,
'not a url',
''
);
expect(matches).toHaveLength(0);
});
// [#17] - Anti-phishing protection
it('should handle anti-phishing protection', () => {
const matches = filterCredentials(
testCredentials,
'https://secure-bankk.com',
''
);
expect(matches).toHaveLength(0);
});
// [#18] - Ensure only full words are matched
it('should not match on string part of word', () => {
const matches = filterCredentials(
testCredentials,
'',
'Express Yourself App | Description'
);
// The string above should not match "AliExpress" service name
expect(matches).toHaveLength(0);
});
// [#19] - Ensure separators and punctuation are stripped for matching
it('should match service names when separated by commas and other punctuation', () => {
const matches = filterCredentials(
testCredentials,
'https://nomatch.com',
'Reddit, social media platform'
);
// Should match "Reddit" even though it's followed by a comma and description
expect(matches).toHaveLength(1);
expect(matches[0].ServiceName).toBe('Reddit');
});
/**
* Creates the shared test credential dataset used across all platforms.
* Note: when making changes to this list, make sure to update the corresponding list for iOS and Android tests as well.
*/
function createSharedTestCredentials(): Credential[] {
return [
createTestCredential('Gmail', 'https://gmail.com', 'user@gmail.com'),
createTestCredential('Google', 'https://google.com', 'user@google.com'),
createTestCredential('Coolblue', 'https://www.coolblue.nl', 'user@coolblue.nl'),
createTestCredential('Amazon', 'https://amazon.com', 'user@amazon.com'),
createTestCredential('Coolblue App', 'com.coolblue.app', 'user@coolblue.nl'),
createTestCredential('Dumpert', 'dumpert.nl', 'user@dumpert.nl'),
createTestCredential('GitHub', 'github.com', 'user@github.com'),
createTestCredential('Stack Overflow', 'https://stackoverflow.com', 'user@stackoverflow.com'),
createTestCredential('Subdomain Example', 'https://app.example.com', 'user@example.com'),
createTestCredential('Title Only newyorktimes', '', ''),
createTestCredential('Bank Account', 'https://secure-bank.com', 'user@bank.com'),
createTestCredential('AliExpress', 'https://aliexpress.com', 'user@aliexpress.com'),
createTestCredential('Reddit', '', 'user@reddit.com'),
];
}
/**
* Helper function to create test credentials with standardized structure.
* @param serviceName - The name of the service
* @param serviceUrl - The URL of the service
* @param username - The username for the service
* @returns A test credential matching the platform's Credential type
*/
function createTestCredential(
serviceName: string,
serviceUrl: string,
username: string
): Credential {
return {
Id: Math.random().toString(),
ServiceName: serviceName,
ServiceUrl: serviceUrl,
Username: username,
Password: 'password123',
Notes: '',
Logo: new Uint8Array(),
Alias: {
FirstName: '',
LastName: '',
NickName: '',
BirthDate: '',
Gender: undefined,
Email: username
}
};
}
});

View File

@@ -222,7 +222,7 @@ body {
/* Search Input */
.av-search-input {
flex: 2;
flex: 1;
border-radius: 4px;
background: #374151;
color: #e5e7eb;
@@ -231,12 +231,13 @@ body {
border: 1px solid #4b5563;
outline: none;
line-height: 1;
text-align: center;
min-width: 0px;
padding: 8px 12px;
padding-right: 0;
}
.av-search-input::placeholder {
color: #bdbebe;
opacity: 1;
}
.av-search-input:focus {
@@ -897,4 +898,288 @@ body {
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Password Configuration Styles */
.av-password-length-container {
margin-top: 12px;
padding-top: 8px;
}
.av-password-length-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.av-password-length-header label {
font-size: 0.875rem;
font-weight: 500;
color: #9ca3af;
margin: 0;
}
.av-password-length-controls {
display: flex;
align-items: center;
gap: 8px;
}
.av-password-length-value {
font-size: 0.875rem;
color: #e5e7eb;
font-family: 'Courier New', monospace;
min-width: 24px;
text-align: center;
}
.av-password-config-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
color: #9ca3af;
transition: color 0.2s ease, background-color 0.2s ease;
}
.av-password-config-btn:hover {
color: #e5e7eb;
background-color: rgba(75, 85, 99, 0.3);
}
.av-password-config-btn .av-icon {
width: 16px;
height: 16px;
}
.av-password-length-slider {
width: 100%;
height: 8px;
background: #374151;
border-radius: 4px;
appearance: none;
cursor: pointer;
outline: none;
}
.av-password-length-slider::-webkit-slider-thumb {
appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: #d68338;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.av-password-length-slider::-moz-range-thumb {
height: 16px;
width: 16px;
border-radius: 50%;
background: #d68338;
cursor: pointer;
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
/* Password Config Dialog */
.av-password-config-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
}
.av-password-config-dialog {
background: #1f2937;
border: 1px solid #374151;
border-radius: 8px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
width: 400px;
max-width: 90vw;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.av-password-config-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #374151;
}
.av-password-config-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #f8f9fa;
}
.av-password-config-close {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: #9ca3af;
border-radius: 4px;
transition: color 0.2s ease, background-color 0.2s ease;
}
.av-password-config-close:hover {
color: #e5e7eb;
background-color: #374151;
}
.av-password-config-close .av-icon {
width: 16px;
height: 16px;
}
.av-password-config-content {
padding: 20px;
}
.av-password-preview-section {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.av-password-config-preview {
flex: 1;
padding: 10px 12px;
border: 1px solid #374151;
border-radius: 6px;
background: #374151;
color: #f8f9fa;
font-size: 14px;
font-family: 'Courier New', monospace;
outline: none;
}
.av-password-config-refresh {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
background: #374151;
border: none;
border-radius: 6px;
cursor: pointer;
color: #e5e7eb;
transition: background-color 0.2s ease;
}
.av-password-config-refresh:hover {
background-color: #4b5563;
}
.av-password-config-refresh .av-icon {
width: 16px;
height: 16px;
}
.av-password-config-options {
margin-bottom: 20px;
}
.av-password-config-toggles {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.av-password-config-toggle {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
background: #374151;
border: none;
border-radius: 6px;
cursor: pointer;
color: #9ca3af;
transition: all 0.2s ease;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
}
.av-password-config-toggle:hover {
background-color: #4b5563;
}
.av-password-config-toggle.active {
background-color: #d68338;
color: white;
}
.av-password-config-toggle.active:hover {
background-color: #c97731;
}
.av-password-config-checkbox {
display: flex;
align-items: center;
gap: 8px;
}
.av-password-config-checkbox label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
color: #e5e7eb;
margin: 0;
}
.av-password-config-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #d68338;
cursor: pointer;
}
.av-password-config-actions {
display: flex;
justify-content: flex-end;
}
.av-password-config-use {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
background: #6b7280;
border: none;
border-radius: 6px;
cursor: pointer;
color: white;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s ease;
}
.av-password-config-use:hover {
background-color: #4b5563;
}
.av-password-config-use .av-icon {
width: 16px;
height: 16px;
}

View File

@@ -1,6 +1,9 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { HashRouter as Router, Routes, Route } from 'react-router-dom';
import { sendMessage } from 'webext-bridge/popup';
import { ClipboardCountdownBar } from '@/entrypoints/popup/components/ClipboardCountdownBar';
import BottomNav from '@/entrypoints/popup/components/Layout/BottomNav';
import Header from '@/entrypoints/popup/components/Layout/Header';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
@@ -19,6 +22,11 @@ import Login from '@/entrypoints/popup/pages/Login';
import Logout from '@/entrypoints/popup/pages/Logout';
import Reinitialize from '@/entrypoints/popup/pages/Reinitialize';
import Settings from '@/entrypoints/popup/pages/Settings';
import AutofillSettings from '@/entrypoints/popup/pages/settings/AutofillSettings';
import AutoLockSettings from '@/entrypoints/popup/pages/settings/AutoLockSettings';
import ClipboardSettings from '@/entrypoints/popup/pages/settings/ClipboardSettings';
import ContextMenuSettings from '@/entrypoints/popup/pages/settings/ContextMenuSettings';
import LanguageSettings from '@/entrypoints/popup/pages/settings/LanguageSettings';
import Unlock from '@/entrypoints/popup/pages/Unlock';
import UnlockSuccess from '@/entrypoints/popup/pages/UnlockSuccess';
import Upgrade from '@/entrypoints/popup/pages/Upgrade';
@@ -41,30 +49,36 @@ type RouteConfig = {
* App component.
*/
const App: React.FC = () => {
const { t } = useTranslation();
const authContext = useAuth();
const { isInitialLoading } = useLoading();
const [isLoading, setIsLoading] = useMinDurationLoading(true, 150);
const [message, setMessage] = useState<string | null>(null);
const { headerButtons } = useHeaderButtons();
// Add these route configurations
const routes: RouteConfig[] = [
// Move routes definition to useMemo to prevent recreation on every render
const routes: RouteConfig[] = React.useMemo(() => [
{ path: '/', element: <Index />, showBackButton: false },
{ path: '/reinitialize', element: <Reinitialize />, showBackButton: false },
{ path: '/login', element: <Login />, showBackButton: false },
{ path: '/unlock', element: <Unlock />, showBackButton: false },
{ path: '/unlock-success', element: <UnlockSuccess />, showBackButton: false },
{ path: '/upgrade', element: <Upgrade />, showBackButton: false },
{ path: '/auth-settings', element: <AuthSettings />, showBackButton: true, title: 'Settings' },
{ path: '/auth-settings', element: <AuthSettings />, showBackButton: true, title: t('settings.title') },
{ path: '/credentials', element: <CredentialsList />, showBackButton: false },
{ path: '/credentials/add', element: <CredentialAddEdit />, showBackButton: true, title: 'Add credential' },
{ path: '/credentials/:id', element: <CredentialDetails />, showBackButton: true, title: 'Credential details' },
{ path: '/credentials/:id/edit', element: <CredentialAddEdit />, showBackButton: true, title: 'Edit credential' },
{ path: '/credentials/add', element: <CredentialAddEdit />, showBackButton: true, title: t('credentials.addCredential') },
{ path: '/credentials/:id', element: <CredentialDetails />, showBackButton: true, title: t('credentials.credentialDetails') },
{ path: '/credentials/:id/edit', element: <CredentialAddEdit />, showBackButton: true, title: t('credentials.editCredential') },
{ path: '/emails', element: <EmailsList />, showBackButton: false },
{ path: '/emails/:id', element: <EmailDetails />, showBackButton: true, title: 'Email details' },
{ path: '/emails/:id', element: <EmailDetails />, showBackButton: true, title: t('emails.title') },
{ path: '/settings', element: <Settings />, showBackButton: false },
{ path: '/settings/autofill', element: <AutofillSettings />, showBackButton: true, title: t('settings.autofillSettings') },
{ path: '/settings/context-menu', element: <ContextMenuSettings />, showBackButton: true, title: t('settings.contextMenuSettings') },
{ path: '/settings/clipboard', element: <ClipboardSettings />, showBackButton: true, title: t('settings.clipboardSettings') },
{ path: '/settings/language', element: <LanguageSettings />, showBackButton: true, title: t('settings.language') },
{ path: '/settings/auto-lock', element: <AutoLockSettings />, showBackButton: true, title: t('settings.autoLockTimeout') },
{ path: '/logout', element: <Logout />, showBackButton: false },
];
], [t]);
useEffect(() => {
if (!isInitialLoading) {
@@ -72,6 +86,29 @@ const App: React.FC = () => {
}
}, [isInitialLoading, setIsLoading]);
/**
* Send heartbeat to background every 5 seconds while popup is open.
* This extends the auto-lock timer to prevent vault locking while popup is active.
*/
useEffect(() => {
// Send initial heartbeat
sendMessage('POPUP_HEARTBEAT', {}, 'background').catch(() => {
// Ignore errors as background script might not be ready
});
// Set up heartbeat interval
const heartbeatInterval = setInterval(() => {
sendMessage('POPUP_HEARTBEAT', {}, 'background').catch(() => {
// Ignore errors as background script might not be ready
});
}, 5000); // Send heartbeat every 5 seconds
// Cleanup: clear interval when popup closes
return () : void => {
clearInterval(heartbeatInterval);
};
}, []);
/**
* Print global message if it exists.
*/
@@ -93,6 +130,7 @@ const App: React.FC = () => {
</div>
)}
<ClipboardCountdownBar />
<Header
routes={routes}
rightButtons={headerButtons}

View File

@@ -0,0 +1,108 @@
import React, { useEffect, useState, useRef } from 'react';
import { onMessage, sendMessage } from 'webext-bridge/popup';
/**
* Clipboard countdown bar component.
*/
export const ClipboardCountdownBar: React.FC = () => {
const [isVisible, setIsVisible] = useState<boolean>(false);
const animationRef = useRef<HTMLDivElement>(null);
const currentCountdownIdRef = useRef<number>(0);
/**
* Starts the countdown animation.
*/
const startAnimation = (remaining: number, total: number) : void => {
// Use a small delay to ensure the component is fully rendered
setTimeout(() => {
if (animationRef.current) {
// Calculate the starting percentage based on remaining time
const percentage = (remaining / total) * 100;
// Reset any existing animation
animationRef.current.style.transition = 'none';
animationRef.current.style.width = `${percentage}%`;
// Force browser to flush styles
void animationRef.current.offsetHeight;
// Start animation from current position to 0
requestAnimationFrame(() => {
if (animationRef.current) {
animationRef.current.style.transition = `width ${remaining}s linear`;
animationRef.current.style.width = '0%';
}
});
}
}, 10);
};
useEffect(() => {
// Request current countdown state on mount
sendMessage('GET_CLIPBOARD_COUNTDOWN_STATE', {}, 'background').then((state) => {
const countdownState = state as { remaining: number; total: number; id: number } | null;
if (countdownState && countdownState.remaining > 0) {
currentCountdownIdRef.current = countdownState.id;
setIsVisible(true);
startAnimation(countdownState.remaining, countdownState.total);
}
}).catch(() => {
// No active countdown
});
// Listen for countdown updates from background script
const unsubscribe = onMessage('CLIPBOARD_COUNTDOWN', ({ data }) => {
const { remaining, total, id } = data as { remaining: number; total: number; id: number };
setIsVisible(remaining > 0);
// Check if this is a new countdown (different ID)
const isNewCountdown = id !== currentCountdownIdRef.current;
// Start animation when new countdown begins
if (isNewCountdown && remaining > 0) {
currentCountdownIdRef.current = id;
startAnimation(remaining, total);
}
});
// Listen for clipboard cleared message
const unsubscribeClear = onMessage('CLIPBOARD_CLEARED', () => {
setIsVisible(false);
currentCountdownIdRef.current = 0;
if (animationRef.current) {
animationRef.current.style.transition = 'none';
animationRef.current.style.width = '0%';
}
});
// Listen for countdown cancelled message
const unsubscribeCancel = onMessage('CLIPBOARD_COUNTDOWN_CANCELLED', () => {
setIsVisible(false);
currentCountdownIdRef.current = 0;
if (animationRef.current) {
animationRef.current.style.transition = 'none';
animationRef.current.style.width = '0%';
}
});
return () : void => {
// Clean up listeners
unsubscribe();
unsubscribeClear();
unsubscribeCancel();
};
}, []);
if (!isVisible) {
return null;
}
return (
<div className="fixed top-0 left-0 right-0 z-50 h-1 bg-gray-200 dark:bg-gray-700">
<div
ref={animationRef}
className="h-full bg-orange-500"
style={{ width: '100%', transition: 'none' }}
/>
</div>
);
};

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { FormInputCopyToClipboard } from '@/entrypoints/popup/components/FormInputCopyToClipboard';
@@ -13,6 +14,7 @@ type AliasBlockProps = {
* Render the alias block.
*/
const AliasBlock: React.FC<AliasBlockProps> = ({ credential }) => {
const { t } = useTranslation();
const hasFirstName = Boolean(credential.Alias?.FirstName?.trim());
const hasLastName = Boolean(credential.Alias?.LastName?.trim());
const hasNickName = Boolean(credential.Alias?.NickName?.trim());
@@ -24,39 +26,39 @@ const AliasBlock: React.FC<AliasBlockProps> = ({ credential }) => {
return (
<div className="space-y-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Alias</h2>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{t('common.alias')}</h2>
{(hasFirstName || hasLastName) && (
<FormInputCopyToClipboard
id="fullName"
label="Full Name"
label={t('common.fullName')}
value={[credential.Alias?.FirstName, credential.Alias?.LastName].filter(Boolean).join(' ')}
/>
)}
{hasFirstName && (
<FormInputCopyToClipboard
id="firstName"
label="First Name"
label={t('common.firstName')}
value={credential.Alias?.FirstName ?? ''}
/>
)}
{hasLastName && (
<FormInputCopyToClipboard
id="lastName"
label="Last Name"
label={t('common.lastName')}
value={credential.Alias?.LastName ?? ''}
/>
)}
{hasBirthDate && (
<FormInputCopyToClipboard
id="birthDate"
label="Birth Date"
label={t('common.birthDate')}
value={IdentityHelperUtils.normalizeBirthDateForDisplay(credential.Alias?.BirthDate)}
/>
)}
{hasNickName && (
<FormInputCopyToClipboard
id="nickName"
label="Nickname"
label={t('common.nickname')}
value={credential.Alias?.NickName ?? ''}
/>
)}

View File

@@ -0,0 +1,120 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import type { Attachment } from '@/utils/dist/shared/models/vault';
type AttachmentBlockProps = {
credentialId: string;
}
/**
* This component shows attachments for a credential.
*/
const AttachmentBlock: React.FC<AttachmentBlockProps> = ({ credentialId }) => {
const { t } = useTranslation();
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [loading, setLoading] = useState(true);
const dbContext = useDb();
/**
* Downloads an attachment file.
*/
const downloadAttachment = (attachment: Attachment): void => {
try {
// Convert Uint8Array or number[] to Uint8Array
const byteArray = attachment.Blob instanceof Uint8Array
? attachment.Blob
: new Uint8Array(attachment.Blob);
// Create blob and download
const blob = new Blob([byteArray as BlobPart]);
const url = URL.createObjectURL(blob);
// Create temporary download link
const a = document.createElement('a');
a.href = url;
a.download = attachment.Filename;
document.body.appendChild(a);
a.click();
// Cleanup
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading attachment:', error);
}
};
useEffect(() => {
/**
* Loads the attachments for the credential.
*/
const loadAttachments = async (): Promise<void> => {
if (!dbContext?.sqliteClient) {
return;
}
try {
const attachmentList = dbContext.sqliteClient.getAttachmentsForCredential(credentialId);
setAttachments(attachmentList);
} catch (error) {
console.error('Error loading attachments:', error);
} finally {
setLoading(false);
}
};
loadAttachments();
}, [credentialId, dbContext?.sqliteClient]);
if (loading) {
return (
<div className="text-gray-500 dark:text-gray-400 mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">{t('common.attachments')}</h2>
{t('common.loadingAttachments')}
</div>
);
}
if (attachments.length === 0) {
return null;
}
return (
<div className="mb-4">
<div className="space-y-2">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('common.attachments')}</h2>
<div className="grid grid-cols-1 gap-2">
{attachments.map(attachment => (
<button
key={attachment.Id}
className="w-full text-left p-2 ps-3 pe-3 rounded bg-white dark:bg-gray-800 shadow hover:shadow-md transition-all border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => downloadAttachment(attachment)}
aria-label={`Download ${attachment.Filename}`}
>
<div className="flex justify-between items-center gap-2">
<div className="flex items-center flex-1">
<div className="flex flex-col">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{attachment.Filename}</h4>
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(attachment.CreatedAt).toLocaleDateString()}
</span>
</div>
</div>
<div className="flex items-center">
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5m0 0l5-5m-5 5V4" />
</svg>
</div>
</div>
</button>
))}
</div>
</div>
</div>
);
};
export default AttachmentBlock;

View File

@@ -0,0 +1,146 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { Attachment } from '@/utils/dist/shared/models/vault';
type AttachmentUploaderProps = {
attachments: Attachment[];
onAttachmentsChange: (attachments: Attachment[]) => void;
}
/**
* This component allows uploading and managing attachments for a credential.
*/
const AttachmentUploader: React.FC<AttachmentUploaderProps> = ({
attachments,
onAttachmentsChange
}) => {
const { t } = useTranslation();
const [statusMessage, setStatusMessage] = useState<string>('');
/**
* Handles file selection and upload.
*/
const handleFileSelection = async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const files = event.target.files;
if (!files || files.length === 0) {
return;
}
setStatusMessage('Uploading...');
try {
const newAttachments = [...attachments];
for (const file of Array.from(files)) {
const arrayBuffer = await file.arrayBuffer();
const byteArray = new Uint8Array(arrayBuffer);
const attachment: Attachment = {
Id: crypto.randomUUID(),
Filename: file.name,
Blob: byteArray,
CredentialId: '', // Will be set when saving credential
CreatedAt: new Date().toISOString(),
UpdatedAt: new Date().toISOString(),
IsDeleted: false,
};
newAttachments.push(attachment);
}
onAttachmentsChange(newAttachments);
setStatusMessage('Files uploaded successfully.');
// Clear status message after 3 seconds
setTimeout(() => setStatusMessage(''), 3000);
} catch (error) {
console.error('Error uploading files:', error);
setStatusMessage('Error uploading files.');
setTimeout(() => setStatusMessage(''), 3000);
}
// Reset file input
event.target.value = '';
};
/**
* Deletes an attachment.
*/
const deleteAttachment = (attachmentToDelete: Attachment): void => {
try {
const updatedAttachments = [...attachments];
// Remove attachment from array
const index = updatedAttachments.findIndex(a => a.Id === attachmentToDelete.Id);
if (index !== -1) {
updatedAttachments.splice(index, 1);
}
onAttachmentsChange(updatedAttachments);
setStatusMessage('Attachment deleted successfully.');
setTimeout(() => setStatusMessage(''), 3000);
} catch (error) {
console.error('Error deleting attachment:', error);
setStatusMessage('Error deleting attachment.');
setTimeout(() => setStatusMessage(''), 3000);
}
};
const activeAttachments = attachments.filter(a => !a.IsDeleted);
return (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('common.attachments')}</h2>
<div className="space-y-4">
<div>
<input
type="file"
multiple
onChange={handleFileSelection}
className="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400"
/>
{statusMessage && (
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{statusMessage}</p>
)}
</div>
{activeAttachments.length > 0 && (
<div>
<h4 className="mb-2 text-md font-medium text-gray-900 dark:text-white">Current attachments:</h4>
<div className="space-y-2">
{activeAttachments.map(attachment => (
<div
key={attachment.Id}
className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded border border-gray-200 dark:border-gray-600"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{attachment.Filename}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(attachment.CreatedAt).toLocaleDateString()}
</span>
</div>
<button
type="button"
onClick={() => deleteAttachment(attachment)}
className="text-red-500 hover:text-red-700 focus:outline-none"
aria-label={`Delete ${attachment.Filename}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};
export default AttachmentUploader;

View File

@@ -21,14 +21,18 @@ const HeaderBlock: React.FC<HeaderBlockProps> = ({ credential }) => (
<div>
<h1 className="text-lg font-bold text-gray-900 dark:text-white">{credential.ServiceName}</h1>
{credential.ServiceUrl && (
<a
href={credential.ServiceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 break-all"
>
{credential.ServiceUrl}
</a>
/^https?:\/\//i.test(credential.ServiceUrl) ? (
<a
href={credential.ServiceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 break-all"
>
{credential.ServiceUrl}
</a>
) : (
<span className="break-all">{credential.ServiceUrl}</span>
)
)}
</div>
</div>

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { FormInputCopyToClipboard } from '@/entrypoints/popup/components/FormInputCopyToClipboard';
@@ -12,6 +13,7 @@ type LoginCredentialsBlockProps = {
* Render the login credentials block.
*/
const LoginCredentialsBlock: React.FC<LoginCredentialsBlockProps> = ({ credential }) => {
const { t } = useTranslation();
const email = credential.Alias?.Email?.trim();
const username = credential.Username?.trim();
const password = credential.Password?.trim();
@@ -22,25 +24,25 @@ const LoginCredentialsBlock: React.FC<LoginCredentialsBlockProps> = ({ credentia
return (
<div className="space-y-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Login credentials</h2>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{t('common.loginCredentials')}</h2>
{email && (
<FormInputCopyToClipboard
id="email"
label="Email"
label={t('common.email')}
value={email}
/>
)}
{username && (
<FormInputCopyToClipboard
id="username"
label="Username"
label={t('common.username')}
value={username}
/>
)}
{password && (
<FormInputCopyToClipboard
id="password"
label="Password"
label={t('common.password')}
value={password}
type="password"
/>

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
type NotesBlockProps = {
notes: string | undefined;
@@ -20,6 +21,7 @@ const convertUrlsToLinks = (text: string): string => {
* Render the notes block.
*/
const NotesBlock: React.FC<NotesBlockProps> = ({ notes }) => {
const { t } = useTranslation();
if (!notes) {
return null;
}
@@ -28,7 +30,7 @@ const NotesBlock: React.FC<NotesBlockProps> = ({ notes }) => {
return (
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Notes</h2>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{t('common.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"

View File

@@ -1,5 +1,7 @@
import * as OTPAuth from 'otpauth';
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { sendMessage } from 'webext-bridge/popup';
import { useDb } from '@/entrypoints/popup/context/DbContext';
@@ -13,6 +15,7 @@ type TotpBlockProps = {
* This component shows TOTP codes for a credential.
*/
const TotpBlock: React.FC<TotpBlockProps> = ({ credentialId }) => {
const { t } = useTranslation();
const [totpCodes, setTotpCodes] = useState<TotpCode[]>([]);
const [loading, setLoading] = useState(true);
const [currentCodes, setCurrentCodes] = useState<Record<string, string>>({});
@@ -66,6 +69,9 @@ const TotpBlock: React.FC<TotpBlockProps> = ({ credentialId }) => {
try {
await navigator.clipboard.writeText(code);
setCopiedId(id);
// Notify background script that clipboard was copied
await sendMessage('CLIPBOARD_COPIED', { value: code }, 'background');
// Reset copied state after 2 seconds
setTimeout(() => {
@@ -138,8 +144,8 @@ const TotpBlock: React.FC<TotpBlockProps> = ({ credentialId }) => {
if (loading) {
return (
<div className="text-gray-500 dark:text-gray-400 mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Two-factor authentication</h2>
Loading TOTP codes...
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">{t('common.twoFactorAuthentication')}</h2>
{t('common.loadingTotpCodes')}
</div>
);
}
@@ -151,7 +157,7 @@ const TotpBlock: React.FC<TotpBlockProps> = ({ credentialId }) => {
return (
<div className="mb-4">
<div className="space-y-2">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Two-factor authentication</h2>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('common.twoFactorAuthentication')}</h2>
<div className="grid grid-cols-1 gap-2">
{totpCodes.map(totpCode => (
<button
@@ -171,7 +177,7 @@ const TotpBlock: React.FC<TotpBlockProps> = ({ credentialId }) => {
</span>
<div className="text-xs">
{copiedId === totpCode.Id ? (
<span className="text-green-600 dark:text-green-400">Copied!</span>
<span className="text-green-600 dark:text-green-400">{t('common.copied')}</span>
) : (
<span className="text-gray-500 dark:text-gray-400">{getRemainingSeconds()}s</span>
)}

View File

@@ -1,4 +1,5 @@
import AliasBlock from './AliasBlock';
import AttachmentBlock from './AttachmentBlock';
import EmailBlock from './EmailBlock';
import HeaderBlock from './HeaderBlock';
import LoginCredentialsBlock from './LoginCredentialsBlock';
@@ -11,5 +12,6 @@ export {
TotpBlock,
LoginCredentialsBlock,
AliasBlock,
NotesBlock
NotesBlock,
AttachmentBlock
};

View File

@@ -0,0 +1,286 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDb } from '@/entrypoints/popup/context/DbContext';
type EmailDomainFieldProps = {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
error?: string;
required?: boolean;
}
// Hardcoded public email domains (same as in AliasVault.Client)
const PUBLIC_EMAIL_DOMAINS = [
'spamok.com',
'solarflarecorp.com',
'spamok.nl',
'3060.nl',
'landmail.nl',
'asdasd.nl',
'spamok.de',
'spamok.com.ua',
'spamok.es',
'spamok.fr',
];
/**
* Email domain field component with domain chooser functionality.
* Allows users to select from private/public domains or enter custom email addresses.
*/
const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
id,
label,
value,
onChange,
error,
required = false
}) => {
const { t } = useTranslation();
const dbContext = useDb();
const [isCustomDomain, setIsCustomDomain] = useState(false);
const [localPart, setLocalPart] = useState('');
const [selectedDomain, setSelectedDomain] = useState('');
const [isPopupVisible, setIsPopupVisible] = useState(false);
const [privateEmailDomains, setPrivateEmailDomains] = useState<string[]>([]);
const popupRef = useRef<HTMLDivElement>(null);
// Get private email domains from vault metadata
useEffect(() => {
/**
* Load private email domains from vault metadata.
*/
const loadDomains = async (): Promise<void> => {
const metadata = await dbContext.getVaultMetadata();
if (metadata?.privateEmailDomains) {
setPrivateEmailDomains(metadata.privateEmailDomains);
}
};
loadDomains();
}, [dbContext]);
// Check if private domains are available and valid
const showPrivateDomains = useMemo(() => {
return privateEmailDomains.length > 0 &&
!(privateEmailDomains.length === 1 && (privateEmailDomains[0] === 'DISABLED.TLD' || privateEmailDomains[0] === ''));
}, [privateEmailDomains]);
// Initialize state from value prop
useEffect(() => {
if (!value) {
// Set default domain
if (showPrivateDomains && privateEmailDomains[0]) {
setSelectedDomain(privateEmailDomains[0]);
} else if (PUBLIC_EMAIL_DOMAINS[0]) {
setSelectedDomain(PUBLIC_EMAIL_DOMAINS[0]);
}
return;
}
if (value.includes('@')) {
const [local, domain] = value.split('@');
setLocalPart(local);
setSelectedDomain(domain);
// Check if it's a custom domain
const isKnownDomain = PUBLIC_EMAIL_DOMAINS.includes(domain) ||
privateEmailDomains.includes(domain);
setIsCustomDomain(!isKnownDomain);
} else {
setLocalPart(value);
setIsCustomDomain(false);
// Set default domain if not already set
if (!selectedDomain && !value.includes('@')) {
if (showPrivateDomains && privateEmailDomains[0]) {
setSelectedDomain(privateEmailDomains[0]);
} else if (PUBLIC_EMAIL_DOMAINS[0]) {
setSelectedDomain(PUBLIC_EMAIL_DOMAINS[0]);
}
}
}
}, [value, privateEmailDomains, showPrivateDomains, selectedDomain]);
// Handle local part changes
const handleLocalPartChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newLocalPart = e.target.value;
// Check if new value contains '@' symbol, if so, switch to custom domain mode
if (newLocalPart.includes('@')) {
setIsCustomDomain(true);
onChange(newLocalPart);
return;
}
setLocalPart(newLocalPart);
if (!isCustomDomain && selectedDomain) {
onChange(`${newLocalPart}@${selectedDomain}`);
} else {
onChange(newLocalPart);
}
}, [isCustomDomain, selectedDomain, onChange]);
// Select a domain from the popup
const selectDomain = useCallback((domain: string) => {
setSelectedDomain(domain);
const cleanLocalPart = localPart.includes('@') ? localPart.split('@')[0] : localPart;
onChange(`${cleanLocalPart}@${domain}`);
setIsCustomDomain(false);
setIsPopupVisible(false);
}, [localPart, onChange]);
// Toggle between custom domain and domain chooser
const toggleCustomDomain = useCallback(() => {
const newIsCustom = !isCustomDomain;
setIsCustomDomain(newIsCustom);
if (!newIsCustom && !value.includes('@')) {
// Switching to domain chooser mode, add default domain
const defaultDomain = showPrivateDomains && privateEmailDomains[0]
? privateEmailDomains[0]
: PUBLIC_EMAIL_DOMAINS[0];
onChange(`${localPart}@${defaultDomain}`);
setSelectedDomain(defaultDomain);
}
}, [isCustomDomain, value, localPart, showPrivateDomains, privateEmailDomains, onChange]);
// Handle clicks outside the popup
useEffect(() => {
/**
* Handle clicks outside the popup to close it.
*/
const handleClickOutside = (event: MouseEvent): void => {
if (popupRef.current && !popupRef.current.contains(event.target as Node)) {
setIsPopupVisible(false);
}
};
if (isPopupVisible) {
document.addEventListener('mousedown', handleClickOutside);
return (): void => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [isPopupVisible]);
return (
<div className="space-y-2">
<label htmlFor={id} className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="relative w-full">
<div className="flex w-full">
<input
type="text"
id={id}
className={`flex-1 min-w-0 px-3 py-2 border ${
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
} ${
!isCustomDomain ? 'rounded-l-md' : 'rounded-md'
} focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-white`}
value={isCustomDomain ? value : localPart}
onChange={handleLocalPartChange}
placeholder={isCustomDomain ? t('credentials.enterFullEmail') : t('credentials.enterEmailPrefix')}
/>
{!isCustomDomain && (
<button
type="button"
onClick={() => setIsPopupVisible(!isPopupVisible)}
className="inline-flex items-center px-2 py-2 border border-l-0 border-gray-300 dark:border-gray-600 rounded-r-md bg-gray-50 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-500 cursor-pointer text-sm truncate max-w-[120px]"
>
<span className="text-gray-500 dark:text-gray-400">@</span>
<span className="truncate ml-0.5">{selectedDomain}</span>
</button>
)}
</div>
{/* Domain selection popup */}
{isPopupVisible && !isCustomDomain && (
<div
ref={popupRef}
className="absolute z-50 mt-2 w-full bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 max-h-96 overflow-y-auto"
>
<div className="p-4">
{showPrivateDomains && (
<div className="mb-4">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{t('credentials.privateEmailTitle')} <span className="text-xs text-gray-500 dark:text-gray-400">({t('credentials.privateEmailAliasVaultServer')})</span>
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
{t('credentials.privateEmailDescription')}
</p>
<div className="flex flex-wrap gap-2">
{privateEmailDomains.map((domain) => (
<button
key={domain}
type="button"
onClick={() => selectDomain(domain)}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
selectedDomain === domain
? 'bg-primary-600 text-white hover:bg-primary-700'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600'
}`}
>
{domain}
</button>
))}
</div>
</div>
)}
<div className={showPrivateDomains ? 'border-t border-gray-200 dark:border-gray-600 pt-4' : ''}>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{t('credentials.publicEmailTitle')}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
{t('credentials.publicEmailDescription')}
</p>
<div className="flex flex-wrap gap-2">
{PUBLIC_EMAIL_DOMAINS.map((domain) => (
<button
key={domain}
type="button"
onClick={() => selectDomain(domain)}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
selectedDomain === domain
? 'bg-primary-600 text-white hover:bg-primary-700'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600'
}`}
>
{domain}
</button>
))}
</div>
</div>
</div>
</div>
)}
</div>
{/* Toggle custom domain button */}
<div>
<button
type="button"
onClick={toggleCustomDomain}
className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
>
{isCustomDomain
? t('credentials.useDomainChooser')
: t('credentials.enterCustomDomain')}
</button>
</div>
{/* Error message */}
{error && (
<p className="text-sm text-red-500 mt-1">{error}</p>
)}
</div>
);
};
export default EmailDomainField;

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useDb } from '@/entrypoints/popup/context/DbContext';
@@ -18,15 +19,38 @@ type EmailPreviewProps = {
* This component shows a preview of the latest emails in the inbox.
*/
export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
const { t } = useTranslation();
const [emails, setEmails] = useState<MailboxEmail[]>([]);
const [displayedEmails, setDisplayedEmails] = useState<MailboxEmail[]>([]);
const [loading, setLoading] = useState(true);
const [lastEmailId, setLastEmailId] = useState<number>(0);
const [isSpamOk, setIsSpamOk] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSupportedDomain, setIsSupportedDomain] = useState(false);
const [displayedCount, setDisplayedCount] = useState(2);
const webApi = useWebApi();
const dbContext = useDb();
const emailsPerLoad = 3;
const canLoadMore = displayedCount < emails.length;
/**
* Updates the displayed emails based on the current count.
*/
const updateDisplayedEmails = (allEmails: MailboxEmail[], count: number) : void => {
const displayed = allEmails.slice(0, count);
setDisplayedEmails(displayed);
};
/**
* Loads more emails.
*/
const loadMoreEmails = (): void => {
const newCount = Math.min(displayedCount + emailsPerLoad, emails.length);
setDisplayedCount(newCount);
updateDisplayedEmails(emails, newCount);
};
/**
* Checks if the email is a public domain.
*/
@@ -74,23 +98,30 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
});
if (!response.ok) {
setError('An error occurred while loading emails. Please try again later.');
setError(t('emails.errors.emailLoadError'));
return;
}
const data = await response.json();
// Only show the latest 2 emails to save space in UI
const latestMails = data?.mails
// Store all emails, sorted by date
const allMails = data?.mails
?.toSorted((a: MailboxEmail, b: MailboxEmail) =>
new Date(b.dateSystem).getTime() - new Date(a.dateSystem).getTime())
?.slice(0, 2) ?? [];
new Date(b.dateSystem).getTime() - new Date(a.dateSystem).getTime()) ?? [];
if (loading && latestMails.length > 0) {
setLastEmailId(latestMails[0].id);
if (loading && allMails.length > 0) {
setLastEmailId(allMails[0].id);
}
setEmails(latestMails);
// Only update emails if they actually changed to preserve displayedCount
setEmails(prevEmails => {
const emailsChanged = JSON.stringify(prevEmails.map(e => e.id)) !== JSON.stringify(allMails.map(e => e.id));
if (emailsChanged) {
updateDisplayedEmails(allMails, displayedCount);
return allMails;
}
return prevEmails;
});
} else if (isPrivate) {
// For private domains, use existing encrypted email logic
try {
@@ -102,15 +133,14 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
try {
const data = response as { mails: MailboxEmail[] };
// Only show the latest 2 emails to save space in UI
const latestMails = data.mails
.toSorted((a, b) => new Date(b.dateSystem).getTime() - new Date(a.dateSystem).getTime())
.slice(0, 2);
// Store all emails, sorted by date
const allMails = data.mails
.toSorted((a, b) => new Date(b.dateSystem).getTime() - new Date(a.dateSystem).getTime());
if (latestMails) {
if (allMails) {
// Loop through all emails and decrypt them locally
const decryptedEmails: MailboxEmail[] = await EncryptionUtility.decryptEmailList(
latestMails,
allMails,
dbContext.sqliteClient!.getAllEncryptionKeys()
);
@@ -118,30 +148,30 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
setLastEmailId(decryptedEmails[0].id);
}
setEmails(decryptedEmails);
// Only update emails if they actually changed to preserve displayedCount
setEmails(prevEmails => {
const emailsChanged = JSON.stringify(prevEmails.map(e => e.id)) !== JSON.stringify(decryptedEmails.map(e => e.id));
if (emailsChanged) {
updateDisplayedEmails(decryptedEmails, displayedCount);
return decryptedEmails;
}
return prevEmails;
});
}
} catch {
// Try to parse as error response instead
const apiErrorResponse = response as ApiErrorResponse;
if (apiErrorResponse?.code === 'CLAIM_DOES_NOT_MATCH_USER') {
setError('The current chosen email address is already in use. Please change the email address by editing this credential.');
} else if (apiErrorResponse?.code === 'CLAIM_DOES_NOT_EXIST') {
setError('An error occurred while trying to load the emails. Please try to edit and save the credential entry to synchronize the database, then try again.');
} else {
setError('An error occurred while loading emails. Please try again later.');
}
setError(t('emails.apiErrors.' + apiErrorResponse?.code));
return;
}
} catch {
setError('An error occurred while loading emails. Please try again later.');
setError(t('emails.errors.emailLoadError'));
return;
}
}
} catch (err) {
console.error('Error loading emails:', err);
setError('An unexpected error occurred while loading emails. Please try again later.');
setError(t('emails.errors.emailUnexpectedError'));
}
setLoading(false);
};
@@ -150,7 +180,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
// Set up auto-refresh interval
const interval = setInterval(loadEmails, 2000);
return () : void => clearInterval(interval);
}, [email, loading, webApi, dbContext]);
}, [email, loading, webApi, dbContext, t, displayedCount]);
// Don't render anything if the domain is not supported
if (!isSupportedDomain) {
@@ -161,7 +191,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
return (
<div className="text-gray-500 dark:text-gray-400 mb-4">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Recent emails</h2>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('common.recentEmails')}</h2>
</div>
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
@@ -174,10 +204,10 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
return (
<div className="text-gray-500 dark:text-gray-400 mb-4">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Recent emails</h2>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('common.recentEmails')}</h2>
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
</div>
Loading emails...
{t('common.loadingEmails')}
</div>
);
}
@@ -185,10 +215,10 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
return (
<div className="text-gray-500 dark:text-gray-400 mb-4">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Recent emails</h2>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('common.recentEmails')}</h2>
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
</div>
No emails received yet.
{t('emails.noEmails')}
</div>
);
}
@@ -196,11 +226,11 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
return (
<div className="space-y-2 mb-4">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Recent emails</h2>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('common.recentEmails')}</h2>
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
</div>
{emails.map((mail) => (
{displayedEmails.map((mail) => (
isSpamOk ? (
<a
key={mail.id}
@@ -239,6 +269,18 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
</Link>
)
))}
{canLoadMore && (
<button
onClick={loadMoreEmails}
className="w-full mt-2 py-1 px-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md transition-colors duration-200 text-sm text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100 flex items-center justify-center gap-1"
>
<span>{t('common.loadMore')}</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
)}
</div>
);
};

View File

@@ -1,4 +1,5 @@
import React, { forwardRef } from 'react';
import { useTranslation } from 'react-i18next';
/**
* Button configuration for form input.
@@ -36,6 +37,13 @@ const Icon: React.FC<{ name: string }> = ({ name }) => {
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</>
);
case 'settings':
return (
<>
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</>
);
default:
return null;
}
@@ -78,6 +86,7 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(({
showPassword: controlledShowPassword,
onShowPasswordChange
}, ref) => {
const { t } = useTranslation();
const [internalShowPassword, setInternalShowPassword] = React.useState(false);
/**
@@ -101,7 +110,7 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(({
};
const inputClasses = `mt-1 block w-full rounded-md ${
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-700'
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
} text-gray-900 sm:text-sm rounded-lg shadow-sm border focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400 py-2 px-3`;
// Add password visibility button if type is password
@@ -112,7 +121,7 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(({
* Toggle password visibility.
*/
onClick: (): void => setShowPassword(!showPassword),
title: showPassword ? 'Hide password' : 'Show password'
title: showPassword ? t('common.hidePassword') : t('common.showPassword')
}]
: buttons;

View File

@@ -1,4 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { sendMessage } from 'webext-bridge/popup';
import { ClipboardCopyService } from '@/entrypoints/popup/utils/ClipboardCopyService';
@@ -60,6 +62,7 @@ export const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> =
value,
type = 'text'
}) => {
const { t } = useTranslation();
const [showPassword, setShowPassword] = useState(false);
const [copied, setCopied] = useState(false);
@@ -79,6 +82,9 @@ export const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> =
try {
await navigator.clipboard.writeText(value);
clipboardService.setCopied(id);
// Notify background script that clipboard was copied
await sendMessage('CLIPBOARD_COPIED', { value }, 'background');
// Reset copied state after 2 seconds
setTimeout(() => {
@@ -112,7 +118,7 @@ export const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> =
<button
type="button"
className="p-1 text-green-500 dark:text-green-400 transition-colors duration-200"
title="Copied!"
title={t('common.copied')}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<Icon name="check" />
@@ -123,7 +129,7 @@ export const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> =
type="button"
onClick={copyToClipboard}
className="p-1 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
title="Copy to clipboard"
title={t('common.copyToClipboard')}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<Icon name="copy" />
@@ -135,7 +141,7 @@ export const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> =
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
title={showPassword ? 'Hide password' : 'Show password'}
title={showPassword ? t('common.hidePassword') : t('common.showPassword')}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<Icon name={showPassword ? 'visibility-off' : 'visibility'} />

View File

@@ -0,0 +1,84 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
type HelpModalProps = {
titleKey: string;
contentKey: string;
className?: string;
}
/**
* Reusable help modal component with a question mark icon button.
* Shows a modal popup with help information when clicked.
*/
const HelpModal: React.FC<HelpModalProps> = ({ titleKey, contentKey, className = '' }) => {
const { t } = useTranslation();
const [showModal, setShowModal] = useState(false);
return (
<>
<button
onClick={() => setShowModal(true)}
className={`${className}`}
type="button"
aria-label="Help"
>
<svg
className="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-help"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm w-full">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t(titleKey)}
</h3>
<button
onClick={() => setShowModal(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label={t('common.close')}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">
{t(contentKey)}
</p>
<button
onClick={() => setShowModal(false)}
className="mt-4 w-full px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-md transition-colors"
>
{t('common.close')}
</button>
</div>
</div>
)}
</>
);
};
export default HelpModal;

View File

@@ -0,0 +1,131 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { AVAILABLE_LANGUAGES, getLanguageConfig, ILanguageConfig } from '../../../i18n/config';
import { storage } from '#imports';
type LanguageSwitcherProps = {
variant?: 'dropdown' | 'buttons';
size?: 'sm' | 'md';
};
/**
* Language switcher component that allows users to switch between supported languages
* @param props - Component props including variant and size
* @returns JSX element for the language switcher
*/
const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({
variant = 'dropdown',
size = 'md'
}): React.JSX.Element => {
const { i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const currentLanguage = getLanguageConfig(i18n.language) || AVAILABLE_LANGUAGES[0];
// Close dropdown when clicking outside
useEffect((): (() => void) => {
/**
* Handle clicks outside the dropdown to close it
* @param event - Mouse event
*/
const handleClickOutside = (event: MouseEvent): void => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
/**
* Change the application language
* @param lng - Language code to switch to
*/
const changeLanguage = async (lng: string): Promise<void> => {
await i18n.changeLanguage(lng);
await storage.setItem('local:language', lng);
setIsOpen(false);
// Force immediate re-render by dispatching the event that react-i18next listens to
i18n.emit('languageChanged', lng);
};
if (variant === 'buttons') {
return (
<div className="flex space-x-2">
{AVAILABLE_LANGUAGES.map((lang: ILanguageConfig) => (
<button
key={lang.code}
onClick={() => changeLanguage(lang.code)}
className={`flex items-center space-x-1 px-2 py-1 text-xs rounded transition-colors ${
i18n.language === lang.code
? 'bg-primary-500 text-white'
: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-500'
}`}
title={lang.nativeName}
>
<span className="text-sm">{lang.flag}</span>
<span>{lang.code.toUpperCase()}</span>
</button>
))}
</div>
);
}
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className={`w-full flex items-center justify-between px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors ${
size === 'sm' ? 'text-sm' : 'text-base'
}`}
>
<div className="flex items-center space-x-2">
<span className="text-lg">{currentLanguage.flag}</span>
<span>{currentLanguage.nativeName}</span>
</div>
<svg
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="absolute top-full left-0 mt-1 w-full bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg z-50">
{AVAILABLE_LANGUAGES.map((lang: ILanguageConfig) => (
<button
key={lang.code}
onClick={() => changeLanguage(lang.code)}
className={`w-full flex items-center justify-between px-3 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors first:rounded-t-lg last:rounded-b-lg ${
size === 'sm' ? 'text-sm' : 'text-base'
}`}
>
<div className="flex items-center space-x-2">
<span className="text-lg">{lang.flag}</span>
<span className="text-gray-700 dark:text-gray-200">{lang.nativeName}</span>
</div>
{i18n.language === lang.code && (
<svg className="w-4 h-4 text-primary-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
</button>
))}
</div>
)}
</div>
);
};
export default LanguageSwitcher;

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';
type TabName = 'credentials' | 'emails' | 'settings';
@@ -7,6 +8,7 @@ type TabName = 'credentials' | 'emails' | 'settings';
* Bottom nav component.
*/
const BottomNav: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const [currentTab, setCurrentTab] = useState<TabName>('credentials');
@@ -60,7 +62,7 @@ const BottomNav: React.FC = () => {
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<span className="text-xs mt-1">Credentials</span>
<span className="text-xs mt-1">{t('menu.credentials')}</span>
</button>
<button
onClick={() => handleTabChange('emails')}
@@ -71,7 +73,7 @@ const BottomNav: React.FC = () => {
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span className="text-xs mt-1">Emails</span>
<span className="text-xs mt-1">{t('menu.emails')}</span>
</button>
<button
onClick={() => handleTabChange('settings')}
@@ -83,7 +85,7 @@ const BottomNav: React.FC = () => {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="text-xs mt-1">Settings</span>
<span className="text-xs mt-1">{t('menu.settings')}</span>
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
@@ -22,6 +23,7 @@ const Header: React.FC<HeaderProps> = ({
routes = [],
rightButtons
}) => {
const { t } = useTranslation();
const authContext = useAuth();
const navigate = useNavigate();
const location = useLocation();
@@ -86,7 +88,7 @@ const Header: React.FC<HeaderProps> = ({
className="flex items-center hover:opacity-80 transition-opacity"
>
<img src="/assets/images/logo.svg" alt="AliasVault" className="h-8 w-8 mr-2" />
<h1 className="text-gray-900 dark:text-white text-xl font-bold">AliasVault</h1>
<h1 className="text-gray-900 dark:text-white text-xl font-bold">{t('common.appName')}</h1>
{/* Hide beta badge on Safari as it's not allowed to show non-production badges */}
{!import.meta.env.SAFARI && (
<span className="text-primary-500 text-[10px] ml-1 font-normal">BETA</span>
@@ -106,7 +108,7 @@ const Header: React.FC<HeaderProps> = ({
onClick={(handleSettings)}
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span className="sr-only">Settings</span>
<span className="sr-only">{t('common.settings')}</span>
<svg className="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
</svg>

View File

@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useApiUrl } from '@/entrypoints/popup/utils/ApiUrlUtility';
@@ -9,6 +10,7 @@ import { useApiUrl } from '@/entrypoints/popup/utils/ApiUrlUtility';
const LoginServerInfo: React.FC = () => {
const { loadApiUrl, getDisplayUrl } = useApiUrl();
const navigate = useNavigate();
const { t } = useTranslation();
useEffect(() => {
/**
@@ -26,7 +28,7 @@ const LoginServerInfo: React.FC = () => {
return (
<div className="text-xs text-gray-600 dark:text-gray-400 mb-4">
(Connecting to{' '}
({t('auth.connectingTo')}{' '}
<button
onClick={handleClick}
type="button"

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
interface IModalProps {
isOpen: boolean;
@@ -24,6 +25,7 @@ const Modal: React.FC<IModalProps> = ({
cancelText = '',
variant = 'default'
}) => {
const { t } = useTranslation();
if (!isOpen) {
return null;
}
@@ -46,7 +48,7 @@ const Modal: React.FC<IModalProps> = ({
className="absolute right-4 top-4 text-gray-400 hover:text-gray-500 focus:outline-none"
onClick={onClose}
>
<span className="sr-only">Close</span>
<span className="sr-only">{t('common.close')}</span>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>

View File

@@ -0,0 +1,218 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { PasswordSettings } from '@/utils/dist/shared/models/vault';
import { CreatePasswordGenerator } from '@/utils/dist/shared/password-generator';
interface IPasswordConfigDialogProps {
isOpen: boolean;
onClose: () => void;
onSave: (password: string) => void;
onSettingsChange?: (settings: PasswordSettings) => void;
initialSettings: PasswordSettings;
}
/**
* Password configuration dialog component.
*/
const PasswordConfigDialog: React.FC<IPasswordConfigDialogProps> = ({
isOpen,
onClose,
onSave,
onSettingsChange,
initialSettings
}) => {
const { t } = useTranslation();
const [settings, setSettings] = useState<PasswordSettings>(initialSettings);
const [previewPassword, setPreviewPassword] = useState<string>('');
const generatePreview = useCallback((currentSettings: PasswordSettings) => {
try {
const passwordGenerator = CreatePasswordGenerator(currentSettings);
const password = passwordGenerator.generateRandomPassword();
setPreviewPassword(password);
} catch (error) {
console.error('Error generating preview password:', error);
setPreviewPassword('');
}
}, []);
// Initialize settings when dialog opens
useEffect(() => {
if (isOpen) {
setSettings({ ...initialSettings });
generatePreview({ ...initialSettings });
}
}, [isOpen, initialSettings, generatePreview]);
const handleSettingChange = useCallback((key: keyof PasswordSettings, value: boolean | number) => {
const newSettings = { ...settings, [key]: value };
setSettings(newSettings);
generatePreview(newSettings);
onSettingsChange?.(newSettings);
}, [settings, generatePreview, onSettingsChange]);
const handleRefreshPreview = useCallback(() => {
generatePreview(settings);
}, [settings, generatePreview]);
const handleSave = useCallback(() => {
onSave(previewPassword);
onClose();
}, [previewPassword, onSave, onClose]);
const handleCancel = useCallback(() => {
onClose();
}, [onClose]);
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div className="fixed inset-0 bg-black bg-opacity-60 transition-opacity" onClick={handleCancel} />
{/* Modal */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all w-full max-w-lg">
{/* Close button */}
<button
type="button"
className="absolute right-4 top-4 text-gray-400 hover:text-gray-500 focus:outline-none"
onClick={handleCancel}
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Modal content */}
<div className="sm:flex sm:items-start">
<div className="w-full mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">
{t('credentials.changePasswordComplexity')}
</h3>
<div className="space-y-4">
{/* Password Preview */}
<div className="mt-4">
<div className="flex items-center gap-2">
<input
type="text"
value={previewPassword}
readOnly
className="flex-1 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:text-white font-mono"
/>
<button
type="button"
onClick={handleRefreshPreview}
className="px-3 py-2 text-sm text-gray-500 dark:text-white bg-gray-200 hover:bg-gray-300 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800"
title={t('credentials.generateNewPreview')}
>
<svg className="w-4 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
</div>
</div>
{/* Character Type Toggle Buttons */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
{/* Lowercase Toggle */}
<button
type="button"
onClick={() => handleSettingChange('UseLowercase', !settings.UseLowercase)}
className={`flex items-center justify-center px-3 py-2 rounded-md text-sm font-medium transition-colors ${
settings.UseLowercase
? 'bg-primary-600 text-white hover:bg-primary-700'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
}`}
title={t('credentials.includeLowercase')}
>
<span className="font-mono text-base">a-z</span>
</button>
{/* Uppercase Toggle */}
<button
type="button"
onClick={() => handleSettingChange('UseUppercase', !settings.UseUppercase)}
className={`flex items-center justify-center px-3 py-2 rounded-md text-sm font-medium transition-colors ${
settings.UseUppercase
? 'bg-primary-600 text-white hover:bg-primary-700'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
}`}
title={t('credentials.includeUppercase')}
>
<span className="font-mono text-base">A-Z</span>
</button>
{/* Numbers Toggle */}
<button
type="button"
onClick={() => handleSettingChange('UseNumbers', !settings.UseNumbers)}
className={`flex items-center justify-center px-3 py-2 rounded-md text-sm font-medium transition-colors ${
settings.UseNumbers
? 'bg-primary-600 text-white hover:bg-primary-700'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
}`}
title={t('credentials.includeNumbers')}
>
<span className="font-mono text-base">0-9</span>
</button>
{/* Special Characters Toggle */}
<button
type="button"
onClick={() => handleSettingChange('UseSpecialChars', !settings.UseSpecialChars)}
className={`flex items-center justify-center px-3 py-2 rounded-md text-sm font-medium transition-colors ${
settings.UseSpecialChars
? 'bg-primary-600 text-white hover:bg-primary-700'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
}`}
title={t('credentials.includeSpecialChars')}
>
<span className="font-mono text-base">!@#</span>
</button>
</div>
{/* Avoid Ambiguous Characters - Checkbox */}
<div className="flex items-center">
<input
id="use-non-ambiguous"
type="checkbox"
checked={settings.UseNonAmbiguousChars}
onChange={(e) => handleSettingChange('UseNonAmbiguousChars', e.target.checked)}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded dark:bg-gray-700 dark:border-gray-600"
/>
<label htmlFor="use-non-ambiguous" className="ml-2 text-sm text-gray-700 dark:text-gray-300">
{t('credentials.avoidAmbiguousChars')}
</label>
</div>
</div>
</div>
{/* Action buttons */}
<div className="mt-5 sm:mt-6 sm:flex sm:flex-row-reverse">
<button
type="button"
className="inline-flex w-full justify-center items-center gap-1 rounded-md bg-gray-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-gray-500 sm:ml-3 sm:w-auto"
onClick={handleSave}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 13l-3 3m0 0l-3-3m3 3V8m0 13a9 9 0 110-18 9 9 0 010 18z" />
</svg>
{t('common.use')}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default PasswordConfigDialog;

View File

@@ -0,0 +1,205 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { PasswordSettings } from '@/utils/dist/shared/models/vault';
import { CreatePasswordGenerator } from '@/utils/dist/shared/password-generator';
import PasswordConfigDialog from './PasswordConfigDialog';
interface IPasswordFieldProps {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
error?: string;
showPassword?: boolean;
onShowPasswordChange?: (show: boolean) => void;
initialSettings: PasswordSettings;
}
/**
* Password field component with inline length slider and advanced configuration.
*/
const PasswordField: React.FC<IPasswordFieldProps> = ({
id,
label,
value,
onChange,
placeholder,
error,
showPassword: controlledShowPassword,
onShowPasswordChange,
initialSettings
}) => {
const { t } = useTranslation();
const [internalShowPassword, setInternalShowPassword] = useState(false);
const [showConfigDialog, setShowConfigDialog] = useState(false);
const [currentSettings, setCurrentSettings] = useState<PasswordSettings>(initialSettings);
// Use controlled or uncontrolled showPassword state
const showPassword = controlledShowPassword !== undefined ? controlledShowPassword : internalShowPassword;
/**
* Set the showPassword state.
*/
const setShowPassword = useCallback((show: boolean): void => {
if (controlledShowPassword !== undefined) {
onShowPasswordChange?.(show);
} else {
setInternalShowPassword(show);
}
}, [controlledShowPassword, onShowPasswordChange]);
// Initialize settings only once when component mounts
useEffect(() => {
setCurrentSettings({ ...initialSettings });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only run on mount to avoid resetting user changes
const generatePassword = useCallback((settings: PasswordSettings) => {
try {
const passwordGenerator = CreatePasswordGenerator(settings);
const password = passwordGenerator.generateRandomPassword();
onChange(password);
setShowPassword(true);
} catch (error) {
console.error('Error generating password:', error);
}
}, [onChange, setShowPassword]);
const handleLengthChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const length = parseInt(e.target.value, 10);
const newSettings = { ...currentSettings, Length: length };
setCurrentSettings(newSettings);
// Always generate password when length changes
generatePassword(newSettings);
}, [currentSettings, generatePassword]);
const handleRegeneratePassword = useCallback(() => {
generatePassword(currentSettings);
}, [generatePassword, currentSettings]);
const handleConfiguredPassword = useCallback((password: string) => {
onChange(password);
setShowPassword(true);
}, [onChange, setShowPassword]);
const handleAdvancedSettingsChange = useCallback((newSettings: PasswordSettings) => {
setCurrentSettings(newSettings);
}, []);
const togglePasswordVisibility = useCallback(() => {
setShowPassword(!showPassword);
}, [showPassword, setShowPassword]);
const openConfigDialog = useCallback(() => {
setShowConfigDialog(true);
}, []);
return (
<div className="space-y-2">
{/* Label */}
<label htmlFor={id} className="block text-sm font-medium text-gray-900 dark:text-white">
{label}
</label>
{/* Password Input with Buttons */}
<div className="flex">
<div className="relative flex-grow">
<input
type={showPassword ? 'text' : 'password'}
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="outline-0 shadow-sm border border-gray-300 bg-gray-50 text-gray-900 sm:text-sm rounded-l-lg block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
/>
</div>
<div className="flex">
{/* Show/Hide Password Button */}
<button
type="button"
onClick={togglePasswordVisibility}
className="px-3 text-gray-500 dark:text-white bg-gray-200 hover:bg-gray-300 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium text-sm dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800"
title={showPassword ? t('common.hidePassword') : t('common.showPassword')}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{showPassword ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
) : (
<>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</>
)}
</svg>
</button>
{/* Generate Password Button */}
<button
type="button"
onClick={handleRegeneratePassword}
className="px-3 text-gray-500 dark:text-white bg-gray-200 hover:bg-gray-300 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-r-lg text-sm border-l border-gray-300 dark:border-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800"
title={t('credentials.generateRandomPassword')}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
{/* Inline Password Length Slider */}
<div className="pt-2">
<div className="flex items-center justify-between mb-2">
<label htmlFor={`${id}-length`} className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('credentials.passwordLength')}
</label>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">
{currentSettings.Length}
</span>
<button
type="button"
onClick={openConfigDialog}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
title={t('credentials.changePasswordComplexity')}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
</div>
<input
type="range"
id={`${id}-length`}
min="8"
max="64"
value={currentSettings.Length}
onChange={handleLengthChange}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div>
{/* Error Message */}
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{/* Advanced Configuration Dialog */}
<PasswordConfigDialog
isOpen={showConfigDialog}
onClose={() => setShowConfigDialog(false)}
onSave={handleConfiguredPassword}
onSettingsChange={handleAdvancedSettingsChange}
initialSettings={currentSettings}
/>
</div>
);
};
export default PasswordField;

View File

@@ -0,0 +1,78 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
interface IUsernameFieldProps {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
error?: string;
onRegenerate: () => void;
}
/**
* Username field component with regenerate functionality.
*/
const UsernameField: React.FC<IUsernameFieldProps> = ({
id,
label,
value,
onChange,
placeholder,
error,
onRegenerate
}) => {
const { t } = useTranslation();
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
}, [onChange]);
const handleRegenerate = useCallback(() => {
onRegenerate();
}, [onRegenerate]);
return (
<div className="space-y-2">
{/* Label */}
<label htmlFor={id} className="block text-sm font-medium text-gray-900 dark:text-white">
{label}
</label>
{/* Username Input with Button */}
<div className="flex">
<div className="relative flex-grow">
<input
type="text"
id={id}
value={value}
onChange={handleInputChange}
placeholder={placeholder}
className="outline-0 shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-l-lg block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
/>
</div>
<div className="flex">
{/* Generate Username Button */}
<button
type="button"
onClick={handleRegenerate}
className="px-3 text-gray-500 dark:text-white bg-gray-200 hover:bg-gray-300 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-r-lg text-sm dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800"
title={t('credentials.generateRandomUsername')}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
{/* Error Message */}
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
);
};
export default UsernameField;

View File

@@ -1,7 +1,7 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import { sendMessage } from 'webext-bridge/popup';
import type { VaultMetadata } from '@/utils/dist/shared/models/metadata';
import type { EncryptionKeyDerivationParams, VaultMetadata } from '@/utils/dist/shared/models/metadata';
import type { VaultResponse } from '@/utils/dist/shared/models/webapi';
import EncryptionUtility from '@/utils/EncryptionUtility';
import SqliteClient from '@/utils/SqliteClient';
@@ -13,6 +13,8 @@ type DbContextType = {
dbInitialized: boolean;
dbAvailable: boolean;
initializeDatabase: (vaultResponse: VaultResponse, derivedKey: string) => Promise<SqliteClient>;
storeEncryptionKey: (derivedKey: string) => Promise<void>;
storeEncryptionKeyDerivationParams: (params: EncryptionKeyDerivationParams) => Promise<void>;
clearDatabase: () => void;
getVaultMetadata: () => Promise<VaultMetadata | null>;
setCurrentVaultRevisionNumber: (revisionNumber: number) => Promise<void>;
@@ -70,7 +72,6 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
*/
const request: StoreVaultRequest = {
vaultBlob: vaultResponse.vault.blob,
derivedKey: derivedKey,
publicEmailDomainList: vaultResponse.vault.publicEmailDomainList,
privateEmailDomainList: vaultResponse.vault.privateEmailDomainList,
vaultRevisionNumber: vaultResponse.vault.currentRevisionNumber,
@@ -145,6 +146,20 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
}
}, [dbInitialized, checkStoredVault]);
/**
* Store encryption key in background worker.
*/
const storeEncryptionKey = useCallback(async (encryptionKey: string) : Promise<void> => {
await sendMessage('STORE_ENCRYPTION_KEY', encryptionKey, 'background');
}, []);
/**
* Store encryption key derivation params in background worker.
*/
const storeEncryptionKeyDerivationParams = useCallback(async (params: EncryptionKeyDerivationParams) : Promise<void> => {
await sendMessage('STORE_ENCRYPTION_KEY_DERIVATION_PARAMS', params, 'background');
}, []);
/**
* Clear database and remove from background worker, called when logging out.
*/
@@ -160,11 +175,13 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
dbInitialized,
dbAvailable,
initializeDatabase,
storeEncryptionKey,
storeEncryptionKeyDerivationParams,
clearDatabase,
getVaultMetadata,
setCurrentVaultRevisionNumber,
hasPendingMigrations,
}), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, clearDatabase, getVaultMetadata, setCurrentVaultRevisionNumber, hasPendingMigrations]);
}), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, storeEncryptionKey, storeEncryptionKeyDerivationParams, clearDatabase, getVaultMetadata, setCurrentVaultRevisionNumber, hasPendingMigrations]);
return (
<DbContext.Provider value={contextValue}>

View File

@@ -1,4 +1,5 @@
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { sendMessage } from 'webext-bridge/popup';
import { useDb } from '@/entrypoints/popup/context/DbContext';
@@ -22,8 +23,9 @@ export function useVaultMutate() : {
isLoading: boolean;
syncStatus: string;
} {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [syncStatus, setSyncStatus] = useState('Syncing vault');
const [syncStatus, setSyncStatus] = useState(t('common.syncingVault'));
const dbContext = useDb();
const { syncVault } = useVaultSync();
@@ -34,24 +36,24 @@ export function useVaultMutate() : {
operation: () => Promise<void>,
options: VaultMutationOptions
) : Promise<void> => {
setSyncStatus('Saving changes to vault');
setSyncStatus(t('common.savingChangesToVault'));
// Execute the provided operation (e.g. create/update/delete credential)
await operation();
setSyncStatus('Uploading vault to server');
setSyncStatus(t('common.uploadingVaultToServer'));
try {
// Upload the updated vault to the server.
const base64Vault = dbContext.sqliteClient!.exportToBase64();
// Get derived key from background worker
const derivedKey = await sendMessage('GET_DERIVED_KEY', {}, 'background') as string;
// Get encryption key from background worker
const encryptionKey = await sendMessage('GET_ENCRYPTION_KEY', {}, 'background') as string;
// Encrypt the vault.
const encryptedVaultBlob = await EncryptionUtility.symmetricEncrypt(
base64Vault,
derivedKey
encryptionKey
);
const request: UploadVaultRequest = {
@@ -90,7 +92,7 @@ export function useVaultMutate() : {
}
throw error;
}
}, [dbContext]);
}, [dbContext, t]);
/**
* Hook to execute a vault mutation which uploads a new encrypted vault to the server
@@ -101,11 +103,11 @@ export function useVaultMutate() : {
) => {
try {
setIsLoading(true);
setSyncStatus('Checking for vault updates');
setSyncStatus(t('common.checkingVaultUpdates'));
// Skip sync check if requested (e.g., during upgrade operations)
if (options.skipSyncCheck) {
setSyncStatus('Executing operation...');
setSyncStatus(t('common.executingOperation'));
await executeMutateOperation(operation, options);
return;
}
@@ -154,7 +156,7 @@ export function useVaultMutate() : {
setIsLoading(false);
setSyncStatus('');
}
}, [syncVault, executeMutateOperation]);
}, [syncVault, executeMutateOperation, t]);
return {
executeVaultMutation,

View File

@@ -1,10 +1,12 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { sendMessage } from 'webext-bridge/popup';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import type { EncryptionKeyDerivationParams } from '@/utils/dist/shared/models/metadata';
import type { VaultResponse } from '@/utils/dist/shared/models/webapi';
/**
@@ -46,6 +48,7 @@ type VaultSyncOptions = {
export const useVaultSync = () : {
syncVault: (options?: VaultSyncOptions) => Promise<boolean>;
} => {
const { t } = useTranslation();
const authContext = useAuth();
const dbContext = useDb();
const webApi = useWebApi();
@@ -65,7 +68,7 @@ export const useVaultSync = () : {
}
// Check app status and vault revision
onStatus?.('Checking vault updates');
onStatus?.(t('common.checkingVaultUpdates'));
const statusResponse = await withMinimumDelay(() => webApi.getStatus(), 300, enableDelay);
// Check if server is actually available, 0.0.0 indicates connection error which triggers offline mode.
@@ -75,7 +78,20 @@ export const useVaultSync = () : {
const statusError = webApi.validateStatusResponse(statusResponse);
if (statusError) {
onError?.(statusError);
onError?.(t('common.errors.' + statusError));
return false;
}
// Check if the SRP salt has changed compared to locally stored encryption key derivation params
const storedEncryptionParams = await sendMessage('GET_ENCRYPTION_KEY_DERIVATION_PARAMS', {}, 'background') as EncryptionKeyDerivationParams | null;
if (storedEncryptionParams && statusResponse.srpSalt && statusResponse.srpSalt !== storedEncryptionParams.salt) {
/**
* Server SRP salt has changed compared to locally stored value, which means the user has changed
* their password since the last time they logged in. This means that the local encryption key is no
* longer valid and the user needs to re-authenticate. We trigger a logout but do not revoke tokens
* as these were already revoked by the server upon password change.
*/
await webApi.logout(t('common.errors.passwordChanged'));
return false;
}
@@ -90,10 +106,10 @@ export const useVaultSync = () : {
const vaultRevisionNumber = vaultMetadata?.vaultRevisionNumber ?? 0;
if (statusResponse.vaultRevision > vaultRevisionNumber) {
onStatus?.('Syncing updated vault');
onStatus?.(t('common.syncingUpdatedVault'));
const vaultResponseJson = await withMinimumDelay(() => webApi.get<VaultResponse>('Vault'), 1000, enableDelay);
const vaultError = webApi.validateVaultResponse(vaultResponseJson as VaultResponse);
const vaultError = webApi.validateVaultResponse(vaultResponseJson as VaultResponse, t);
if (vaultError) {
// Only logout if it's an authentication error, not a network error
if (vaultError.includes('authentication') || vaultError.includes('unauthorized')) {
@@ -112,9 +128,9 @@ export const useVaultSync = () : {
}
try {
// Get derived key from background worker
const passwordHashBase64 = await sendMessage('GET_DERIVED_KEY', {}, 'background') as string;
const sqliteClient = await dbContext.initializeDatabase(vaultResponseJson as VaultResponse, passwordHashBase64);
// Get encryption key from background worker
const encryptionKey = await sendMessage('GET_ENCRYPTION_KEY', {}, 'background') as string;
const sqliteClient = await dbContext.initializeDatabase(vaultResponseJson as VaultResponse, encryptionKey);
// Check if the current vault version is known and up to date, if not known trigger an exception, if not up to date redirect to the upgrade page.
if (await sqliteClient.hasPendingMigrations()) {
@@ -169,7 +185,7 @@ export const useVaultSync = () : {
onError?.(errorMessage);
return false;
}
}, [authContext, dbContext, webApi]);
}, [authContext, dbContext, webApi, t]);
return { syncVault };
};

View File

@@ -8,19 +8,33 @@ import { LoadingProvider } from '@/entrypoints/popup/context/LoadingContext';
import { ThemeProvider } from '@/entrypoints/popup/context/ThemeContext';
import { WebApiProvider } from '@/entrypoints/popup/context/WebApiContext';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<DbProvider>
<AuthProvider>
<WebApiProvider>
<LoadingProvider>
<HeaderButtonsProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</HeaderButtonsProvider>
</LoadingProvider>
</WebApiProvider>
</AuthProvider>
</DbProvider>
);
import i18n from '@/i18n/i18n';
/**
* Renders the main application.
*/
const renderApp = (): void => {
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<DbProvider>
<AuthProvider>
<WebApiProvider>
<LoadingProvider>
<HeaderButtonsProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</HeaderButtonsProvider>
</LoadingProvider>
</WebApiProvider>
</AuthProvider>
</DbProvider>
);
};
// Wait for i18n to be ready before rendering React. Not waiting can cause issues on some browsers, Firefox on Windows specifically.
if (i18n.isInitialized) {
renderApp();
} else {
i18n.on('initialized', renderApp);
}

View File

@@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import * as Yup from 'yup';
import LanguageSwitcher from '@/entrypoints/popup/components/LanguageSwitcher';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { AppInfo } from '@/utils/AppInfo';
@@ -19,10 +21,13 @@ const DEFAULT_OPTIONS: ApiOption[] = [
];
// Validation schema for URLs
const urlSchema = Yup.object().shape({
/**
* Creates a URL validation schema with localized error messages.
*/
const createUrlSchema = (t: (key: string) => string): Yup.ObjectSchema<{apiUrl: string; clientUrl: string}> => Yup.object().shape({
apiUrl: Yup.string()
.required('API URL is required')
.test('is-valid-api-url', 'Please enter a valid API URL', (value: string | undefined) => {
.required(t('validation.apiUrlRequired'))
.test('is-valid-api-url', t('settings.validation.apiUrlInvalid'), (value: string | undefined) => {
if (!value) {
return true; // Allow empty for non-custom option
}
@@ -34,8 +39,8 @@ const urlSchema = Yup.object().shape({
}
}),
clientUrl: Yup.string()
.required('Client URL is required')
.test('is-valid-client-url', 'Please enter a valid client URL', (value: string | undefined) => {
.required(t('validation.clientUrlRequired'))
.test('is-valid-client-url', t('settings.validation.clientUrlInvalid'), (value: string | undefined) => {
if (!value) {
return true; // Allow empty for non-custom option
}
@@ -52,6 +57,7 @@ const urlSchema = Yup.object().shape({
* Auth settings page only shown when user is not logged in.
*/
const AuthSettings: React.FC = () => {
const { t } = useTranslation();
const [selectedOption, setSelectedOption] = useState<string>('');
const [customUrl, setCustomUrl] = useState<string>('');
const [customClientUrl, setCustomClientUrl] = useState<string>('');
@@ -59,6 +65,8 @@ const AuthSettings: React.FC = () => {
const [errors, setErrors] = useState<{ apiUrl?: string; clientUrl?: string }>({});
const { setIsInitialLoading } = useLoading();
const urlSchema = createUrlSchema(t);
useEffect(() => {
/**
* Load the stored settings from the storage.
@@ -165,9 +173,17 @@ const AuthSettings: React.FC = () => {
return (
<div className="p-4">
{/* Language Settings Section */}
<div className="mb-6">
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('common.language')}</p>
<LanguageSwitcher variant="dropdown" size="sm" />
</div>
</div>
<div className="mb-6">
<label htmlFor="api-connection" className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
API Connection
{t('settings.serverUrl')}
</label>
<select
value={selectedOption}
@@ -222,7 +238,7 @@ const AuthSettings: React.FC = () => {
{/* Autofill Popup Settings Section */}
<div className="mb-6">
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-gray-900 dark:text-white">Autofill popup</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('settings.autofillEnabled')}</p>
<button
onClick={toggleGlobalPopup}
className={`px-4 py-2 rounded-md transition-colors ${
@@ -231,13 +247,13 @@ const AuthSettings: React.FC = () => {
: 'bg-red-200 text-red-800 hover:bg-red-300 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
}`}
>
{isGloballyEnabled ? 'Enabled' : 'Disabled'}
{isGloballyEnabled ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
</button>
</div>
</div>
<div className="text-center text-gray-400 dark:text-gray-600">
Version: {AppInfo.VERSION}
{t('settings.version')}: {AppInfo.VERSION}
</div>
</div>
);

View File

@@ -1,28 +1,32 @@
import { Buffer } from 'buffer';
import { yupResolver } from '@hookform/resolvers/yup';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import { sendMessage } from 'webext-bridge/popup';
import * as Yup from 'yup';
import AttachmentUploader from '@/entrypoints/popup/components/CredentialDetails/AttachmentUploader';
import EmailDomainField from '@/entrypoints/popup/components/EmailDomainField';
import { FormInput } from '@/entrypoints/popup/components/FormInput';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
import Modal from '@/entrypoints/popup/components/Modal';
import PasswordField from '@/entrypoints/popup/components/PasswordField';
import UsernameField from '@/entrypoints/popup/components/UsernameField';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
import { IdentityHelperUtils, CreateIdentityGenerator, CreateUsernameEmailGenerator, Identity, Gender } from '@/utils/dist/shared/identity-generator';
import type { Credential } from '@/utils/dist/shared/models/vault';
import type { Attachment, Credential } from '@/utils/dist/shared/models/vault';
import { CreatePasswordGenerator } from '@/utils/dist/shared/password-generator';
import LoadingSpinner from '../components/LoadingSpinner';
import { useLoading } from '../context/LoadingContext';
type CredentialMode = 'random' | 'manual';
// Persisted form data type used for JSON serialization.
@@ -32,52 +36,58 @@ type PersistedFormData = {
formValues: Omit<Credential, 'Logo'> & { Logo?: string | null };
}
/**
* Validation schema for the credential form.
*/
const credentialSchema = Yup.object().shape({
Id: Yup.string(),
ServiceName: Yup.string().required('Service name is required'),
ServiceUrl: Yup.string().url('Invalid URL format').nullable().optional(),
Alias: Yup.object().shape({
FirstName: Yup.string().nullable().optional(),
LastName: Yup.string().nullable().optional(),
NickName: Yup.string().nullable().optional(),
BirthDate: Yup.string()
.nullable()
.optional()
.test(
'is-valid-date-format',
'Date must be in YYYY-MM-DD format',
value => {
if (!value) {
return true;
}
return /^\d{4}-\d{2}-\d{2}$/.test(value);
},
),
Gender: Yup.string().nullable().optional(),
Email: Yup.string().email('Invalid email format').nullable().optional()
}),
Username: Yup.string().nullable().optional(),
Password: Yup.string().nullable().optional(),
Notes: Yup.string().nullable().optional()
});
/**
* Add or edit credential page.
*/
const CredentialAddEdit: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams();
const navigate = useNavigate();
const dbContext = useDb();
// If we received an ID, we're in edit mode
const isEditMode = id !== undefined && id.length > 0;
/**
* Validation schema for the credential form with translatable messages.
*/
const credentialSchema = useMemo(() => Yup.object().shape({
Id: Yup.string(),
ServiceName: Yup.string().required(t('credentials.validation.serviceNameRequired')),
ServiceUrl: Yup.string().nullable().optional(),
Alias: Yup.object().shape({
FirstName: Yup.string().nullable().optional(),
LastName: Yup.string().nullable().optional(),
NickName: Yup.string().nullable().optional(),
BirthDate: Yup.string()
.nullable()
.optional()
.test(
'is-valid-date-format',
t('credentials.validation.invalidDateFormat'),
value => {
if (!value) {
return true;
}
return /^\d{4}-\d{2}-\d{2}$/.test(value);
},
),
Gender: Yup.string().nullable().optional(),
Email: Yup.string().email(t('credentials.validation.invalidEmail')).nullable().optional()
}),
Username: Yup.string().nullable().optional(),
Password: Yup.string().nullable().optional(),
Notes: Yup.string().nullable().optional()
}), [t]);
const { executeVaultMutation, isLoading, syncStatus } = useVaultMutate();
const [mode, setMode] = useState<CredentialMode>('random');
const { setHeaderButtons } = useHeaderButtons();
const { setIsInitialLoading } = useLoading();
const [localLoading, setLocalLoading] = useState(true);
const [showPassword, setShowPassword] = useState(false);
const [showPassword, setShowPassword] = useState(!isEditMode);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [originalAttachmentIds, setOriginalAttachmentIds] = useState<string[]>([]);
const webApi = useWebApi();
const serviceNameRef = useRef<HTMLInputElement>(null);
@@ -89,7 +99,7 @@ const CredentialAddEdit: React.FC = () => {
Username: "",
Password: "",
ServiceName: "",
ServiceUrl: "",
ServiceUrl: "https://",
Notes: "",
Alias: {
FirstName: "",
@@ -141,9 +151,6 @@ const CredentialAddEdit: React.FC = () => {
return (): void => subscription.unsubscribe();
}, [watch, persistFormValues]);
// If we received an ID, we're in edit mode
const isEditMode = id !== undefined && id.length > 0;
/**
* Loads persisted form values from storage. This is used to keep track of form changes
* and restore them when the page is reloaded. The browser extension popup will close
@@ -223,7 +230,15 @@ const CredentialAddEdit: React.FC = () => {
setIsInitialLoading(false);
// Load persisted form values if they exist.
loadPersistedValues();
loadPersistedValues().then(() => {
// Generate default password if no persisted password exists
if (!watch('Password')) {
const passwordSettings = dbContext.sqliteClient!.getPasswordSettings();
const passwordGenerator = CreatePasswordGenerator(passwordSettings);
const defaultPassword = passwordGenerator.generateRandomPassword();
setValue('Password', defaultPassword);
}
});
return;
}
@@ -238,6 +253,11 @@ const CredentialAddEdit: React.FC = () => {
setValue(key as keyof Credential, value);
});
// Load attachments for this credential
const credentialAttachments = dbContext.sqliteClient.getAttachmentsForCredential(id);
setAttachments(credentialAttachments);
setOriginalAttachmentIds(credentialAttachments.map(a => a.Id));
setMode('manual');
setIsInitialLoading(false);
@@ -251,7 +271,7 @@ const CredentialAddEdit: React.FC = () => {
console.error('Error loading credential:', err);
setIsInitialLoading(false);
}
}, [dbContext.sqliteClient, id, navigate, setIsInitialLoading, setValue, loadPersistedValues]);
}, [dbContext.sqliteClient, id, navigate, setIsInitialLoading, setValue, loadPersistedValues, watch]);
/**
* Handle the delete button click.
@@ -368,16 +388,9 @@ const CredentialAddEdit: React.FC = () => {
}
}, [setValue, watch]);
const generateRandomPassword = useCallback(async () => {
try {
const { passwordGenerator } = await initializeGenerators();
const password = passwordGenerator.generateRandomPassword();
setValue('Password', password);
setShowPassword(true);
} catch (error) {
console.error('Error generating random password:', error);
}
}, [initializeGenerators, setValue]);
const initialPasswordSettings = useMemo(() => {
return dbContext.sqliteClient?.getPasswordSettings();
}, [dbContext.sqliteClient]);
/**
* Handle form submission.
@@ -389,6 +402,11 @@ const CredentialAddEdit: React.FC = () => {
birthdate = IdentityHelperUtils.normalizeBirthDateForDb(birthdate);
}
// Clean up empty protocol-only URLs
if (data.ServiceUrl === 'http://' || data.ServiceUrl === 'https://') {
data.ServiceUrl = '';
}
// If we're creating a new credential and mode is random, generate random values here
if (!isEditMode && mode === 'random') {
// Generate random values now and then read them from the form fields to manually assign to the credentialToSave object
@@ -401,6 +419,9 @@ const CredentialAddEdit: React.FC = () => {
data.Alias.BirthDate = birthdate;
data.Alias.Gender = watch('Alias.Gender');
data.Alias.Email = watch('Alias.Email');
// Clean up ServiceUrl for random mode too
const serviceUrl = watch('ServiceUrl');
data.ServiceUrl = (serviceUrl === 'http://' || serviceUrl === 'https://') ? '' : serviceUrl;
}
// Extract favicon from service URL if the credential has one
@@ -427,9 +448,9 @@ const CredentialAddEdit: React.FC = () => {
setLocalLoading(false);
if (isEditMode) {
await dbContext.sqliteClient!.updateCredentialById(data);
await dbContext.sqliteClient!.updateCredentialById(data, originalAttachmentIds, attachments);
} else {
const credentialId = await dbContext.sqliteClient!.createCredential(data);
const credentialId = await dbContext.sqliteClient!.createCredential(data, attachments);
data.Id = credentialId.toString();
}
}, {
@@ -448,7 +469,7 @@ const CredentialAddEdit: React.FC = () => {
}
},
});
}, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate, mode, watch, generateRandomAlias, webApi, clearPersistedValues]);
}, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate, mode, watch, generateRandomAlias, webApi, clearPersistedValues, originalAttachmentIds, attachments]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
@@ -458,14 +479,14 @@ const CredentialAddEdit: React.FC = () => {
{isEditMode && (
<HeaderButton
onClick={() => setShowDeleteModal(true)}
title="Delete credential"
title={t('credentials.deleteCredential')}
iconType={HeaderIconType.DELETE}
variant="danger"
/>
)}
<HeaderButton
onClick={handleSubmit(onSubmit)}
title="Save credential"
title={t('credentials.saveCredential')}
iconType={HeaderIconType.SAVE}
/>
</div>
@@ -473,7 +494,7 @@ const CredentialAddEdit: React.FC = () => {
setHeaderButtons(headerButtonsJSX);
return () => {};
}, [setHeaderButtons, handleSubmit, onSubmit, isEditMode]);
}, [setHeaderButtons, handleSubmit, onSubmit, isEditMode, t]);
// Clear header buttons on unmount
useEffect((): (() => void) => {
@@ -481,7 +502,7 @@ const CredentialAddEdit: React.FC = () => {
}, [setHeaderButtons]);
if (isEditMode && !watch('ServiceName')) {
return <div>Loading...</div>;
return <div>{t('common.loading')}</div>;
}
return (
@@ -503,10 +524,10 @@ const CredentialAddEdit: React.FC = () => {
setShowDeleteModal(false);
void handleDelete();
}}
title="Delete Credential"
message="Are you sure you want to delete this credential? This action cannot be undone."
confirmText="Delete"
cancelText="Cancel"
title={t('credentials.deleteCredentialTitle')}
message={t('credentials.deleteCredentialConfirm')}
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
variant="danger"
/>
@@ -527,7 +548,7 @@ const CredentialAddEdit: React.FC = () => {
<circle cx="8" cy="16" r="1"/>
<circle cx="16" cy="16" r="1"/>
</svg>
Random Alias
{t('credentials.randomAlias')}
</button>
<button
type="button"
@@ -540,18 +561,18 @@ const CredentialAddEdit: React.FC = () => {
<circle cx="12" cy="7" r="4"/>
<path d="M5.5 20a6.5 6.5 0 0 1 13 0"/>
</svg>
Manual
{t('credentials.manual')}
</button>
</div>
)}
<div className="space-y-4">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Service</h2>
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('credentials.service')}</h2>
<div className="space-y-4">
<FormInput
id="serviceName"
label="Service Name"
label={t('credentials.serviceName')}
ref={serviceNameRef}
value={watch('ServiceName') ?? ''}
onChange={(value) => setValue('ServiceName', value)}
@@ -560,7 +581,7 @@ const CredentialAddEdit: React.FC = () => {
/>
<FormInput
id="serviceUrl"
label="Service URL"
label={t('credentials.serviceUrl')}
value={watch('ServiceUrl') ?? ''}
onChange={(value) => setValue('ServiceUrl', value)}
error={errors.ServiceUrl?.message}
@@ -571,91 +592,88 @@ const CredentialAddEdit: React.FC = () => {
{(mode === 'manual' || isEditMode) && (
<>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Login Credentials</h2>
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('credentials.loginCredentials')}</h2>
<div className="space-y-4">
<FormInput
<EmailDomainField
id="email"
label={t('common.email')}
value={watch('Alias.Email') ?? ''}
onChange={(value: string) => setValue('Alias.Email', value)}
error={errors.Alias?.Email?.message}
/>
<UsernameField
id="username"
label="Username"
label={t('common.username')}
value={watch('Username') ?? ''}
onChange={(value) => setValue('Username', value)}
error={errors.Username?.message}
buttons={[
{
icon: 'refresh',
onClick: generateRandomUsername,
title: 'Generate random username'
}
]}
/>
<FormInput
id="password"
label="Password"
type="password"
value={watch('Password') ?? ''}
onChange={(value) => setValue('Password', value)}
error={errors.Password?.message}
showPassword={showPassword}
onShowPasswordChange={setShowPassword}
buttons={[
{
icon: 'refresh',
onClick: generateRandomPassword,
title: 'Generate random password'
}
]}
/>
<button
type="button"
onClick={handleGenerateRandomAlias}
className="w-full bg-primary-500 text-white py-2 px-4 rounded hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
>
Generate Random Alias
</button>
<FormInput
id="email"
label="Email"
value={watch('Alias.Email') ?? ''}
onChange={(value) => setValue('Alias.Email', value)}
error={errors.Alias?.Email?.message}
onRegenerate={generateRandomUsername}
/>
{initialPasswordSettings && (
<PasswordField
id="password"
label={t('common.password')}
value={watch('Password') ?? ''}
onChange={(value) => setValue('Password', value)}
error={errors.Password?.message}
showPassword={showPassword}
onShowPasswordChange={setShowPassword}
initialSettings={initialPasswordSettings}
/>
)}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Alias</h2>
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('credentials.alias')}</h2>
<div className="space-y-4">
<button
type="button"
onClick={handleGenerateRandomAlias}
className="w-full bg-primary-500 text-white py-2 px-4 rounded hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 flex items-center justify-center gap-2"
>
<svg className='w-5 h-5 inline-block' viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8" cy="8" r="1"/>
<circle cx="16" cy="8" r="1"/>
<circle cx="12" cy="12" r="1"/>
<circle cx="8" cy="16" r="1"/>
<circle cx="16" cy="16" r="1"/>
</svg>
<span>{t('credentials.generateRandomAlias')}</span>
</button>
<FormInput
id="firstName"
label="First Name"
label={t('credentials.firstName')}
value={watch('Alias.FirstName') ?? ''}
onChange={(value) => setValue('Alias.FirstName', value)}
error={errors.Alias?.FirstName?.message}
/>
<FormInput
id="lastName"
label="Last Name"
label={t('credentials.lastName')}
value={watch('Alias.LastName') ?? ''}
onChange={(value) => setValue('Alias.LastName', value)}
error={errors.Alias?.LastName?.message}
/>
<FormInput
id="nickName"
label="Nick Name"
label={t('credentials.nickName')}
value={watch('Alias.NickName') ?? ''}
onChange={(value) => setValue('Alias.NickName', value)}
error={errors.Alias?.NickName?.message}
/>
<FormInput
id="gender"
label="Gender"
label={t('credentials.gender')}
value={watch('Alias.Gender') ?? ''}
onChange={(value) => setValue('Alias.Gender', value)}
error={errors.Alias?.Gender?.message}
/>
<FormInput
id="birthDate"
label="Birth Date"
placeholder="YYYY-MM-DD"
label={t('credentials.birthDate')}
placeholder={t('credentials.birthDatePlaceholder')}
value={watch('Alias.BirthDate') ?? ''}
onChange={(value) => setValue('Alias.BirthDate', value)}
error={errors.Alias?.BirthDate?.message}
@@ -664,11 +682,11 @@ const CredentialAddEdit: React.FC = () => {
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Metadata</h2>
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('credentials.metadata')}</h2>
<div className="space-y-4">
<FormInput
id="notes"
label="Notes"
label={t('credentials.notes')}
value={watch('Notes') ?? ''}
onChange={(value) => setValue('Notes', value)}
multiline
@@ -677,6 +695,12 @@ const CredentialAddEdit: React.FC = () => {
/>
</div>
</div>
<AttachmentUploader
attachments={attachments}
onAttachmentsChange={setAttachments}
originalAttachmentIds={originalAttachmentIds}
/>
</>
)}
</div>

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import {
@@ -7,7 +8,8 @@ import {
TotpBlock,
LoginCredentialsBlock,
AliasBlock,
NotesBlock
NotesBlock,
AttachmentBlock
} from '@/entrypoints/popup/components/CredentialDetails';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
@@ -22,6 +24,7 @@ import type { Credential } from '@/utils/dist/shared/models/vault';
* Credential details page.
*/
const CredentialDetails: React.FC = (): React.ReactElement => {
const { t } = useTranslation();
const { id } = useParams();
const navigate = useNavigate();
const dbContext = useDb();
@@ -74,20 +77,20 @@ const CredentialDetails: React.FC = (): React.ReactElement => {
{!PopoutUtility.isPopup() && (
<HeaderButton
onClick={openInNewPopup}
title="Open in new window"
title={t('common.openInNewWindow')}
iconType={HeaderIconType.EXPAND}
/>
)}
<HeaderButton
onClick={handleEdit}
title="Edit credential"
title={t('credentials.editCredential')}
iconType={HeaderIconType.EDIT}
/>
</div>
);
setHeaderButtons(headerButtonsJSX);
return () => {};
}, [setHeaderButtons, handleEdit, openInNewPopup]);
}, [setHeaderButtons, handleEdit, openInNewPopup, t]);
// Clear header buttons on unmount
useEffect((): (() => void) => {
@@ -95,7 +98,7 @@ const CredentialDetails: React.FC = (): React.ReactElement => {
}, [setHeaderButtons]);
if (!credential) {
return <div>Loading...</div>;
return <div>{t('common.loading')}</div>;
}
return (
@@ -112,6 +115,7 @@ const CredentialDetails: React.FC = (): React.ReactElement => {
<LoginCredentialsBlock credential={credential} />
<AliasBlock credential={credential} />
<NotesBlock notes={credential.Notes} />
<AttachmentBlock credentialId={credential.Id} />
</div>
);
};

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import CredentialCard from '@/entrypoints/popup/components/CredentialCard';
@@ -21,6 +22,7 @@ import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
* Credentials list page.
*/
const CredentialsList: React.FC = () => {
const { t } = useTranslation();
const dbContext = useDb();
const webApi = useWebApi();
const navigate = useNavigate();
@@ -133,14 +135,23 @@ const CredentialsList: React.FC = () => {
refreshCredentials();
}, [dbContext?.sqliteClient, setIsLoading, setIsInitialLoading]);
// Add this function to filter credentials
const filteredCredentials = credentials.filter(cred => {
const filteredCredentials = credentials.filter(credential => {
const searchLower = searchTerm.toLowerCase();
/**
* We filter credentials by searching in the following fields:
* - Service name
* - Username
* - Alias email
* - Service URL
* - Notes
*/
const searchableFields = [
cred.ServiceName?.toLowerCase(),
cred.Username?.toLowerCase(),
cred.Alias?.Email?.toLowerCase(),
cred.ServiceUrl?.toLowerCase()
credential.ServiceName?.toLowerCase(),
credential.Username?.toLowerCase(),
credential.Alias?.Email?.toLowerCase(),
credential.ServiceUrl?.toLowerCase(),
credential.Notes?.toLowerCase(),
];
return searchableFields.some(field => field?.includes(searchLower));
});
@@ -156,19 +167,21 @@ const CredentialsList: React.FC = () => {
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-gray-900 dark:text-white text-xl">Credentials</h2>
<h2 className="text-gray-900 dark:text-white text-xl">{t('credentials.title')}</h2>
<ReloadButton onClick={syncVaultAndRefresh} />
</div>
{credentials.length > 0 ? (
<input
type="text"
placeholder="Search credentials..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
autoFocus
className="w-full p-2 mb-4 border dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-blue-500 focus:border-blue-500"
/>
<div className="mb-4">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={`${t('content.searchVault')}`}
autoFocus
className="w-full p-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
) : (
<></>
)}
@@ -176,13 +189,10 @@ const CredentialsList: React.FC = () => {
{credentials.length === 0 ? (
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
<p className="text-sm">
Welcome to AliasVault!
{t('credentials.welcomeTitle')}
</p>
<p className="text-sm">
To use the AliasVault browser extension: navigate to a website and use the AliasVault autofill popup to create a new credential.
</p>
<p className="text-sm">
If you want to create manual identities, open the full AliasVault app via the popout icon in the top right corner.
{t('credentials.welcomeDescription')}
</p>
</div>
) : (

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, useNavigate } from 'react-router-dom';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
@@ -22,6 +23,7 @@ import { HeaderIconType } from '../components/Icons/HeaderIcons';
* Email details page.
*/
const EmailDetails: React.FC = (): React.ReactElement => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const dbContext = useDb();
@@ -149,13 +151,13 @@ const EmailDetails: React.FC = (): React.ReactElement => {
{!PopoutUtility.isPopup() && (
<HeaderButton
onClick={openInNewPopup}
title="Open in new window"
title={t('common.openInNewWindow')}
iconType={HeaderIconType.EXPAND}
/>
)}
<HeaderButton
onClick={() => setShowDeleteModal(true)}
title="Delete email"
title={t('emails.deleteEmail')}
iconType={HeaderIconType.DELETE}
variant="danger"
/>
@@ -166,7 +168,7 @@ const EmailDetails: React.FC = (): React.ReactElement => {
setHeaderButtonsConfigured(true);
}
return () => {};
}, [setHeaderButtons, headerButtonsConfigured, openInNewPopup]);
}, [setHeaderButtons, headerButtonsConfigured, openInNewPopup, t]);
// Clear header buttons on unmount
useEffect((): (() => void) => {
@@ -182,11 +184,11 @@ const EmailDetails: React.FC = (): React.ReactElement => {
}
if (error) {
return <div className="text-red-500">Error: {error}</div>;
return <div className="text-red-500">{t('common.error')} {error}</div>;
}
if (!email) {
return <div className="text-gray-500">Email not found</div>;
return <div className="text-gray-500">{t('emails.emailNotFound')}</div>;
}
return (
@@ -198,10 +200,10 @@ const EmailDetails: React.FC = (): React.ReactElement => {
setShowDeleteModal(false);
void handleDelete();
}}
title="Delete Email"
message="Are you sure you want to delete this email? This action cannot be undone."
confirmText="Delete"
cancelText="Cancel"
title={t('emails.deleteEmailTitle')}
message={t('emails.deleteEmailConfirm')}
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
variant="danger"
/>
@@ -212,9 +214,9 @@ const EmailDetails: React.FC = (): React.ReactElement => {
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{email.subject}</h1>
</div>
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<p>From: {email.fromDisplay} ({email.fromLocal}@{email.fromDomain})</p>
<p>To: {email.toLocal}@{email.toDomain}</p>
<p>Date: {new Date(email.dateSystem).toLocaleString()}</p>
<p>{t('emails.from')} {email.fromDisplay} ({email.fromLocal}@{email.fromDomain})</p>
<p>{t('emails.to')} {email.toLocal}@{email.toDomain}</p>
<p>{t('emails.date')} {new Date(email.dateSystem).toLocaleString()}</p>
</div>
</div>
@@ -224,10 +226,10 @@ const EmailDetails: React.FC = (): React.ReactElement => {
<iframe
srcDoc={ConversionUtility.convertAnchorTagsToOpenInNewTab(email.messageHtml)}
className="w-full min-h-[500px] border-0"
title="Email content"
title={t('emails.emailContent')}
/>
) : (
<pre className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">
<pre className="whitespace-pre-wrap text-gray-800 p-3">
{email.messagePlain}
</pre>
)}
@@ -237,7 +239,7 @@ const EmailDetails: React.FC = (): React.ReactElement => {
{email.attachments && email.attachments.length > 0 && (
<div className="p-6 border-t border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
Attachments
{t('emails.attachments')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{email.attachments.map((attachment) => (

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
@@ -20,6 +21,7 @@ import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
* Emails list page.
*/
const EmailsList: React.FC = () => {
const { t } = useTranslation();
const dbContext = useDb();
const webApi = useWebApi();
const { setHeaderButtons } = useHeaderButtons();
@@ -64,15 +66,15 @@ const EmailsList: React.FC = () => {
setEmails(decryptedEmails);
} catch (error) {
console.error(error);
throw new Error('Failed to load emails');
throw new Error(t('emails.errors.emailLoadError'));
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
setError(err instanceof Error ? err.message : t('common.errors.unknownError'));
} finally {
setIsLoading(false);
setIsInitialLoading(false);
}
}, [dbContext?.sqliteClient, webApi, setIsLoading, setIsInitialLoading]);
}, [dbContext?.sqliteClient, webApi, setIsLoading, setIsInitialLoading, t]);
useEffect(() => {
loadEmails();
@@ -83,7 +85,7 @@ const EmailsList: React.FC = () => {
const headerButtonsJSX = !PopoutUtility.isPopup() ? (
<HeaderButton
onClick={() => PopoutUtility.openInNewPopup()}
title="Open in new window"
title={t('common.openInNewWindow')}
iconType={HeaderIconType.EXPAND}
/>
) : null;
@@ -93,7 +95,7 @@ const EmailsList: React.FC = () => {
return () => {
setHeaderButtons(null);
};
}, [setHeaderButtons]);
}, [setHeaderButtons, t]);
/**
* Formats the date display for emails
@@ -104,18 +106,26 @@ const EmailsList: React.FC = () => {
const secondsAgo = Math.floor((now.getTime() - emailDate.getTime()) / 1000);
if (secondsAgo < 60) {
return 'just now';
return t('emails.dateFormat.justNow');
} else if (secondsAgo < 3600) {
// Less than 1 hour ago
const minutes = Math.floor(secondsAgo / 60);
return `${minutes} ${minutes === 1 ? 'min' : 'mins'} ago`;
if (minutes === 1) {
return t('emails.dateFormat.minutesAgo_single', { count: minutes });
} else {
return t('emails.dateFormat.minutesAgo_plural', { count: minutes });
}
} else if (secondsAgo < 86400) {
// Less than 24 hours ago
const hours = Math.floor(secondsAgo / 3600);
return `${hours} ${hours === 1 ? 'hr' : 'hrs'} ago`;
if (hours === 1) {
return t('emails.dateFormat.hoursAgo_single', { count: hours });
} else {
return t('emails.dateFormat.hoursAgo_plural', { count: hours });
}
} else if (secondsAgo < 172800) {
// Less than 48 hours ago
return 'yesterday';
return t('emails.dateFormat.yesterday');
} else {
// Older than 48 hours
return emailDate.toLocaleDateString('en-GB', {
@@ -134,19 +144,19 @@ const EmailsList: React.FC = () => {
}
if (error) {
return <div className="text-red-500">Error: {error}</div>;
return <div className="text-red-500">{t('common.error')}: {error}</div>;
}
if (emails.length === 0) {
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-gray-900 dark:text-white text-xl">Emails</h2>
<h2 className="text-gray-900 dark:text-white text-xl">{t('emails.title')}</h2>
<ReloadButton onClick={loadEmails} />
</div>
<div className="text-gray-500 dark:text-gray-400 space-y-2">
<p className="text-sm">
You have not received any emails at your private email addresses yet. When you receive a new email, it will appear here.
{t('emails.noEmailsDescription')}
</p>
</div>
</div>
@@ -156,7 +166,7 @@ const EmailsList: React.FC = () => {
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-gray-900 dark:text-white text-xl">Emails</h2>
<h2 className="text-gray-900 dark:text-white text-xl">{t('emails.title')}</h2>
<ReloadButton onClick={loadEmails} />
</div>
<div className="space-y-2">

View File

@@ -1,6 +1,7 @@
import { Buffer } from 'buffer';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Button from '@/entrypoints/popup/components/Button';
@@ -28,6 +29,7 @@ import { storage } from '#imports';
* Login page
*/
const Login: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const authContext = useAuth();
const dbContext = useDb();
@@ -48,6 +50,66 @@ const Login: React.FC = () => {
const webApi = useWebApi();
const srpUtil = new SrpUtility(webApi);
/**
* Handle successful authentication by storing tokens and initializing the database
*/
const handleSuccessfulAuth = async (
username: string,
token: string,
refreshToken: string,
passwordHashBase64: string,
loginResponse: LoginResponse
) : Promise<void> => {
// Try to get latest vault manually providing auth token.
const vaultResponseJson = await webApi.authFetch<VaultResponse>('Vault', { method: 'GET', headers: {
'Authorization': `Bearer ${token}`
} });
const vaultError = webApi.validateVaultResponse(vaultResponseJson, t);
if (vaultError) {
setError(vaultError);
hideLoading();
return;
}
// All is good. Store auth info which is required to make requests to the web API.
await authContext.setAuthTokens(username, token, refreshToken);
// Store the encryption key and derivation params separately
await dbContext.storeEncryptionKey(passwordHashBase64);
await dbContext.storeEncryptionKeyDerivationParams({
salt: loginResponse.salt,
encryptionType: loginResponse.encryptionType,
encryptionSettings: loginResponse.encryptionSettings
});
// Initialize the SQLite context with the new vault data.
const sqliteClient = await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
// Set logged in status to true which refreshes the app.
await authContext.login();
// If there are pending migrations, redirect to the upgrade page.
try {
if (await sqliteClient.hasPendingMigrations()) {
navigate('/upgrade', { replace: true });
hideLoading();
return;
}
} catch (err) {
await authContext.logout();
setError(err instanceof Error ? err.message : t('auth.errors.migrationError'));
hideLoading();
return;
}
// Navigate to reinitialize page which will take care of the proper redirect.
navigate('/reinitialize', { replace: true });
// Show app.
hideLoading();
};
useEffect(() => {
/**
* Load the client URL from the storage.
@@ -138,55 +200,23 @@ const Login: React.FC = () => {
// Check if token was returned.
if (!validationResponse.token) {
throw new Error('Login failed -- no token returned');
throw new Error(t('auth.errors.noToken'));
}
// Try to get latest vault manually providing auth token.
const vaultResponseJson = await webApi.authFetch<VaultResponse>('Vault', { method: 'GET', headers: {
'Authorization': `Bearer ${validationResponse.token.token}`
} });
const vaultError = webApi.validateVaultResponse(vaultResponseJson);
if (vaultError) {
setError(vaultError);
hideLoading();
return;
}
// All is good. Store auth info which is required to make requests to the web API.
await authContext.setAuthTokens(ConversionUtility.normalizeUsername(credentials.username), validationResponse.token.token, validationResponse.token.refreshToken);
// Initialize the SQLite context with the new vault data.
const sqliteClient = await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
// Set logged in status to true which refreshes the app.
await authContext.login();
// If there are pending migrations, redirect to the upgrade page.
try {
if (await sqliteClient.hasPendingMigrations()) {
navigate('/upgrade', { replace: true });
hideLoading();
return;
}
} catch (err) {
await authContext.logout();
setError(err instanceof Error ? err.message : 'An error occurred while checking for pending migrations.');
hideLoading();
return;
}
// Navigate to reinitialize page which will take care of the proper redirect.
navigate('/reinitialize', { replace: true });
// Show app.
hideLoading();
// Handle successful authentication
await handleSuccessfulAuth(
ConversionUtility.normalizeUsername(credentials.username),
validationResponse.token.token,
validationResponse.token.refreshToken,
passwordHashBase64,
loginResponse
);
} catch (err) {
// Show API authentication errors as-is.
if (err instanceof ApiAuthError) {
setError(err.message);
setError(t('common.apiErrors.' + err.message));
} else {
setError('Could not reach AliasVault server. Please try again later or contact support if the problem persists.');
setError(t('auth.errors.serverError'));
}
hideLoading();
}
@@ -203,13 +233,13 @@ const Login: React.FC = () => {
showLoading();
if (!passwordHashString || !passwordHashBase64 || !loginResponse) {
throw new Error('Required login data not found');
throw new Error(t('auth.errors.loginDataMissing'));
}
// 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.');
throw new Error(t('auth.errors.invalidCode'));
}
const validationResponse = await srpUtil.validateLogin2Fa(
@@ -222,46 +252,17 @@ const Login: React.FC = () => {
// Check if token was returned.
if (!validationResponse.token) {
throw new Error('Login failed -- no token returned');
throw new Error(t('auth.errors.noToken'));
}
// Try to get latest vault manually providing auth token.
const vaultResponseJson = await webApi.authFetch<VaultResponse>('Vault', { method: 'GET', headers: {
'Authorization': `Bearer ${validationResponse.token.token}`
} });
const vaultError = webApi.validateVaultResponse(vaultResponseJson);
if (vaultError) {
setError(vaultError);
hideLoading();
return;
}
// All is good. Store auth info which is required to make requests to the web API.
await authContext.setAuthTokens(ConversionUtility.normalizeUsername(credentials.username), validationResponse.token.token, validationResponse.token.refreshToken);
// Initialize the SQLite context with the new vault data.
const sqliteClient = await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
// Set logged in status to true which refreshes the app.
await authContext.login();
// If there are pending migrations, redirect to the upgrade page.
try {
if (await sqliteClient.hasPendingMigrations()) {
navigate('/upgrade', { replace: true });
hideLoading();
return;
}
} catch (err) {
await authContext.logout();
setError(err instanceof Error ? err.message : 'An error occurred while checking for pending migrations.');
hideLoading();
return;
}
// Navigate to reinitialize page which will take care of the proper redirect.
navigate('/reinitialize', { replace: true });
// Handle successful authentication
await handleSuccessfulAuth(
ConversionUtility.normalizeUsername(credentials.username),
validationResponse.token.token,
validationResponse.token.refreshToken,
passwordHashBase64,
loginResponse
);
// Reset 2FA state and login response as it's no longer needed
setTwoFactorRequired(false);
@@ -269,14 +270,13 @@ const Login: React.FC = () => {
setPasswordHashString(null);
setPasswordHashBase64(null);
setLoginResponse(null);
hideLoading();
} catch (err) {
// Show API authentication errors as-is.
console.error('2FA error:', err);
if (err instanceof ApiAuthError) {
setError(err.message);
setError(t('common.apiErrors.' + err.message));
} else {
setError('Could not reach AliasVault server. Please try again later or contact support if the problem persists.');
setError(t('auth.errors.serverError'));
}
hideLoading();
}
@@ -304,10 +304,10 @@ const Login: React.FC = () => {
)}
<div className="mb-6">
<p className="text-gray-700 dark:text-gray-200 mb-4">
Please enter the authentication code from your authenticator app.
{t('auth.twoFactorTitle')}
</p>
<label className="block text-gray-700 dark:text-gray-200 text-sm font-bold mb-2" htmlFor="twoFactorCode">
Authentication Code
{t('auth.authCode')}
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 leading-tight focus:outline-none focus:shadow-outline"
@@ -315,13 +315,13 @@ const Login: React.FC = () => {
type="text"
value={twoFactorCode}
onChange={(e) => setTwoFactorCode(e.target.value)}
placeholder="Enter 6-digit code"
placeholder={t('auth.authCodePlaceholder')}
required
/>
</div>
<div className="flex flex-col w-full space-y-2">
<Button type="submit">
Verify
{t('auth.verify')}
</Button>
<Button
type="button"
@@ -340,11 +340,11 @@ const Login: React.FC = () => {
}}
variant="secondary"
>
Cancel
{t('auth.cancel')}
</Button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4 text-center">
Note: if you don&apos;t have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website.
{t('auth.twoFactorNote')}
</p>
</form>
</div>
@@ -359,18 +359,18 @@ const Login: React.FC = () => {
{error}
</div>
)}
<h2 className="text-xl font-bold dark:text-gray-200">Log in to AliasVault</h2>
<h2 className="text-xl font-bold dark:text-gray-200">{t('auth.loginTitle')}</h2>
<LoginServerInfo />
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-200 text-sm font-bold mb-2" htmlFor="username">
Username or email
{t('auth.username')}
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 leading-tight focus:outline-none focus:shadow-outline"
id="username"
type="text"
name="username"
placeholder="name / name@company.com"
placeholder={t('auth.usernamePlaceholder')}
value={credentials.username}
onChange={handleChange}
required
@@ -378,14 +378,14 @@ const Login: React.FC = () => {
</div>
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-200 text-sm font-bold mb-2" htmlFor="password">
Password
{t('auth.password')}
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 mb-3 leading-tight focus:outline-none focus:shadow-outline"
id="password"
type="password"
name="password"
placeholder="Enter your password"
placeholder={t('auth.passwordPlaceholder')}
value={credentials.password}
onChange={handleChange}
required
@@ -399,24 +399,24 @@ const Login: React.FC = () => {
onChange={(e) => setRememberMe(e.target.checked)}
className="mr-2"
/>
<span className="text-sm text-gray-700 dark:text-gray-200">Remember me</span>
<span className="text-sm text-gray-700 dark:text-gray-200">{t('auth.rememberMe')}</span>
</label>
</div>
<div className="flex w-full">
<Button type="submit">
Login
{t('auth.loginButton')}
</Button>
</div>
</form>
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
No account yet?{' '}
{t('auth.noAccount')}{' '}
<a
href={clientUrl ?? ''}
target="_blank"
rel="noopener noreferrer"
className="text-orange-500 hover:text-orange-600 dark:text-orange-400 dark:hover:text-orange-500"
>
Create new vault
{t('auth.createVault')}
</a>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { sendMessage } from 'webext-bridge/popup';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
@@ -12,58 +12,29 @@ import { useApiUrl } from '@/entrypoints/popup/utils/ApiUrlUtility';
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
import { AppInfo } from '@/utils/AppInfo';
import { DISABLED_SITES_KEY, GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, GLOBAL_CONTEXT_MENU_ENABLED_KEY, TEMPORARY_DISABLED_SITES_KEY } from '@/utils/Constants';
import { storage, browser } from "#imports";
/**
* Popup settings type.
*/
type PopupSettings = {
disabledUrls: string[];
temporaryDisabledUrls: Record<string, number>;
currentUrl: string;
isEnabled: boolean;
isGloballyEnabled: boolean;
isContextMenuEnabled: boolean;
}
import { browser } from "#imports";
/**
* Settings page component.
*/
const Settings: React.FC = () => {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
const authContext = useAuth();
const { setHeaderButtons } = useHeaderButtons();
const { setIsInitialLoading } = useLoading();
const { loadApiUrl, getDisplayUrl } = useApiUrl();
const navigate = useNavigate();
const [settings, setSettings] = useState<PopupSettings>({
disabledUrls: [],
temporaryDisabledUrls: {},
currentUrl: '',
isEnabled: true,
isGloballyEnabled: true,
isContextMenuEnabled: true
});
/**
* Get current tab in browser.
*/
const getCurrentTab = async (): Promise<browser.Tabs.Tab> => {
const queryOptions = { active: true, currentWindow: true };
const [tab] = await browser.tabs.query(queryOptions);
return tab;
};
/**
* Open the client tab.
*/
const openClientTab = async () : Promise<void> => {
const settingClientUrl = await storage.getItem('local:clientUrl') as string;
const settingClientUrl = await browser.storage.local.get('clientUrl');
let clientUrl = AppInfo.DEFAULT_CLIENT_URL;
if (settingClientUrl && settingClientUrl.length > 0) {
clientUrl = settingClientUrl;
if (settingClientUrl?.clientUrl && settingClientUrl.clientUrl.length > 0) {
clientUrl = settingClientUrl.clientUrl;
}
window.open(clientUrl, '_blank');
@@ -77,14 +48,14 @@ const Settings: React.FC = () => {
<>
<HeaderButton
onClick={() => PopoutUtility.openInNewPopup()}
title="Open in new window"
title={t('settings.openInNewWindow')}
iconType={HeaderIconType.EXPAND}
/>
</>
)}
<HeaderButton
onClick={openClientTab}
title="Open web app"
title={t('settings.openWebApp')}
iconType={HeaderIconType.EXTERNAL_LINK}
/>
</div>
@@ -92,42 +63,14 @@ const Settings: React.FC = () => {
setHeaderButtons(headerButtonsJSX);
return () => setHeaderButtons(null);
}, [setHeaderButtons]);
}, [setHeaderButtons, t]);
/**
* Load settings.
*/
const loadSettings = useCallback(async () : Promise<void> => {
const tab = await getCurrentTab();
const currentUrl = new URL(tab.url ?? '').hostname;
// Load settings local storage.
const disabledUrls = await storage.getItem(DISABLED_SITES_KEY) as string[] ?? [];
const temporaryDisabledUrls = await storage.getItem(TEMPORARY_DISABLED_SITES_KEY) as Record<string, number> ?? {};
const isGloballyEnabled = await storage.getItem(GLOBAL_AUTOFILL_POPUP_ENABLED_KEY) !== false; // Default to true if not set
const isContextMenuEnabled = await storage.getItem(GLOBAL_CONTEXT_MENU_ENABLED_KEY) !== false; // Default to true if not set
// Clean up expired temporary disables
const now = Date.now();
const cleanedTemporaryDisabledUrls = Object.fromEntries(
Object.entries(temporaryDisabledUrls).filter(([_, expiry]) => expiry > now)
);
if (Object.keys(cleanedTemporaryDisabledUrls).length !== Object.keys(temporaryDisabledUrls).length) {
await storage.setItem(TEMPORARY_DISABLED_SITES_KEY, cleanedTemporaryDisabledUrls);
}
// Load API URL
await loadApiUrl();
setSettings({
disabledUrls,
temporaryDisabledUrls: cleanedTemporaryDisabledUrls,
currentUrl,
isEnabled: !disabledUrls.includes(currentUrl) && !(currentUrl in cleanedTemporaryDisabledUrls),
isGloballyEnabled,
isContextMenuEnabled
});
setIsInitialLoading(false);
}, [setIsInitialLoading, loadApiUrl]);
@@ -135,95 +78,12 @@ const Settings: React.FC = () => {
loadSettings();
}, [loadSettings]);
/**
* Toggle current site.
*/
const toggleCurrentSite = async () : Promise<void> => {
const { currentUrl, disabledUrls, temporaryDisabledUrls, isEnabled } = settings;
let newDisabledUrls = [...disabledUrls];
let newTemporaryDisabledUrls = { ...temporaryDisabledUrls };
if (isEnabled) {
// When disabling, add to permanent disabled list
if (!newDisabledUrls.includes(currentUrl)) {
newDisabledUrls.push(currentUrl);
}
// Also remove from temporary disabled list if present
delete newTemporaryDisabledUrls[currentUrl];
} else {
// When enabling, remove from both permanent and temporary disabled lists
newDisabledUrls = newDisabledUrls.filter(url => url !== currentUrl);
delete newTemporaryDisabledUrls[currentUrl];
}
await storage.setItem(DISABLED_SITES_KEY, newDisabledUrls);
await storage.setItem(TEMPORARY_DISABLED_SITES_KEY, newTemporaryDisabledUrls);
setSettings(prev => ({
...prev,
disabledUrls: newDisabledUrls,
temporaryDisabledUrls: newTemporaryDisabledUrls,
isEnabled: !isEnabled
}));
};
/**
* Reset settings.
*/
const resetSettings = async () : Promise<void> => {
await storage.setItem(DISABLED_SITES_KEY, []);
await storage.setItem(TEMPORARY_DISABLED_SITES_KEY, {});
setSettings(prev => ({
...prev,
disabledUrls: [],
temporaryDisabledUrls: {},
isEnabled: true
}));
};
/**
* Toggle global popup.
*/
const toggleGlobalPopup = async () : Promise<void> => {
const newGloballyEnabled = !settings.isGloballyEnabled;
await storage.setItem(GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, newGloballyEnabled);
setSettings(prev => ({
...prev,
isGloballyEnabled: newGloballyEnabled
}));
};
/**
* Toggle context menu.
*/
const toggleContextMenu = async () : Promise<void> => {
const newContextMenuEnabled = !settings.isContextMenuEnabled;
await storage.setItem(GLOBAL_CONTEXT_MENU_ENABLED_KEY, newContextMenuEnabled);
await sendMessage('TOGGLE_CONTEXT_MENU', { enabled: newContextMenuEnabled }, 'background');
setSettings(prev => ({
...prev,
isContextMenuEnabled: newContextMenuEnabled
}));
};
/**
* Set theme preference.
*/
const setThemePreference = async (newTheme: 'system' | 'light' | 'dark') : Promise<void> => {
// Use the ThemeContext to apply the theme
setTheme(newTheme);
// Update local state
setSettings(prev => ({
...prev,
theme: newTheme
}));
};
/**
@@ -252,10 +112,45 @@ const Settings: React.FC = () => {
navigate('/logout', { replace: true });
};
/**
* Navigate to autofill settings.
*/
const navigateToAutofillSettings = () : void => {
navigate('/settings/autofill');
};
/**
* Navigate to clipboard settings.
*/
const navigateToClipboardSettings = () : void => {
navigate('/settings/clipboard');
};
/**
* Navigate to language settings.
*/
const navigateToLanguageSettings = () : void => {
navigate('/settings/language');
};
/**
* Navigate to auto-lock settings.
*/
const navigateToAutoLockSettings = () : void => {
navigate('/settings/auto-lock');
};
/**
* Navigate to context menu settings.
*/
const navigateToContextMenuSettings = () : void => {
navigate('/settings/context-menu');
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-gray-900 dark:text-white text-xl">Settings</h2>
<h2 className="text-gray-900 dark:text-white text-xl">{t('settings.title')}</h2>
</div>
{/* User Menu Section */}
@@ -276,119 +171,210 @@ const Settings: React.FC = () => {
{authContext.username}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Logged in
{t('settings.loggedIn')}
</p>
</div>
</div>
<button
onClick={handleLogout}
className="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
title={t('settings.logout')}
className="p-2 bg-red-100 hover:bg-red-200 dark:bg-red-900/30 dark:hover:bg-red-900/50 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 rounded-md transition-colors"
>
Logout
</button>
</div>
</div>
</div>
</section>
{/* Global Settings Section */}
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">Global Settings</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">Autofill popup</p>
<p className={`text-sm mt-1 ${settings.isGloballyEnabled ? 'text-gray-600 dark:text-gray-400' : 'text-red-600 dark:text-red-400'}`}>
{settings.isGloballyEnabled ? 'Active on all sites (unless disabled below)' : 'Disabled on all sites'}
</p>
</div>
<button
onClick={toggleGlobalPopup}
className={`px-4 py-2 rounded-md transition-colors ${
settings.isGloballyEnabled
? 'bg-green-500 hover:bg-green-600 text-white'
: 'bg-red-500 hover:bg-red-600 text-white'
}`}
>
{settings.isGloballyEnabled ? 'Enabled' : 'Disabled'}
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">Right-click context menu</p>
<p className={`text-sm mt-1 ${settings.isContextMenuEnabled ? 'text-gray-600 dark:text-gray-400' : 'text-red-600 dark:text-red-400'}`}>
{settings.isContextMenuEnabled ? 'Enabled' : 'Disabled'}
</p>
</div>
<button
onClick={toggleContextMenu}
className={`px-4 py-2 rounded-md transition-colors ${
settings.isContextMenuEnabled
? 'bg-green-500 hover:bg-green-600 text-white'
: 'bg-red-500 hover:bg-red-600 text-white'
}`}
>
{settings.isContextMenuEnabled ? 'Enabled' : 'Disabled'}
</button>
</div>
</div>
</div>
</section>
{/* Site-Specific Settings Section */}
{settings.isGloballyEnabled && (
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">Site-Specific Settings</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">Autofill popup on: {settings.currentUrl}</p>
<p className={`text-sm mt-1 ${settings.isEnabled ? 'text-gray-600 dark:text-gray-400' : 'text-red-600 dark:text-red-400'}`}>
{settings.isEnabled ? 'Enabled for this site' : 'Disabled for this site'}
</p>
{!settings.isEnabled && settings.temporaryDisabledUrls[settings.currentUrl] && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Temporarily disabled until {new Date(settings.temporaryDisabledUrls[settings.currentUrl]).toLocaleTimeString()}
</p>
)}
</div>
{settings.isGloballyEnabled && (
<button
onClick={toggleCurrentSite}
className={`px-4 py-2 ml-1 rounded-md transition-colors ${
settings.isEnabled
? 'bg-green-500 hover:bg-green-600 text-white'
: 'bg-red-500 hover:bg-red-600 text-white'
}`}
>
{settings.isEnabled ? 'Enabled' : 'Disabled'}
</button>
)}
</div>
<div className="mt-4">
<button
onClick={resetSettings}
className="w-full px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-md text-gray-700 dark:text-gray-300 transition-colors text-sm"
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
aria-label={t('settings.logout')}
>
Reset all site-specific settings
</button>
</div>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</button>
</div>
</div>
</section>
)}
</div>
</section>
{/* Settings Navigation Section */}
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.preferences')}</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{/* Autofill Settings */}
<button
onClick={navigateToAutofillSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span className="text-sm text-gray-900 dark:text-white">{t('settings.autofillSettings')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Context Menu Settings */}
<button
onClick={navigateToContextMenuSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 6h16M4 12h16m-7 6h7"
/>
</svg>
<span className="text-sm text-gray-900 dark:text-white">{t('settings.contextMenuSettings')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Auto-lock Settings */}
<button
onClick={navigateToAutoLockSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
<span className="text-sm text-gray-900 dark:text-white">{t('settings.autoLockTimeout')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Clipboard Settings */}
<button
onClick={navigateToClipboardSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2"
/>
</svg>
<span className="text-sm text-gray-900 dark:text-white">{t('settings.clipboardSettings')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Language Settings */}
<button
onClick={navigateToLanguageSettings}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<svg
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
/>
</svg>
<span className="text-sm text-gray-900 dark:text-white">{t('settings.language')}</span>
</div>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</section>
{/* Appearance Settings Section */}
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">Appearance</h3>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.appearance')}</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-2">Theme</p>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-2">{t('settings.theme')}</p>
<div className="flex flex-col space-y-2">
<label className="flex items-center">
<input
@@ -399,7 +385,7 @@ const Settings: React.FC = () => {
onChange={() => setThemePreference('system')}
className="mr-2"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Use default</span>
<span className="text-xs text-gray-700 dark:text-gray-300">{t('settings.useDefault')}</span>
</label>
<label className="flex items-center">
<input
@@ -410,7 +396,7 @@ const Settings: React.FC = () => {
onChange={() => setThemePreference('light')}
className="mr-2"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Light</span>
<span className="text-xs text-gray-700 dark:text-gray-300">{t('settings.light')}</span>
</label>
<label className="flex items-center">
<input
@@ -421,7 +407,7 @@ const Settings: React.FC = () => {
onChange={() => setThemePreference('dark')}
className="mr-2"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Dark</span>
<span className="text-xs text-gray-700 dark:text-gray-300">{t('settings.dark')}</span>
</label>
</div>
</div>
@@ -432,18 +418,18 @@ const Settings: React.FC = () => {
{/* Keyboard Shortcuts Section */}
{import.meta.env.CHROME && (
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">Keyboard Shortcuts</h3>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.keyboardShortcuts')}</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">Configure keyboard shortcuts</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('settings.configureKeyboardShortcuts')}</p>
</div>
<button
onClick={openKeyboardShortcuts}
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-md transition-colors"
>
Configure
{t('settings.configure')}
</button>
</div>
</div>
@@ -452,7 +438,7 @@ const Settings: React.FC = () => {
)}
<div className="text-center text-gray-400 dark:text-gray-600">
Version {AppInfo.VERSION} ({getDisplayUrl()})
{t('settings.versionPrefix')}{AppInfo.VERSION} ({getDisplayUrl()})
</div>
</div>
);

View File

@@ -1,6 +1,7 @@
import { Buffer } from 'buffer';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Button from '@/entrypoints/popup/components/Button';
@@ -24,6 +25,7 @@ import { storage } from '#imports';
* Unlock page
*/
const Unlock: React.FC = () => {
const { t } = useTranslation();
const authContext = useAuth();
const dbContext = useDb();
const navigate = useNavigate();
@@ -44,21 +46,21 @@ const Unlock: React.FC = () => {
const statusResponse = await webApi.getStatus();
const statusError = webApi.validateStatusResponse(statusResponse);
if (statusError !== null) {
await webApi.logout(statusError);
await webApi.logout(t('common.errors.' + statusError));
navigate('/logout');
}
setIsInitialLoading(false);
};
checkStatus();
}, [webApi, authContext, setIsInitialLoading, navigate]);
}, [webApi, authContext, setIsInitialLoading, navigate, t]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
const headerButtonsJSX = !PopoutUtility.isPopup() ? (
<HeaderButton
onClick={() => PopoutUtility.openInNewPopup()}
title="Open in new window"
title={t('common.openInNewWindow')}
iconType={HeaderIconType.EXPAND}
/>
) : null;
@@ -68,7 +70,7 @@ const Unlock: React.FC = () => {
return () => {
setHeaderButtons(null);
};
}, [setHeaderButtons]);
}, [setHeaderButtons, t]);
/**
* Handle submit
@@ -93,9 +95,9 @@ const Unlock: React.FC = () => {
// Make API call to get latest vault
const vaultResponseJson = await webApi.get<VaultResponse>('Vault');
const vaultError = webApi.validateVaultResponse(vaultResponseJson);
const vaultError = webApi.validateVaultResponse(vaultResponseJson, t);
if (vaultError) {
setError(vaultError);
setError(t('common.apiErrors.' + vaultError));
hideLoading();
return;
}
@@ -103,6 +105,9 @@ const Unlock: React.FC = () => {
// Get the derived key as base64 string required for decryption.
const passwordHashBase64 = Buffer.from(passwordHash).toString('base64');
// Store the encryption key in session storage.
await dbContext.storeEncryptionKey(passwordHashBase64);
// Initialize the SQLite context with the new vault data.
await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
@@ -112,7 +117,7 @@ const Unlock: React.FC = () => {
// Redirect to reinitialize page
navigate('/reinitialize', { replace: true });
} catch (err) {
setError('Failed to unlock vault. Please check your password and try again.');
setError(t('auth.errors.wrongPassword'));
console.error('Unlock error:', err);
} finally {
hideLoading();
@@ -143,14 +148,14 @@ const Unlock: React.FC = () => {
{authContext.username}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Logged in
{t('auth.loggedIn')}
</p>
</div>
</div>
{/* Instruction Title */}
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
Unlock your vault
{t('auth.unlockTitle')}
</h2>
{error && (
@@ -161,7 +166,7 @@ const Unlock: React.FC = () => {
<div className="mb-2">
<label className="block text-gray-700 dark:text-gray-200 text-sm font-bold mb-2" htmlFor="password">
Password
{t('auth.masterPassword')}
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 mb-3 leading-tight focus:outline-none focus:shadow-outline"
@@ -169,17 +174,18 @@ const Unlock: React.FC = () => {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
placeholder={t('auth.passwordPlaceholder')}
required
autoFocus
/>
</div>
<Button type="submit">
Unlock
{t('auth.unlockVault')}
</Button>
<div className="text-sm font-medium text-gray-500 dark:text-gray-200 mt-6">
Switch accounts? <button onClick={handleLogout} className="text-primary-700 hover:underline dark:text-primary-500">Log out</button>
{t('auth.switchAccounts')} <button onClick={handleLogout} className="text-primary-700 hover:underline dark:text-primary-500">{t('auth.logout')}</button>
</div>
</form>
</div>

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
/**
@@ -7,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
*/
const UnlockSuccess: React.FC = () => {
const navigate = useNavigate();
const { t } = useTranslation();
/**
* Handle browsing vault contents - navigate to credentials page and reset mode parameter
@@ -29,23 +31,23 @@ const UnlockSuccess: React.FC = () => {
</svg>
</div>
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
Your vault is successfully unlocked
{t('auth.unlockSuccessTitle')}
</h2>
<p className="mb-6 text-gray-600 dark:text-gray-400">
You can now use autofill in login forms in your browser.
{t('auth.unlockSuccessDescription')}
</p>
<div className="space-y-3 w-full">
<button
onClick={() => window.close()}
className="w-full px-4 py-2 text-white bg-primary-600 rounded hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
>
Close this popup
{t('auth.closePopup')}
</button>
<button
onClick={handleBrowseVaultContents}
className="w-full px-4 py-2 text-gray-700 bg-gray-100 rounded hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Browse vault contents
{t('auth.browseVault')}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Button from '@/entrypoints/popup/components/Button';
@@ -22,6 +23,7 @@ import { VaultSqlGenerator } from '@/utils/dist/shared/vault-sql';
* Upgrade page for handling vault version upgrades.
*/
const Upgrade: React.FC = () => {
const { t } = useTranslation();
const { username } = useAuth();
const dbContext = useDb();
const { sqliteClient } = dbContext;
@@ -44,7 +46,7 @@ const Upgrade: React.FC = () => {
<>
<HeaderButton
onClick={() => PopoutUtility.openInNewPopup()}
title="Open in new window"
title={t('common.openInNewWindow')}
iconType={HeaderIconType.EXPAND}
/>
</>
@@ -55,7 +57,7 @@ const Upgrade: React.FC = () => {
return () => {
setHeaderButtons(null);
};
}, [setHeaderButtons]);
}, [setHeaderButtons, t]);
/**
* Load version information from the database.
@@ -71,9 +73,9 @@ const Upgrade: React.FC = () => {
setIsInitialLoading(false);
} catch (error) {
console.error('Failed to load version information:', error);
setError('Failed to load version information. Please try again.');
setError(t('upgrade.alerts.unableToGetVersionInfo'));
}
}, [sqliteClient, setIsInitialLoading]);
}, [sqliteClient, setIsInitialLoading, t]);
useEffect(() => {
loadVersionInfo();
@@ -84,7 +86,7 @@ const Upgrade: React.FC = () => {
*/
const handleUpgrade = async (): Promise<void> => {
if (!sqliteClient || !currentVersion || !latestVersion) {
setError('Unable to get version information. Please try again.');
setError(t('upgrade.alerts.unableToGetVersionInfo'));
return;
}
@@ -102,7 +104,7 @@ const Upgrade: React.FC = () => {
*/
const performUpgrade = async (): Promise<void> => {
if (!sqliteClient || !currentVersion || !latestVersion) {
setError('Unable to get version information. Please try again.');
setError(t('upgrade.alerts.unableToGetVersionInfo'));
return;
}
@@ -115,7 +117,7 @@ const Upgrade: React.FC = () => {
const upgradeResult = vaultSqlGenerator.getUpgradeVaultSql(currentVersion.revision, latestVersion.revision);
if (!upgradeResult.success) {
throw new Error(upgradeResult.error ?? 'Failed to generate upgrade SQL');
throw new Error(upgradeResult.error ?? t('upgrade.alerts.upgradeFailed'));
}
if (upgradeResult.sqlCommands.length === 0) {
@@ -125,30 +127,24 @@ const Upgrade: React.FC = () => {
}
// Use the useVaultMutate hook to handle the upgrade and vault upload
console.debug('executeVaultMutation');
await executeVaultMutation(async () => {
// Begin transaction
console.debug('beginTransaction');
sqliteClient.beginTransaction();
// Execute each SQL command
console.debug('executeRaw', upgradeResult.sqlCommands.length);
for (let i = 0; i < upgradeResult.sqlCommands.length; i++) {
const sqlCommand = upgradeResult.sqlCommands[i];
try {
console.debug('executeRaw', sqlCommand);
sqliteClient.executeRaw(sqlCommand);
} catch (error) {
console.debug('error', error);
console.error(`Error executing SQL command ${i + 1}:`, sqlCommand, error);
sqliteClient.rollbackTransaction();
throw new Error(`Failed to apply migration ${i + 1}: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw new Error(t('upgrade.alerts.failedToApplyMigration', { current: i + 1, total: upgradeResult.sqlCommands.length }));
}
}
// Commit transaction
console.debug('commitTransaction');
sqliteClient.commitTransaction();
}, {
skipSyncCheck: true, // Skip sync check during upgrade to prevent loop
@@ -156,14 +152,12 @@ const Upgrade: React.FC = () => {
* Handle successful upgrade completion.
*/
onSuccess: () => {
console.debug('onSuccess');
void handleUpgradeSuccess();
},
/**
* Handle upgrade error.
*/
onError: (error: Error) => {
console.debug('onError');
console.error('Upgrade failed:', error);
setError(error.message);
}
@@ -171,7 +165,7 @@ const Upgrade: React.FC = () => {
console.debug('executeVaultMutation done?');
} catch (error) {
console.error('Upgrade failed:', error);
setError(error instanceof Error ? error.message : 'An unknown error occurred during the upgrade. Please try again.');
setError(error instanceof Error ? error.message : t('upgrade.alerts.unknownErrorDuringUpgrade'));
} finally {
setIsLoading(false);
}
@@ -229,7 +223,7 @@ const Upgrade: React.FC = () => {
<div className="fixed inset-0 flex flex-col justify-center items-center bg-white dark:bg-gray-900 bg-opacity-90 dark:bg-opacity-90 z-50">
<LoadingSpinner />
<div className="text-sm text-gray-500 mt-2">
{syncStatus || 'Upgrading vault...'}
{syncStatus || t('upgrade.upgrading')}
</div>
</div>
)}
@@ -242,10 +236,10 @@ const Upgrade: React.FC = () => {
setShowSelfHostedWarning(false);
void performUpgrade();
}}
title="Self-Hosted Server"
message="If you're using a self-hosted server, make sure to also update your self-hosted instance as otherwise logging in to the web client will stop working. Do you want to continue with the upgrade?"
confirmText="Continue"
cancelText="Cancel"
title={t('upgrade.alerts.selfHostedServer')}
message={t('upgrade.alerts.selfHostedWarning')}
confirmText={t('upgrade.alerts.continueUpgrade')}
cancelText={t('upgrade.alerts.cancel')}
/>
{/* Version info modal */}
@@ -253,8 +247,8 @@ const Upgrade: React.FC = () => {
isOpen={showVersionInfo}
onClose={() => setShowVersionInfo(false)}
onConfirm={() => setShowVersionInfo(false)}
title="What's New"
message={`An upgrade is required to support the following changes:\n\n${latestVersion?.description ?? 'No description available for this version.'}`}
title={t('upgrade.whatsNew')}
message={`${t('upgrade.whatsNewDescription')}\n\n${latestVersion?.description ?? t('upgrade.noDescriptionAvailable')}`}
/>
<form className="w-full px-2 pt-2 pb-2 mb-4">
@@ -280,33 +274,33 @@ const Upgrade: React.FC = () => {
</div>
</div>
<h2 className="text-xl font-bold dark:text-gray-200 mb-4">Upgrade Vault</h2>
<h2 className="text-xl font-bold dark:text-gray-200 mb-4">{t('upgrade.title')}</h2>
<div className="mb-6">
<p className="text-gray-700 dark:text-gray-200 text-sm mb-4">
AliasVault has updated and your vault needs to be upgraded. This should only take a few seconds.
{t('upgrade.subtitle')}
</p>
<div className="bg-gray-50 dark:bg-gray-800 rounded p-4 mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">Version Information</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">{t('upgrade.versionInformation')}</span>
<button
type="button"
onClick={showVersionDialog}
className="bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200 rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold hover:bg-gray-300 dark:hover:bg-gray-500"
title="Show version details"
title={t('upgrade.whatsNew')}
>
?
</button>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400">Your vault:</span>
<span className="text-sm text-gray-600 dark:text-gray-400">{t('upgrade.yourVault')}</span>
<span className="text-sm font-bold text-orange-600 dark:text-orange-400">
{currentVersion?.releaseVersion ?? '...'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400">New version:</span>
<span className="text-sm text-gray-600 dark:text-gray-400">{t('upgrade.newVersion')}</span>
<span className="text-sm font-bold text-green-600 dark:text-green-400">
{latestVersion?.releaseVersion ?? '...'}
</span>
@@ -320,7 +314,7 @@ const Upgrade: React.FC = () => {
type="button"
onClick={handleUpgrade}
>
{isLoading || isVaultMutationLoading ? (syncStatus || 'Upgrading...') : 'Upgrade Vault'}
{isLoading || isVaultMutationLoading ? (syncStatus || t('upgrade.upgrading')) : t('upgrade.upgrade')}
</Button>
<button
type="button"
@@ -328,7 +322,7 @@ const Upgrade: React.FC = () => {
className="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm font-medium py-2"
disabled={isLoading || isVaultMutationLoading}
>
Logout
{t('upgrade.logout')}
</button>
</div>
</form>

View File

@@ -0,0 +1,83 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { sendMessage } from 'webext-bridge/popup';
import HelpModal from '@/entrypoints/popup/components/HelpModal';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { AUTO_LOCK_TIMEOUT_KEY } from '@/utils/Constants';
import { storage } from "#imports";
/**
* Auto-lock settings page component.
*/
const AutoLockSettings: React.FC = () => {
const { t } = useTranslation();
const { setIsInitialLoading } = useLoading();
const [autoLockTimeout, setAutoLockTimeout] = useState<number>(0);
useEffect(() => {
/**
* Load auto-lock settings.
*/
const loadSettings = async () : Promise<void> => {
// Load auto-lock timeout
const autoLockTimeoutValue = await storage.getItem(AUTO_LOCK_TIMEOUT_KEY) as number ?? 0;
setAutoLockTimeout(autoLockTimeoutValue);
setIsInitialLoading(false);
};
loadSettings();
}, [setIsInitialLoading]);
/**
* Set auto-lock timeout.
*/
const setAutoLockTimeoutSetting = async (timeout: number) : Promise<void> => {
await storage.setItem(AUTO_LOCK_TIMEOUT_KEY, timeout);
await sendMessage('SET_AUTO_LOCK_TIMEOUT', timeout, 'background');
setAutoLockTimeout(timeout);
};
return (
<div className="space-y-6">
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.autoLockTimeout')}</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div>
<div className="flex items-center mb-2">
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('settings.autoLockTimeout')}</p>
<HelpModal
titleKey="settings.autoLockTimeout"
contentKey="settings.autoLockTimeoutHelp"
className="ml-2"
/>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 mb-3">{t('settings.autoLockTimeoutDescription')}</p>
<select
value={autoLockTimeout}
onChange={(e) => setAutoLockTimeoutSetting(Number(e.target.value))}
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white focus:ring-primary-500 focus:border-primary-500"
>
<option value="0">{t('settings.autoLockNever')}</option>
<option value="15">{t('settings.autoLock15Seconds')}</option>
<option value="60">{t('settings.autoLock1Minute')}</option>
<option value="300">{t('settings.autoLock5Minutes')}</option>
<option value="900">{t('settings.autoLock15Minutes')}</option>
<option value="1800">{t('settings.autoLock30Minutes')}</option>
<option value="3600">{t('settings.autoLock1Hour')}</option>
<option value="14400">{t('settings.autoLock4Hours')}</option>
<option value="28800">{t('settings.autoLock8Hours')}</option>
<option value="86400">{t('settings.autoLock24Hours')}</option>
</select>
</div>
</div>
</div>
</section>
</div>
);
};
export default AutoLockSettings;

View File

@@ -0,0 +1,260 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { AutofillMatchingMode } from '@/entrypoints/contentScript/Filter';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import {
DISABLED_SITES_KEY,
GLOBAL_AUTOFILL_POPUP_ENABLED_KEY,
TEMPORARY_DISABLED_SITES_KEY,
AUTOFILL_MATCHING_MODE_KEY
} from '@/utils/Constants';
import { storage, browser } from "#imports";
/**
* Autofill settings type.
*/
type AutofillSettingsType = {
disabledUrls: string[];
temporaryDisabledUrls: Record<string, number>;
currentUrl: string;
isEnabled: boolean;
isGloballyEnabled: boolean;
}
/**
* Autofill settings page component.
*/
const AutofillSettings: React.FC = () => {
const { t } = useTranslation();
const { setIsInitialLoading } = useLoading();
const [settings, setSettings] = useState<AutofillSettingsType>({
disabledUrls: [],
temporaryDisabledUrls: {},
currentUrl: '',
isEnabled: true,
isGloballyEnabled: true
});
const [autofillMatchingMode, setAutofillMatchingMode] = useState<AutofillMatchingMode>(AutofillMatchingMode.DEFAULT);
/**
* Get current tab in browser.
*/
const getCurrentTab = async () : Promise<chrome.tabs.Tab> => {
const queryOptions = { active: true, currentWindow: true };
const [tab] = await browser.tabs.query(queryOptions);
return tab;
};
/**
* Load settings.
*/
const loadSettings = useCallback(async () : Promise<void> => {
const tab = await getCurrentTab();
const currentUrl = new URL(tab.url ?? '').hostname;
// Load settings local storage.
const disabledUrls = await storage.getItem(DISABLED_SITES_KEY) as string[] ?? [];
const temporaryDisabledUrls = await storage.getItem(TEMPORARY_DISABLED_SITES_KEY) as Record<string, number> ?? {};
const isGloballyEnabled = await storage.getItem(GLOBAL_AUTOFILL_POPUP_ENABLED_KEY) !== false; // Default to true if not set
// Clean up expired temporary disables
const now = Date.now();
const cleanedTemporaryDisabledUrls = Object.fromEntries(
Object.entries(temporaryDisabledUrls).filter(([_, expiry]) => expiry > now)
);
if (Object.keys(cleanedTemporaryDisabledUrls).length !== Object.keys(temporaryDisabledUrls).length) {
await storage.setItem(TEMPORARY_DISABLED_SITES_KEY, cleanedTemporaryDisabledUrls);
}
// Load autofill matching mode
const matchingModeValue = await storage.getItem(AUTOFILL_MATCHING_MODE_KEY) as AutofillMatchingMode ?? AutofillMatchingMode.DEFAULT;
setAutofillMatchingMode(matchingModeValue);
setSettings({
disabledUrls,
temporaryDisabledUrls: cleanedTemporaryDisabledUrls,
currentUrl,
isEnabled: !disabledUrls.includes(currentUrl) && !(currentUrl in cleanedTemporaryDisabledUrls),
isGloballyEnabled
});
setIsInitialLoading(false);
}, [setIsInitialLoading]);
useEffect(() => {
loadSettings();
}, [loadSettings]);
/**
* Toggle current site.
*/
const toggleCurrentSite = async () : Promise<void> => {
const { currentUrl, disabledUrls, temporaryDisabledUrls, isEnabled } = settings;
let newDisabledUrls = [...disabledUrls];
let newTemporaryDisabledUrls = { ...temporaryDisabledUrls };
if (isEnabled) {
// When disabling, add to permanent disabled list
if (!newDisabledUrls.includes(currentUrl)) {
newDisabledUrls.push(currentUrl);
}
// Also remove from temporary disabled list if present
delete newTemporaryDisabledUrls[currentUrl];
} else {
// When enabling, remove from both permanent and temporary disabled lists
newDisabledUrls = newDisabledUrls.filter(url => url !== currentUrl);
delete newTemporaryDisabledUrls[currentUrl];
}
await storage.setItem(DISABLED_SITES_KEY, newDisabledUrls);
await storage.setItem(TEMPORARY_DISABLED_SITES_KEY, newTemporaryDisabledUrls);
setSettings(prev => ({
...prev,
disabledUrls: newDisabledUrls,
temporaryDisabledUrls: newTemporaryDisabledUrls,
isEnabled: !isEnabled
}));
};
/**
* Reset settings.
*/
const resetSettings = async () : Promise<void> => {
await storage.setItem(DISABLED_SITES_KEY, []);
await storage.setItem(TEMPORARY_DISABLED_SITES_KEY, {});
setSettings(prev => ({
...prev,
disabledUrls: [],
temporaryDisabledUrls: {},
isEnabled: true
}));
};
/**
* Toggle global popup.
*/
const toggleGlobalPopup = async () : Promise<void> => {
const newGloballyEnabled = !settings.isGloballyEnabled;
await storage.setItem(GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, newGloballyEnabled);
setSettings(prev => ({
...prev,
isGloballyEnabled: newGloballyEnabled
}));
};
/**
* Set autofill matching mode.
*/
const setAutofillMatchingModeSetting = async (mode: AutofillMatchingMode) : Promise<void> => {
await storage.setItem(AUTOFILL_MATCHING_MODE_KEY, mode);
setAutofillMatchingMode(mode);
};
return (
<div className="space-y-6">
{/* Global Settings Section */}
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.globalSettings')}</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('settings.autofillPopup')}</p>
<p className={`text-xs mt-1 ${settings.isGloballyEnabled ? 'text-gray-600 dark:text-gray-400' : 'text-red-600 dark:text-red-400'}`}>
{settings.isGloballyEnabled ? t('settings.activeOnAllSites') : t('settings.disabledOnAllSites')}
</p>
</div>
<button
onClick={toggleGlobalPopup}
className={`px-4 py-2 rounded-md transition-colors ${
settings.isGloballyEnabled
? 'bg-green-500 hover:bg-green-600 text-white'
: 'bg-red-500 hover:bg-red-600 text-white'
}`}
>
{settings.isGloballyEnabled ? t('settings.enabled') : t('settings.disabled')}
</button>
</div>
</div>
</div>
</section>
{/* Site-Specific Settings Section */}
{settings.isGloballyEnabled && (
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.siteSpecificSettings')}</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('settings.autofillPopupOn')}{settings.currentUrl}</p>
<p className={`text-xs mt-1 ${settings.isEnabled ? 'text-gray-600 dark:text-gray-400' : 'text-red-600 dark:text-red-400'}`}>
{settings.isEnabled ? t('settings.enabledForThisSite') : t('settings.disabledForThisSite')}
</p>
{!settings.isEnabled && settings.temporaryDisabledUrls[settings.currentUrl] && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('settings.temporarilyDisabledUntil')}{new Date(settings.temporaryDisabledUrls[settings.currentUrl]).toLocaleTimeString()}
</p>
)}
</div>
{settings.isGloballyEnabled && (
<button
onClick={toggleCurrentSite}
className={`px-4 py-2 ml-1 rounded-md transition-colors ${
settings.isEnabled
? 'bg-green-500 hover:bg-green-600 text-white'
: 'bg-red-500 hover:bg-red-600 text-white'
}`}
>
{settings.isEnabled ? t('settings.enabled') : t('settings.disabled')}
</button>
)}
</div>
<div className="mt-4">
<button
onClick={resetSettings}
className="w-full px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-md text-gray-700 dark:text-gray-300 transition-colors text-sm"
>
{t('settings.resetAllSiteSettings')}
</button>
</div>
</div>
</div>
</section>
)}
{/* Autofill Matching Settings Section */}
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.autofillMatching')}</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-2">{t('settings.autofillMatchingMode')}</p>
<p className="text-xs text-gray-600 dark:text-gray-400 mb-3">{t('settings.autofillMatchingModeDescription')}</p>
<select
value={autofillMatchingMode}
onChange={(e) => setAutofillMatchingModeSetting(e.target.value as AutofillMatchingMode)}
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white focus:ring-primary-500 focus:border-primary-500"
>
<option value={AutofillMatchingMode.DEFAULT}>{t('settings.autofillMatchingDefault')}</option>
<option value={AutofillMatchingMode.URL_SUBDOMAIN}>{t('settings.autofillMatchingUrlSubdomain')}</option>
<option value={AutofillMatchingMode.URL_EXACT}>{t('settings.autofillMatchingUrlExact')}</option>
</select>
</div>
</div>
</div>
</section>
</div>
);
};
export default AutofillSettings;

View File

@@ -0,0 +1,69 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { sendMessage } from 'webext-bridge/popup';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { CLIPBOARD_CLEAR_TIMEOUT_KEY } from '@/utils/Constants';
import { storage } from "#imports";
/**
* Clipboard settings page component.
*/
const ClipboardSettings: React.FC = () => {
const { t } = useTranslation();
const { setIsInitialLoading } = useLoading();
const [clipboardTimeout, setClipboardTimeout] = useState<number>(10);
useEffect(() => {
/**
* Load clipboard settings.
*/
const loadSettings = async () : Promise<void> => {
// Load clipboard clear timeout
const timeout = await storage.getItem(CLIPBOARD_CLEAR_TIMEOUT_KEY) as number ?? 10;
setClipboardTimeout(timeout);
setIsInitialLoading(false);
};
loadSettings();
}, [setIsInitialLoading]);
/**
* Set clipboard clear timeout.
*/
const setClipboardClearTimeout = async (timeout: number) : Promise<void> => {
await storage.setItem(CLIPBOARD_CLEAR_TIMEOUT_KEY, timeout);
await sendMessage('SET_CLIPBOARD_CLEAR_TIMEOUT', timeout, 'background');
setClipboardTimeout(timeout);
};
return (
<div className="space-y-6">
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.clipboardSettings')}</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-2">{t('settings.clipboardClearTimeout')}</p>
<p className="text-xs text-gray-600 dark:text-gray-400 mb-3">{t('settings.clipboardClearTimeoutDescription')}</p>
<select
value={clipboardTimeout}
onChange={(e) => setClipboardClearTimeout(Number(e.target.value))}
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white focus:ring-primary-500 focus:border-primary-500"
>
<option value="0">{t('settings.clipboardClearDisabled')}</option>
<option value="5">{t('settings.clipboardClear5Seconds')}</option>
<option value="10">{t('settings.clipboardClear10Seconds')}</option>
<option value="15">{t('settings.clipboardClear15Seconds')}</option>
</select>
</div>
</div>
</div>
</section>
</div>
);
};
export default ClipboardSettings;

View File

@@ -0,0 +1,78 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { sendMessage } from 'webext-bridge/popup';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { GLOBAL_CONTEXT_MENU_ENABLED_KEY } from '@/utils/Constants';
import { storage } from "#imports";
/**
* Context menu settings page component.
*/
const ContextMenuSettings: React.FC = () => {
const { t } = useTranslation();
const { setIsInitialLoading } = useLoading();
const [isContextMenuEnabled, setIsContextMenuEnabled] = useState<boolean>(true);
/**
* Load settings.
*/
const loadSettings = useCallback(async () : Promise<void> => {
const isEnabled = await storage.getItem(GLOBAL_CONTEXT_MENU_ENABLED_KEY) !== false; // Default to true if not set
setIsContextMenuEnabled(isEnabled);
setIsInitialLoading(false);
}, [setIsInitialLoading]);
useEffect(() => {
loadSettings();
}, [loadSettings]);
/**
* Toggle context menu.
*/
const toggleContextMenu = async () : Promise<void> => {
const newContextMenuEnabled = !isContextMenuEnabled;
await storage.setItem(GLOBAL_CONTEXT_MENU_ENABLED_KEY, newContextMenuEnabled);
await sendMessage('TOGGLE_CONTEXT_MENU', { enabled: newContextMenuEnabled }, 'background');
setIsContextMenuEnabled(newContextMenuEnabled);
};
return (
<div className="space-y-6">
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.contextMenu')}</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('settings.rightClickContextMenu')}</p>
<p className={`text-xs mt-1 ${isContextMenuEnabled ? 'text-gray-600 dark:text-gray-400' : 'text-red-600 dark:text-red-400'}`}>
{isContextMenuEnabled ? t('settings.contextMenuEnabled') : t('settings.contextMenuDisabled')}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
{t('settings.contextMenuDescription')}
</p>
</div>
<button
onClick={toggleContextMenu}
className={`px-4 py-2 rounded-md transition-colors ${
isContextMenuEnabled
? 'bg-green-500 hover:bg-green-600 text-white'
: 'bg-red-500 hover:bg-red-600 text-white'
}`}
>
{isContextMenuEnabled ? t('settings.enabled') : t('settings.disabled')}
</button>
</div>
</div>
</div>
</section>
</div>
);
};
export default ContextMenuSettings;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import LanguageSwitcher from '@/entrypoints/popup/components/LanguageSwitcher';
/**
* Language settings page component.
*/
const LanguageSettings: React.FC = () => {
const { t } = useTranslation();
return (
<div className="space-y-6">
<section>
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.language')}</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-3">{t('settings.selectLanguage')}</p>
<LanguageSwitcher variant="dropdown" size="sm" />
</div>
</div>
</div>
</section>
</div>
);
};
export default LanguageSettings;

View File

@@ -0,0 +1,78 @@
/**
* Standalone i18n for non-React contexts.
* This is used to translate strings in non-React contexts, such as the background and content scripts.
*/
import {
DEFAULT_LANGUAGE,
LANGUAGE_CODES,
loadTranslations,
getNestedValue
} from './config';
import { storage } from '#imports';
/**
* Get current language from storage
*/
export async function getCurrentLanguage(): Promise<string> {
try {
// Use extension storage API exclusively (reliable across all contexts)
const langFromStorage = await storage.getItem('local:language') as string;
if (langFromStorage && LANGUAGE_CODES.includes(langFromStorage)) {
return langFromStorage;
}
// If no language is set in storage, detect browser language and save it
const browserLang = navigator.language.split('-')[0];
const detectedLanguage = LANGUAGE_CODES.includes(browserLang) ? browserLang : DEFAULT_LANGUAGE;
// Save the detected language to storage for future use
await storage.setItem('local:language', detectedLanguage);
return detectedLanguage;
} catch (error) {
console.error('Failed to get current language:', error);
return DEFAULT_LANGUAGE;
}
}
/**
* Translation function for non-React contexts
*
* @param key - Translation key (supports nested keys like 'auth.loginButton' or 'common.errors.networkError')
* @param fallback - Fallback text if translation is not found
* @returns Promise<string> - Translated text
*/
export async function t(
key: string,
fallback?: string
): Promise<string> {
try {
const language = await getCurrentLanguage();
const translations = await loadTranslations(language);
// Support nested keys like 'auth.loginButton' or 'common.errors.networkError'
const value = getNestedValue(translations, key);
if (value && typeof value === 'string') {
return value;
}
// If translation not found and we're not using English, try English fallback
if (language !== DEFAULT_LANGUAGE) {
const englishTranslations = await loadTranslations(DEFAULT_LANGUAGE);
const englishValue = getNestedValue(englishTranslations, key);
if (englishValue && typeof englishValue === 'string') {
return englishValue;
}
}
// Return fallback or key if no translation found
return fallback || key;
} catch (error) {
console.error('Translation error:', error);
return fallback || key;
}
}

View File

@@ -0,0 +1,198 @@
/**
* Central configuration for i18n languages
* Add new languages here to make them available throughout the application
*/
import deTranslations from './locales/de.json';
import enTranslations from './locales/en.json';
import fiTranslations from './locales/fi.json';
import itTranslations from './locales/it.json';
import nlTranslations from './locales/nl.json';
import zhTranslations from './locales/zh.json';
/**
* Create a map of all available languages and their resources for i18n.
* When adding a new language, add the translation JSON file to the locales folder and add the language to the map here.
*/
export const LANGUAGE_RESOURCES = {
de: {
translation: deTranslations
},
en: {
translation: enTranslations
},
fi: {
translation: fiTranslations
},
it: {
translation: itTranslations
},
nl: {
translation: nlTranslations
},
zh: {
translation: zhTranslations
},
};
/**
* List of all available languages with their code, name, native name and flag.
* When adding a new language, add the language to the map here.
*/
export const AVAILABLE_LANGUAGES: ILanguageConfig[] = [
{
code: 'de',
name: 'German',
nativeName: 'Deutsch',
flag: '🇩🇪'
},
{
code: 'en',
name: 'English',
nativeName: 'English',
flag: '🇺🇸'
},
{
code: 'fi',
name: 'Finnish',
nativeName: 'Suomi',
flag: '🇫🇮'
},
{
code: 'it',
name: 'Italian',
nativeName: 'Italiano',
flag: '🇮🇹'
},
{
code: 'nl',
name: 'Dutch',
nativeName: 'Nederlands',
flag: '🇳🇱'
},
{
code: 'zh',
name: 'Chinese',
nativeName: '简体中文',
flag: '🇨🇳'
},
/*
* {
* code: 'de',
* name: 'German',
* nativeName: 'Deutsch',
* flag: '🇩🇪'
* },
* {
* code: 'es',
* name: 'Spanish',
* nativeName: 'Español',
* flag: '🇪🇸'
* },
* {
* code: 'fr',
* name: 'French',
* nativeName: 'Français',
* flag: '🇫🇷'
* },
* {
* code: 'uk',
* name: 'Ukrainian',
* nativeName: 'Українська',
* flag: '🇺🇦'
* }
*/
];
/**
* Default language that is used when no language is set in the browser or when a localized string is not found for the current language.
*/
export const DEFAULT_LANGUAGE = 'en';
export const LANGUAGE_CODES = AVAILABLE_LANGUAGES.map(lang => lang.code);
export interface ILanguageConfig {
code: string;
name: string;
nativeName: string;
flag?: string;
}
/**
* Type for content translations
*/
export type ContentTranslations = {
[key: string]: string | ContentTranslations;
};
/**
* Cache for loaded translations to avoid repeated file reads
*/
const translationCache = new Map<string, ContentTranslations>();
/**
* Load translations for a specific language
*/
export async function loadTranslations(language: string): Promise<ContentTranslations> {
const cacheKey = `all:${language}`;
// Check cache first
if (translationCache.has(cacheKey)) {
return translationCache.get(cacheKey)!;
}
// Get translations from pre-loaded resources
if (LANGUAGE_RESOURCES[language as keyof typeof LANGUAGE_RESOURCES]) {
const translationData = LANGUAGE_RESOURCES[language as keyof typeof LANGUAGE_RESOURCES].translation;
translationCache.set(cacheKey, translationData);
return translationData;
}
// Fallback to English if available
if (language !== DEFAULT_LANGUAGE && LANGUAGE_RESOURCES[DEFAULT_LANGUAGE]) {
console.warn(`Translations not found for ${language}, falling back to ${DEFAULT_LANGUAGE}`);
const fallbackData = LANGUAGE_RESOURCES[DEFAULT_LANGUAGE].translation;
translationCache.set(cacheKey, fallbackData);
return fallbackData;
}
// Return empty object as last resort
console.warn(`No translations found for ${language} and no fallback available`);
return {};
}
/**
* Load all available translations for i18next
*/
export async function loadAllTranslations(): Promise<Record<string, { translation: ContentTranslations }>> {
const resources: Record<string, { translation: ContentTranslations }> = {};
for (const language of AVAILABLE_LANGUAGES) {
try {
const translations = await loadTranslations(language.code);
resources[language.code] = { translation: translations };
} catch (error) {
console.warn(`Failed to load translations for ${language.code}:`, error);
}
}
return resources;
}
/**
* Get language config by code
*/
export function getLanguageConfig(code: string): ILanguageConfig | undefined {
return AVAILABLE_LANGUAGES.find(lang => lang.code === code);
}
/**
* Get nested value from object using dot notation
*/
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
return path.split('.').reduce((current: unknown, key: string) => {
return current && typeof current === 'object' && current !== null && key in current
? (current as Record<string, unknown>)[key]
: undefined;
}, obj);
}

View File

@@ -0,0 +1,62 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import {
DEFAULT_LANGUAGE,
LANGUAGE_CODES,
LANGUAGE_RESOURCES
} from './config';
import { storage } from '#imports';
// Detect browser language
/**
* Detect the user's preferred language from localStorage or browser settings
*/
const detectLanguage = async (): Promise<string> => {
// Check localStorage first
const stored = await storage.getItem('local:language') as string;
if (stored && LANGUAGE_CODES.includes(stored)) {
return stored;
}
// Fall back to browser language
const browserLang = navigator.language.split('-')[0];
return LANGUAGE_CODES.includes(browserLang) ? browserLang : DEFAULT_LANGUAGE;
};
/**
* Initialize i18n with async language detection
*/
const initI18n = async (): Promise<void> => {
const language = await detectLanguage();
await i18n
.use(initReactI18next)
.init({
resources: LANGUAGE_RESOURCES,
lng: language,
fallbackLng: DEFAULT_LANGUAGE,
debug: false, // Set to true for development debugging
interpolation: {
escapeValue: false // React already escapes
},
react: {
useSuspense: false, // Important for browser extensions
bindI18n: 'languageChanged loaded', // Bind to language change and loaded events
bindI18nStore: '' // Don't bind to resource store changes
}
});
};
// Initialize immediately and handle potential errors
initI18n().catch((error) => {
console.error('Failed to initialize i18n:', error);
// Even if initialization fails, emit initialized event to prevent app from hanging
i18n.emit('initialized');
});
export default i18n;

View File

@@ -0,0 +1,392 @@
{
"auth": {
"loginTitle": "Log in to AliasVault",
"username": "Username or email",
"usernamePlaceholder": "name / name@company.com",
"password": "Contrasenya",
"passwordPlaceholder": "Enter your password",
"rememberMe": "Remember me",
"loginButton": "Login",
"noAccount": "No account yet?",
"createVault": "Create new vault",
"twoFactorTitle": "Please enter the authentication code from your authenticator app.",
"authCode": "Codi d'autenticació",
"authCodePlaceholder": "Introduïu el codi de 6 dígits",
"verify": "Verifica",
"cancel": "Cancel",
"twoFactorNote": "Note: if you don't have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website.",
"masterPassword": "Contrasenya Mestra",
"unlockVault": "Unlock Vault",
"unlockTitle": "Unlock Your Vault",
"unlockDescription": "Enter your master password to unlock your vault.",
"logout": "Tanca la sessió",
"logoutConfirm": "Are you sure you want to logout?",
"sessionExpired": "Your session has expired. Please log in again.",
"unlockSuccess": "Vault unlocked successfully!",
"unlockSuccessTitle": "Your vault is successfully unlocked",
"unlockSuccessDescription": "You can now use autofill in login forms in your browser.",
"closePopup": "Close this popup",
"browseVault": "Browse vault contents",
"connectingTo": "Connectant a",
"switchAccounts": "Switch accounts?",
"loggedIn": "Logged in",
"errors": {
"invalidCode": "Please enter a valid 6-digit authentication code.",
"serverError": "Could not reach AliasVault server. Please try again later or contact support if the problem persists.",
"noToken": "Login failed -- no token returned",
"migrationError": "An error occurred while checking for pending migrations.",
"wrongPassword": "Incorrect password. Please try again.",
"accountLocked": "Account temporarily locked due to too many failed attempts.",
"networkError": "Network error. Please check your connection and try again.",
"loginDataMissing": "Login session expired. Please try again."
}
},
"menu": {
"credentials": "Credentials",
"emails": "Emails",
"settings": "Settings"
},
"common": {
"appName": "AliasVault",
"loading": "S'està carregant...",
"error": "Error",
"success": "Success",
"cancel": "Cancel",
"use": "Utilitza",
"delete": "Suprimeix",
"close": "Tanca",
"copied": "Copied!",
"openInNewWindow": "Open in new window",
"language": "Language",
"enabled": "Enabled",
"disabled": "Disabled",
"showPassword": "Mostra la contrasenya",
"hidePassword": "Amaga la contrasenya",
"copyToClipboard": "Copy to clipboard",
"loadingEmails": "Loading emails...",
"loadingTotpCodes": "Loading TOTP codes...",
"attachments": "Attachments",
"loadingAttachments": "Loading attachments...",
"settings": "Settings",
"recentEmails": "Recent emails",
"loginCredentials": "Login credentials",
"twoFactorAuthentication": "Two-factor authentication",
"alias": "Alias",
"notes": "Notes",
"fullName": "Full Name",
"firstName": "First Name",
"lastName": "Last Name",
"birthDate": "Birth Date",
"nickname": "Nickname",
"email": "Email",
"username": "Username",
"password": "Password",
"syncingVault": "Syncing vault",
"savingChangesToVault": "Saving changes to vault",
"uploadingVaultToServer": "Uploading vault to server",
"checkingVaultUpdates": "Checking for vault updates",
"syncingUpdatedVault": "Syncing updated vault",
"executingOperation": "Executing operation...",
"loadMore": "Load more",
"errors": {
"VaultOutdated": "Your vault is outdated. Please login on the AliasVault website and follow the steps.",
"serverNotAvailable": "The AliasVault server is not available. Please try again later or contact support if the problem persists.",
"clientVersionNotSupported": "This version of the AliasVault browser extension is not supported by the server anymore. Please update your browser extension to the latest version.",
"serverVersionNotSupported": "The AliasVault server needs to be updated to a newer version in order to use this browser extension. Please contact support if you need help.",
"unknownError": "An unknown error occurred",
"failedToStoreVault": "Failed to store vault",
"vaultNotAvailable": "Vault not available",
"failedToRetrieveData": "Failed to retrieve data",
"vaultIsLocked": "Vault is locked",
"failedToUploadVault": "Failed to upload vault",
"passwordChanged": "Your password has changed since the last time you logged in. Please login again for security reasons."
},
"apiErrors": {
"UNKNOWN_ERROR": "An unknown error occurred. Please try again.",
"ACCOUNT_LOCKED": "Account temporarily locked due to too many failed attempts. Please try again later.",
"ACCOUNT_BLOCKED": "Your account has been disabled. If you believe this is a mistake, please contact support.",
"USER_NOT_FOUND": "Invalid username or password. Please try again.",
"INVALID_AUTHENTICATOR_CODE": "Invalid authenticator code. Please try again.",
"INVALID_RECOVERY_CODE": "Invalid recovery code. Please try again.",
"REFRESH_TOKEN_REQUIRED": "Refresh token is required.",
"INVALID_REFRESH_TOKEN": "Invalid refresh token.",
"REFRESH_TOKEN_REVOKED_SUCCESSFULLY": "Refresh token revoked successfully.",
"PUBLIC_REGISTRATION_DISABLED": "New account registration is currently disabled on this server. Please contact the administrator.",
"USERNAME_REQUIRED": "Username is required.",
"USERNAME_ALREADY_IN_USE": "Username is already in use.",
"USERNAME_AVAILABLE": "Username is available.",
"USERNAME_MISMATCH": "Username does not match the current user.",
"PASSWORD_MISMATCH": "The provided password does not match your current password.",
"ACCOUNT_SUCCESSFULLY_DELETED": "Account successfully deleted.",
"USERNAME_EMPTY_OR_WHITESPACE": "Username cannot be empty or whitespace.",
"USERNAME_TOO_SHORT": "Username too short: must be at least 3 characters long.",
"USERNAME_TOO_LONG": "Username too long: cannot be longer than 40 characters.",
"USERNAME_INVALID_EMAIL": "Invalid email address.",
"USERNAME_INVALID_CHARACTERS": "Username is invalid, can only contain letters or digits.",
"VAULT_NOT_UP_TO_DATE": "Your vault is not up-to-date. Please synchronize your vault and try again.",
"INTERNAL_SERVER_ERROR": "Internal server error.",
"VAULT_ERROR": "The local vault is not up-to-date. Please synchronize your vault by refreshing the page and try again."
}
},
"content": {
"or": "or",
"new": "New",
"cancel": "Cancel",
"search": "Search",
"vaultLocked": "AliasVault is locked.",
"creatingNewAlias": "Creating new alias...",
"noMatchesFound": "No matches found",
"searchVault": "Search vault...",
"serviceName": "Service name",
"email": "Email",
"username": "Username",
"password": "Password",
"enterServiceName": "Enter service name",
"enterEmailAddress": "Enter email address",
"enterUsername": "Enter username",
"hideFor1Hour": "Hide for 1 hour (current site)",
"hidePermanently": "Hide permanently (current site)",
"createRandomAlias": "Create random alias",
"createUsernamePassword": "Create username/password",
"randomAlias": "Random alias",
"usernamePassword": "Username/password",
"createAndSaveAlias": "Create and save alias",
"createAndSaveCredential": "Create and save credential",
"randomIdentityDescription": "Generate a random identity with a random email address accessible in AliasVault.",
"randomIdentityDescriptionDropdown": "Random identity with random email",
"manualCredentialDescription": "Specify your own email address and username.",
"manualCredentialDescriptionDropdown": "Manual username and password",
"failedToCreateIdentity": "Failed to create identity. Please try again.",
"enterEmailAndOrUsername": "Enter email and/or username",
"autofillWithAliasVault": "Autofill with AliasVault",
"generateRandomPassword": "Generate random password (copy to clipboard)",
"generateNewPassword": "Generate new password",
"togglePasswordVisibility": "Toggle password visibility",
"passwordCopiedToClipboard": "Password copied to clipboard",
"enterEmailAndOrUsernameError": "Enter email and/or username",
"openAliasVaultToUpgrade": "Open AliasVault to upgrade",
"vaultUpgradeRequired": "Vault upgrade required.",
"dismissPopup": "Dismiss popup"
},
"credentials": {
"title": "Credentials",
"addCredential": "Add Credential",
"editCredential": "Edit Credential",
"deleteCredential": "Delete Credential",
"credentialDetails": "Credential Details",
"serviceName": "Service Name",
"serviceNamePlaceholder": "e.g., Gmail, Facebook, Bank",
"website": "Website",
"websitePlaceholder": "https://example.com",
"username": "Username",
"usernamePlaceholder": "Enter username",
"password": "Password",
"passwordPlaceholder": "Enter password",
"generatePassword": "Generate Password",
"copyPassword": "Copy Password",
"showPassword": "Show Password",
"hidePassword": "Hide Password",
"notes": "Notes",
"notesPlaceholder": "Additional notes...",
"totp": "Two-Factor Authentication",
"totpCode": "TOTP Code",
"copyTotp": "Copy TOTP",
"totpSecret": "TOTP Secret",
"totpSecretPlaceholder": "Enter TOTP secret key",
"noCredentials": "No credentials found",
"noCredentialsDescription": "Add your first credential to get started",
"searchPlaceholder": "Search credentials...",
"welcomeTitle": "Welcome to AliasVault!",
"welcomeDescription": "To use the AliasVault browser extension: navigate to a website and use the AliasVault autofill popup to create a new credential.",
"createdAt": "Created",
"updatedAt": "Last updated",
"autofill": "Autofill",
"fillForm": "Fill Form",
"deleteConfirm": "Are you sure you want to delete this credential?",
"saveSuccess": "Credential saved successfully",
"tags": "Tags",
"addTag": "Add Tag",
"removeTag": "Remove Tag",
"folder": "Folder",
"selectFolder": "Select Folder",
"createFolder": "Create Folder",
"saveCredential": "Save credential",
"deleteCredentialTitle": "Delete Credential",
"deleteCredentialConfirm": "Are you sure you want to delete this credential? This action cannot be undone.",
"randomAlias": "Random Alias",
"manual": "Manual",
"service": "Service",
"serviceUrl": "Service URL",
"loginCredentials": "Login Credentials",
"generateRandomUsername": "Generate random username",
"generateRandomPassword": "Generate random password",
"changePasswordComplexity": "Change password complexity",
"passwordLength": "Password length",
"includeLowercase": "Include lowercase letters",
"includeUppercase": "Include uppercase letters",
"includeNumbers": "Include numbers",
"includeSpecialChars": "Include special characters",
"avoidAmbiguousChars": "Avoid ambiguous characters (o, 0, etc.)",
"generateNewPreview": "Generate new preview",
"generateRandomAlias": "Generate Random Alias",
"alias": "Alias",
"firstName": "First Name",
"lastName": "Last Name",
"nickName": "Nick Name",
"gender": "Gender",
"birthDate": "Birth Date",
"birthDatePlaceholder": "YYYY-MM-DD",
"metadata": "Metadata",
"validation": {
"required": "This field is required",
"serviceNameRequired": "Service name is required",
"invalidEmail": "Invalid email format",
"invalidDateFormat": "Date must be in YYYY-MM-DD format"
},
"privateEmailTitle": "Private Email",
"privateEmailAliasVaultServer": "AliasVault server",
"privateEmailDescription": "E2E encrypted, fully private.",
"publicEmailTitle": "Public Temp Email Providers",
"publicEmailDescription": "Anonymous but limited privacy. Email content is readable by anyone that knows the address.",
"useDomainChooser": "Use domain chooser",
"enterCustomDomain": "Enter custom domain",
"enterFullEmail": "Enter full email address",
"enterEmailPrefix": "Enter email prefix"
},
"emails": {
"title": "Emails",
"deleteEmailTitle": "Delete Email",
"deleteEmailConfirm": "Are you sure you want to permanently delete this email?",
"from": "From",
"to": "To",
"date": "Date",
"emailContent": "Email content",
"attachments": "Attachments",
"emailNotFound": "Email not found",
"noEmails": "No emails found",
"noEmailsDescription": "You have not received any emails at your private email addresses yet. When you receive a new email, it will appear here.",
"dateFormat": {
"justNow": "just now",
"minutesAgo_single": "{{count}} min ago",
"minutesAgo_plural": "{{count}} mins ago",
"hoursAgo_single": "{{count}} hr ago",
"hoursAgo_plural": "{{count}} hrs ago",
"yesterday": "yesterday"
},
"errors": {
"emailLoadError": "An error occurred while loading emails. Please try again later.",
"emailUnexpectedError": "An unexpected error occurred while loading emails. Please try again later."
},
"apiErrors": {
"CLAIM_DOES_NOT_MATCH_USER": "The current chosen email address is already in use. Please change the email address by editing this credential.",
"CLAIM_DOES_NOT_EXIST": "An error occurred while trying to load the emails. Please try to edit and save the credential entry to synchronize the database, then try again."
}
},
"settings": {
"title": "Settings",
"serverUrl": "Server URL",
"language": "Language",
"autofillEnabled": "Enable Autofill",
"version": "Version",
"openInNewWindow": "Open in new window",
"openWebApp": "Open web app",
"loggedIn": "Logged in",
"logout": "Logout",
"globalSettings": "Global Settings",
"autofillPopup": "Autofill popup",
"activeOnAllSites": "Active on all sites (unless disabled below)",
"disabledOnAllSites": "Disabled on all sites",
"enabled": "Enabled",
"disabled": "Disabled",
"rightClickContextMenu": "Right-click context menu",
"autofillMatching": "Autofill Matching",
"autofillMatchingMode": "Autofill matching mode",
"autofillMatchingModeDescription": "Determines which credentials are considered a match and shown as suggestions in the autofill popup for a given website.",
"autofillMatchingDefault": "URL + subdomain + name wildcard",
"autofillMatchingUrlSubdomain": "URL + subdomain",
"autofillMatchingUrlExact": "Exact URL domain only",
"siteSpecificSettings": "Site-Specific Settings",
"autofillPopupOn": "Autofill popup on: ",
"enabledForThisSite": "Enabled for this site",
"disabledForThisSite": "Disabled for this site",
"temporarilyDisabledUntil": "Temporarily disabled until ",
"resetAllSiteSettings": "Reset all site-specific settings",
"appearance": "Appearance",
"theme": "Theme",
"useDefault": "Use default",
"light": "Light",
"dark": "Dark",
"keyboardShortcuts": "Keyboard Shortcuts",
"configureKeyboardShortcuts": "Configure keyboard shortcuts",
"configure": "Configure",
"security": "Security",
"clipboardClearTimeout": "Clear clipboard after copying",
"clipboardClearTimeoutDescription": "Automatically clear the clipboard after copying sensitive data",
"clipboardClearDisabled": "Never clear",
"clipboardClear5Seconds": "Clear after 5 seconds",
"clipboardClear10Seconds": "Clear after 10 seconds",
"clipboardClear15Seconds": "Clear after 15 seconds",
"autoLockTimeout": "Auto-lock Timeout",
"autoLockTimeoutDescription": "Automatically lock the vault after a period of inactivity",
"autoLockTimeoutHelp": "The vault will only lock after the specified period of inactivity (no autofill usage or extension popup opened). The vault will always lock when the browser is closed, regardless of this setting.",
"autoLockNever": "Never",
"autoLock15Seconds": "15 seconds",
"autoLock1Minute": "1 minute",
"autoLock5Minutes": "5 minutes",
"autoLock15Minutes": "15 minutes",
"autoLock30Minutes": "30 minutes",
"autoLock1Hour": "1 hour",
"autoLock4Hours": "4 hours",
"autoLock8Hours": "8 hours",
"autoLock24Hours": "24 hours",
"versionPrefix": "Version ",
"preferences": "Preferences",
"autofillSettings": "Autofill Settings",
"clipboardSettings": "Clipboard Settings",
"contextMenuSettings": "Context Menu Settings",
"contextMenu": "Context Menu",
"contextMenuEnabled": "Context menu is enabled",
"contextMenuDisabled": "Context menu is disabled",
"contextMenuDescription": "Right-click on input fields to access AliasVault options",
"selectLanguage": "Select Language",
"validation": {
"apiUrlRequired": "API URL is required",
"apiUrlInvalid": "Please enter a valid API URL",
"clientUrlRequired": "Client URL is required",
"clientUrlInvalid": "Please enter a valid client URL"
}
},
"upgrade": {
"title": "Upgrade Vault",
"subtitle": "AliasVault has updated and your vault needs to be upgraded. This should only take a few seconds.",
"versionInformation": "Version Information",
"yourVault": "Your vault:",
"newVersion": "New version:",
"upgrade": "Upgrade Vault",
"upgrading": "Upgrading...",
"logout": "Logout",
"whatsNew": "What's New",
"whatsNewDescription": "An upgrade is required to support the following changes:",
"noDescriptionAvailable": "No description available for this version.",
"okay": "Ok",
"status": {
"preparingUpgrade": "Preparing upgrade...",
"vaultAlreadyUpToDate": "Vault is already up to date",
"startingDatabaseTransaction": "Starting database transaction...",
"applyingDatabaseMigrations": "Applying database migrations...",
"applyingMigration": "Applying migration {{current}} of {{total}}...",
"committingChanges": "Committing changes..."
},
"alerts": {
"error": "Error",
"unableToGetVersionInfo": "Unable to get version information. Please try again.",
"selfHostedServer": "Self-Hosted Server",
"selfHostedWarning": "If you're using a self-hosted server, make sure to also update your self-hosted instance as otherwise logging in to the web client will stop working.",
"cancel": "Cancel",
"continueUpgrade": "Continue Upgrade",
"upgradeFailed": "Upgrade Failed",
"failedToApplyMigration": "Failed to apply migration ({{current}} of {{total}})",
"unknownErrorDuringUpgrade": "An unknown error occurred during the upgrade. Please try again."
}
}
}

View File

@@ -0,0 +1,392 @@
{
"auth": {
"loginTitle": "Bei AliasVault anmelden",
"username": "Benutzername oder E-Mail-Adresse",
"usernamePlaceholder": "Name / name@unternehmen.com",
"password": "Passwort",
"passwordPlaceholder": "Gib Dein Passwort ein",
"rememberMe": "Angemeldet bleiben",
"loginButton": "Anmelden",
"noAccount": "Noch kein Konto?",
"createVault": "Neuen Tresor erstellen",
"twoFactorTitle": "Bitte gib den Sicherheits-Code aus Deiner Authentifizierungs-App ein.",
"authCode": "Sicherheits-Code",
"authCodePlaceholder": "Gib den 6-stelligen Sicherheits-Code ein.",
"verify": "Bestätige",
"cancel": "Abbrechen",
"twoFactorNote": "Hinweis: Wenn Du keinen Zugriff auf Dein Authentifizierungsgerät hast, kannst Du Deine Zwei-Faktor-Authentifizierung (2FA) mit einem Wiederherstellungscode zurücksetzen, indem Du Dich über die Website anmeldest.",
"masterPassword": "Master-Passwort",
"unlockVault": "Tresor entsperren",
"unlockTitle": "Entsperre Deinen Tresor",
"unlockDescription": "Bitte gib Dein Master-Passwort zum Entsperren des Tresors ein.",
"logout": "Abmelden",
"logoutConfirm": "Bist Du sicher, dass Du Dich abmelden möchtest?",
"sessionExpired": "Deine Sitzung ist abgelaufen. Bitte melde Dich erneut an.",
"unlockSuccess": "Tresor erfolgreich entsperrt!",
"unlockSuccessTitle": "Ihr Tresor wurde erfolgreich entsperrt",
"unlockSuccessDescription": "Du kannst jetzt die Autofill-Funktion in Anmeldeformularen in Deinem Browser nutzen.",
"closePopup": "Popup schließen",
"browseVault": "Tresor durchsuchen",
"connectingTo": "Verbinde zu",
"switchAccounts": "Konto wechseln?",
"loggedIn": "Angemeldet",
"errors": {
"invalidCode": "Bitte gib einen gültigen 6-stelligen Sicherheits-Code ein.",
"serverError": "Der AliasVault-Server konnte nicht erreicht werden. Bitte versuche es später noch einmal oder kontaktiere den Support, falls das Problem weiterhin besteht.",
"noToken": "Anmeldung fehlgeschlagen -- es wurde kein Token zurückgegeben",
"migrationError": "Beim Prüfen auf ausstehende Migrationen ist ein Fehler aufgetreten.",
"wrongPassword": "Falsches Passwort. Bitte versuche es erneut.",
"accountLocked": "Das Konto wurde wegen zu vieler fehlgeschlagener Anmeldeversuche vorübergehend gesperrt.",
"networkError": "Netzwerkfehler. Bitte überprüfe Deine Verbindung und versuche es erneut.",
"loginDataMissing": "Deine Anmelde-Sitzung ist abgelaufen. Bitte versuche es erneut."
}
},
"menu": {
"credentials": "Zugangsdaten",
"emails": "E-Mails",
"settings": "Einstellungen"
},
"common": {
"appName": "AliasVault",
"loading": "Laden...",
"error": "Fehler",
"success": "Aktion erfolgreich",
"cancel": "Abbrechen",
"use": "Benutzen",
"delete": "Löschen",
"close": "Schließen",
"copied": "Kopiert!",
"openInNewWindow": "In neuem Fenster öffnen",
"language": "Sprache",
"enabled": "Aktiviert",
"disabled": "Deaktiviert",
"showPassword": "Passwort anzeigen",
"hidePassword": "Passwort verbergen",
"copyToClipboard": "In die Zwischenablage kopieren",
"loadingEmails": "E-Mails werden geladen...",
"loadingTotpCodes": "TOTP-Codes werden geladen...",
"attachments": "Anhänge",
"loadingAttachments": "Anhänge werden geladen...",
"settings": "Einstellungen",
"recentEmails": "Neueste E-Mails",
"loginCredentials": "Zugangsdaten",
"twoFactorAuthentication": "Zwei-Faktor-Authentifizierung",
"alias": "Alias",
"notes": "Notizen",
"fullName": "Vor- und Nachname",
"firstName": "Vorname",
"lastName": "Nachname",
"birthDate": "Geburtsdatum",
"nickname": "Spitzname",
"email": "E-Mail-Adresse",
"username": "Benutzername",
"password": "Passwort",
"syncingVault": "Tresor wird synchronisiert",
"savingChangesToVault": "Änderungen werden gespeichert",
"uploadingVaultToServer": "Tresor wird auf den Server hochgeladen",
"checkingVaultUpdates": "Prüfe auf Tresor-Updates",
"syncingUpdatedVault": "Aktualisierter Tresor wird synchronisiert",
"executingOperation": "Vorgang wird ausgeführt...",
"loadMore": "Mehr laden",
"errors": {
"VaultOutdated": "Dein Tresor ist veraltet. Bitte melde Dich auf der AliasVault-Webseite an und folge den Anweisungen.",
"serverNotAvailable": "Der AliasVault-Server konnte nicht erreicht werden. Bitte versuche es später noch einmal oder kontaktiere den Support, falls das Problem weiterhin besteht.",
"clientVersionNotSupported": "Diese Version der AliasVault-Browser-Erweiterung wird vom Server nicht mehr unterstützt. Bitte aktualisiere Deine Browser-Erweiterung auf die neueste Version.",
"serverVersionNotSupported": "Der AliasVault-Server muss auf eine neuere Version aktualisiert werden, um diese Browser-Erweiterung nutzen zu können. Bitte kontaktiere den Support, falls Du Hilfe benötigst.",
"unknownError": "Ein unbekannter Fehler ist aufgetreten",
"failedToStoreVault": "Fehler beim Speichern des Tresors",
"vaultNotAvailable": "Tresor nicht verfügbar",
"failedToRetrieveData": "Abruf der Daten fehlgeschlagen",
"vaultIsLocked": "Der Tresor ist gesperrt.",
"failedToUploadVault": "Das Hochladen des Tresors ist fehlgeschlagen",
"passwordChanged": "Dein Passwort hat sich seit Deiner letzten Anmeldung geändert. Bitte melden Dich aus Sicherheitsgründen erneut an."
},
"apiErrors": {
"UNKNOWN_ERROR": "Ein unbekannter Fehler ist aufgetreten. Bitte versuche es erneut.",
"ACCOUNT_LOCKED": "Das Konto wurde wegen zu vieler fehlgeschlagener Anmeldeversuche vorübergehend gesperrt. Bitte versuche es später erneut.",
"ACCOUNT_BLOCKED": "Dein Konto wurde deaktiviert. Wenn Du glaubst, dass dies ein Fehler ist, kontaktiere bitte den Support.",
"USER_NOT_FOUND": "Ungültiger Benutzername oder Passwort. Bitte versuche es erneut.",
"INVALID_AUTHENTICATOR_CODE": "Ungültiger Sicherheits-Code. Bitte versuche es erneut.",
"INVALID_RECOVERY_CODE": "Ungültiger Wiederherstellungscode. Bitte versuche es erneut.",
"REFRESH_TOKEN_REQUIRED": "Aktualisierungstoken ist erforderlich.",
"INVALID_REFRESH_TOKEN": "Ungültiger Aktualisierungstoken.",
"REFRESH_TOKEN_REVOKED_SUCCESSFULLY": "Aktualisierungstoken wurde erfolgreich widerrufen.",
"PUBLIC_REGISTRATION_DISABLED": "Die Registrierung eines neuen Kontos ist auf diesem Server derzeit deaktiviert. Bitte kontaktiere den Administrator.",
"USERNAME_REQUIRED": "Der Benutzername ist erforderlich.",
"USERNAME_ALREADY_IN_USE": "Benutzername ist bereits vergeben.",
"USERNAME_AVAILABLE": "Der Benutzername ist verfügbar.",
"USERNAME_MISMATCH": "Der Benutzername stimmt nicht mit dem aktuellen Benutzer überein.",
"PASSWORD_MISMATCH": "Das angegebene Passwort stimmt nicht mit Deinem aktuellen Passwort überein.",
"ACCOUNT_SUCCESSFULLY_DELETED": "Konto erfolgreich gelöscht.",
"USERNAME_EMPTY_OR_WHITESPACE": "Der Benutzername darf nicht leer sein.",
"USERNAME_TOO_SHORT": "Der Benutzername ist zu kurz. Er muss mindestens 3 Zeichen lang sein.",
"USERNAME_TOO_LONG": "Der Benutzername ist zu lang. Er darf höchstens 40 Zeichen lang sein.",
"USERNAME_INVALID_EMAIL": "Ungültige E-Mail-Adresse.",
"USERNAME_INVALID_CHARACTERS": "Der Benutzername ist ungültig. Er darf nur aus Buchstaben oder Ziffern bestehen.",
"VAULT_NOT_UP_TO_DATE": "Dein Tresor ist nicht aktuell. Bitte synchronisiere Deinen Tresor und versuche es erneut.",
"INTERNAL_SERVER_ERROR": "Internal server error.",
"VAULT_ERROR": "Der lokale Tresor ist nicht aktuell. Bitte synchronisiere Deinen Tresor, indem Du die Seite aktualisierst, und versuche es erneut."
}
},
"content": {
"or": "oder",
"new": "Neu",
"cancel": "Abbrechen",
"search": "Suche",
"vaultLocked": "AliasVault ist gesperrt.",
"creatingNewAlias": "Neuen Alias erstellen...",
"noMatchesFound": "Keine Treffer gefunden",
"searchVault": "Tresor durchsuchen...",
"serviceName": "Name des Dienstes",
"email": "E-Mail-Adresse",
"username": "Benutzername",
"password": "Passwort",
"enterServiceName": "Name des Dienstes eingeben",
"enterEmailAddress": "E-Mail-Adresse eingeben",
"enterUsername": "Benutzername eingeben",
"hideFor1Hour": "Für 1 Stunde ausblenden (aktuelle Seite)",
"hidePermanently": "Dauerhaft ausblenden (aktuelle Seite)",
"createRandomAlias": "Zufälligen Alias generieren",
"createUsernamePassword": "Benutzername/Passwort erstellen",
"randomAlias": "Zufälliger Alias",
"usernamePassword": "Benutzername/Passwort",
"createAndSaveAlias": "Alias erstellen und speichern",
"createAndSaveCredential": "Zugang erstellen und speichern",
"randomIdentityDescription": "Generiere eine zufällige Identität mit einer zufälligen E-Mail-Adresse von AliasVault.",
"randomIdentityDescriptionDropdown": "Zufällige Identität mit zufälliger E-Mail-Adresse",
"manualCredentialDescription": "Gebe Deine eigene E-Mail-Adresse und Benutzernamen an.",
"manualCredentialDescriptionDropdown": "Manueller Benutzername und Passwort",
"failedToCreateIdentity": "Das Erstellen der Identität ist fehlgeschlagen. Bitte versuche es erneut.",
"enterEmailAndOrUsername": "E-Mail-Adresse und/oder Benutzername eingeben",
"autofillWithAliasVault": "Autofill mit AliasVault",
"generateRandomPassword": "Zufälliges Passwort erzeugen (wird in die Zwischenablage kopiert)",
"generateNewPassword": "Neues Passwort erzeugen",
"togglePasswordVisibility": "Passwort ein-/ausblenden",
"passwordCopiedToClipboard": "Passwort in die Zwischenablage kopiert",
"enterEmailAndOrUsernameError": "E-Mail-Adresse und/oder Benutzername eingeben",
"openAliasVaultToUpgrade": "Zum Aktualisieren AliasVault öffnen ",
"vaultUpgradeRequired": "Aktualisierung des Tresors erforderlich.",
"dismissPopup": "Popup schliessen"
},
"credentials": {
"title": "Zugangsdaten",
"addCredential": "Zugang hinzufügen",
"editCredential": "Zugang bearbeiten",
"deleteCredential": "Zugang löschen",
"credentialDetails": "Details zum Zugang",
"serviceName": "Name des Dienstes",
"serviceNamePlaceholder": "z. B. Gmail, Facebook, Bank",
"website": "Webseite",
"websitePlaceholder": "https://example.com",
"username": "Benutzername",
"usernamePlaceholder": "Benutzername eingeben",
"password": "Passwort",
"passwordPlaceholder": "Passwort eingeben",
"generatePassword": "Passwort generieren",
"copyPassword": "Passwort kopieren",
"showPassword": "Passwort anzeigen",
"hidePassword": "Passwort verbergen",
"notes": "Notizen",
"notesPlaceholder": "Zusätzliche Notizen...",
"totp": "Zwei-Faktor-Authentifizierung",
"totpCode": "TOTP-Code",
"copyTotp": "TOTP kopieren",
"totpSecret": "TOTP-Geheimcode",
"totpSecretPlaceholder": "TOTP-Geheimcode eingeben",
"noCredentials": "Keine Zugangsdaten gefunden",
"noCredentialsDescription": "Erstelle Deinen ersten Zugang, um loszulegen",
"searchPlaceholder": "Zugangsdaten suchen...",
"welcomeTitle": "Willkommen bei AliasVault!",
"welcomeDescription": "Du möchtest die AliasVault-Browser-Erweiterung verwenden? Navigiere zu einer Website und verwende das AliasVault-Popup-Fenster um einen neuen Zugang zu erstellen.",
"createdAt": "Erstellt",
"updatedAt": "Zuletzt aktualisiert",
"autofill": "Autofill",
"fillForm": "Formular ausfüllen",
"deleteConfirm": "Bist Du sicher, dass Du diesen Zugang löschen möchtest?",
"saveSuccess": "Zugang erfolgreich gespeichert.",
"tags": "Schlagwörter",
"addTag": "Schlagwort hinzufügen",
"removeTag": "Schlagwort entfernen",
"folder": "Ordner",
"selectFolder": "Ordner auswählen",
"createFolder": "Ordner erstellen",
"saveCredential": "Zugang speichern",
"deleteCredentialTitle": "Zugang löschen",
"deleteCredentialConfirm": "Bist Du sicher, dass Du diesen Zugang löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
"randomAlias": "Zufälliger Alias",
"manual": "Manuell",
"service": "Dienst",
"serviceUrl": "URL des Dienstes",
"loginCredentials": "Zugangsdaten",
"generateRandomUsername": "Zufälligen Benutzernamen generieren",
"generateRandomPassword": "Zufälliges Passwort generieren",
"changePasswordComplexity": "Komplexität des Passworts ändern",
"passwordLength": "Passwortlänge",
"includeLowercase": "Kleinbuchstaben (a-z)",
"includeUppercase": "Großbuchstaben (A-Z)",
"includeNumbers": "Ziffern (0-9)",
"includeSpecialChars": "Sonderzeichen (!@#$%^&*)",
"avoidAmbiguousChars": "Mehrdeutige Zeichen (1, l, I, 0, O, etc.) vermeiden",
"generateNewPreview": "Neue Vorschau erstellen",
"generateRandomAlias": "Zufälligen Alias generieren",
"alias": "Alias",
"firstName": "Vorname",
"lastName": "Nachname",
"nickName": "Spitzname",
"gender": "Geschlecht",
"birthDate": "Geburtsdatum",
"birthDatePlaceholder": "JJJJ-MM-TT",
"metadata": "Metadaten",
"validation": {
"required": "Dieses Feld ist ein Pflichtfeld",
"serviceNameRequired": "Name des Dienstes ist erforderlich",
"invalidEmail": "Ungültiges E-Mail-Format",
"invalidDateFormat": "Bitte gib das Datum im Format JJJJ-MM-TT ein."
},
"privateEmailTitle": "Private E-Mail-Adresse",
"privateEmailAliasVaultServer": "AliasVault-Server",
"privateEmailDescription": "Ende-zu-Ende verschlüsselt, vollständig privat.",
"publicEmailTitle": "Öffentliche Temp-E-Mail-Anbieter",
"publicEmailDescription": "Anonyme, aber beschränkte Privatsphäre. E-Mail-Inhalt ist für jeden lesbar, der die Adresse kennt.",
"useDomainChooser": "Domain-Auswahl verwenden",
"enterCustomDomain": "Eigene Domain eingeben",
"enterFullEmail": "Vollständige E-Mail-Adresse eingeben",
"enterEmailPrefix": "E-Mail-Präfix eingeben"
},
"emails": {
"title": "E-Mails",
"deleteEmailTitle": "E-Mail löschen",
"deleteEmailConfirm": "Bist Du sicher, dass Du diese E-Mail unwiderruflich löschen möchtest?",
"from": "Von",
"to": "An",
"date": "Datum",
"emailContent": "Inhalt der E-Mail",
"attachments": "Anhänge",
"emailNotFound": "E-Mail nicht gefunden",
"noEmails": "Keine E-Mails gefunden",
"noEmailsDescription": "Du hast bisher keine E-Mails an Deine privaten E-Mail-Adressen erhalten. Neue E-Mails werden hier angezeigt, sobald sie eintreffen.",
"dateFormat": {
"justNow": "gerade eben",
"minutesAgo_single": "vor {{count}} Minute",
"minutesAgo_plural": "vor {{count}} Minuten",
"hoursAgo_single": "vor {{count}} Stunde",
"hoursAgo_plural": "vor {{count}} Stunden",
"yesterday": "gestern"
},
"errors": {
"emailLoadError": "Beim Laden der E-Mails ist ein Fehler aufgetreten. Bitte versuche es später erneut.",
"emailUnexpectedError": "Beim Laden der E-Mails ist ein unerwarteter Fehler aufgetreten. Bitte versuche es später erneut."
},
"apiErrors": {
"CLAIM_DOES_NOT_MATCH_USER": "Die aktuell gewählte E-Mail-Adresse wird bereits verwendet. Bitte ändere die E-Mail-Adresse, indem Du diese Zugangsdaten bearbeitest.",
"CLAIM_DOES_NOT_EXIST": "Beim Laden der E-Mails ist ein Fehler aufgetreten. Bitte bearbeite und speichere den Eintrag, um die Datenbank zu synchronisieren, und versuche es dann erneut."
}
},
"settings": {
"title": "Einstellungen",
"serverUrl": "URL des Servers",
"language": "Sprache",
"autofillEnabled": "Autofill aktivieren",
"version": "Version",
"openInNewWindow": "In neuem Fenster öffnen",
"openWebApp": "Web-App öffnen",
"loggedIn": "Angemeldet",
"logout": "Abmelden",
"globalSettings": "Allgemeine Einstellungen",
"autofillPopup": "Autofill-Popup",
"activeOnAllSites": "Auf allen Seiten aktiv (sofern nicht unten deaktiviert)",
"disabledOnAllSites": "Auf allen Seiten deaktiviert",
"enabled": "Aktiviert",
"disabled": "Deaktiviert",
"rightClickContextMenu": "Kontextmenü mit Rechtsklick",
"autofillMatching": "Autofill-Übereinstimmung",
"autofillMatchingMode": "Autofill-Übereinstimmungs-Modus",
"autofillMatchingModeDescription": "Legt fest, welche Zugangsdaten als Übereinstimmung angesehen werden und wird als Vorschlag im Autofill-Popup für eine bestimmte Website angezeigt.",
"autofillMatchingDefault": "URL + Subdomain + Wildcard-Name",
"autofillMatchingUrlSubdomain": "URL + Subdomain",
"autofillMatchingUrlExact": "Nur exakte URL-Domain",
"siteSpecificSettings": "Seitenspezifische Einstellungen",
"autofillPopupOn": "Autofill-Popup auf: ",
"enabledForThisSite": "Für diese Seite aktiviert",
"disabledForThisSite": "Für diese Seite deaktivieren",
"temporarilyDisabledUntil": "Vorübergehend deaktiviert bis ",
"resetAllSiteSettings": "Alle seitenspezifischen Einstellungen zurücksetzen",
"appearance": "Erscheinungsbild",
"theme": "Thema",
"useDefault": "Standard verwenden",
"light": "Hell",
"dark": "Dunkel",
"keyboardShortcuts": "Tastaturkürzel",
"configureKeyboardShortcuts": "Tastaturkürzel konfigurieren",
"configure": "Konfigurieren",
"security": "Sicherheit",
"clipboardClearTimeout": "Zwischenablage nach dem Kopieren automatisch löschen",
"clipboardClearTimeoutDescription": "Zwischenablage nach dem Kopieren sensibler Daten automatisch löschen",
"clipboardClearDisabled": "Niemals löschen",
"clipboardClear5Seconds": "Nach 5 Sekunden löschen",
"clipboardClear10Seconds": "Nach 10 Sekunden löschen",
"clipboardClear15Seconds": "Nach 15 Sekunden löschen",
"autoLockTimeout": "Sperr-Timeout",
"autoLockTimeoutDescription": "Tresor bei Inaktivität automatisch sperren",
"autoLockTimeoutHelp": "Der Tresor wird erst nach dem angegebenen Zeitraum der Inaktivität gesperrt (keine Nutzung von Autofill oder Öffnen des Erweiterungs-Popups). Der Tresor wird immer gesperrt, wenn der Browser geschlossen wird, unabhängig von dieser Einstellung.",
"autoLockNever": "Niemals",
"autoLock15Seconds": "15 Sekunden",
"autoLock1Minute": "1 Minute",
"autoLock5Minutes": "5 Minuten",
"autoLock15Minutes": "15 Minuten",
"autoLock30Minutes": "30 Minuten",
"autoLock1Hour": "1 Stunde",
"autoLock4Hours": "4 Stunden",
"autoLock8Hours": "8 Stunden",
"autoLock24Hours": "24 Stunden",
"versionPrefix": "Version ",
"preferences": "Einstellungen",
"autofillSettings": "Autofill-Einstellungen",
"clipboardSettings": "Zwischenablage-Einstellungen",
"contextMenuSettings": "Kontextmenü-Einstellungen",
"contextMenu": "Kontextmenü",
"contextMenuEnabled": "Kontextmenü ist aktiviert",
"contextMenuDisabled": "Kontextmenü ist deaktiviert",
"contextMenuDescription": "Rechtsklicke auf Eingabefelder, um auf AliasVault-Optionen zuzugreifen",
"selectLanguage": "Sprache auswählen",
"validation": {
"apiUrlRequired": "API-URL ist erforderlich",
"apiUrlInvalid": "Bitte gib eine gültige API-URL ein",
"clientUrlRequired": "Client-URL ist erforderlich",
"clientUrlInvalid": "Bitte gib eine gültige Client-URL ein"
}
},
"upgrade": {
"title": "Tresor aktualisieren",
"subtitle": "AliasVault wurde aktualisiert. Dadurch muss auch Dein Tresor aktualisiert werden. Dies sollte nur wenige Sekunden dauern.",
"versionInformation": "Versionsinformationen",
"yourVault": "Dein Tresor:",
"newVersion": "Neue Version:",
"upgrade": "Tresor aktualisieren",
"upgrading": "Aktualisieren...",
"logout": "Abmelden",
"whatsNew": "Neu in dieser Version",
"whatsNewDescription": "Eine Aktualisierung ist erforderlich, um die folgenden Änderungen zu unterstützen:",
"noDescriptionAvailable": "Für diese Version ist keine Beschreibung vorhanden.",
"okay": "OK",
"status": {
"preparingUpgrade": "Aktualisierung wird vorbereitet...",
"vaultAlreadyUpToDate": "Tresor ist bereits aktualisiert",
"startingDatabaseTransaction": "Datenbanktransaktion wird gestartet...",
"applyingDatabaseMigrations": "Datenbankmigration wird durchgeführt...",
"applyingMigration": "Führe Migration {{current}} von {{total}} durch...",
"committingChanges": "Änderungen werden übernommen..."
},
"alerts": {
"error": "Fehler",
"unableToGetVersionInfo": "Versionsinformationen konnten nicht abgerufen werden. Bitte versuche es erneut.",
"selfHostedServer": "Selbstgehosteter Server",
"selfHostedWarning": "Nutzt Du einen selbst gehosteten Server, musst Du Deine Instanz ebenfalls updaten. Andernfalls kannst Du Dich im Web-Client nicht mehr anmelden.",
"cancel": "Abbrechen",
"continueUpgrade": "Aktualisierung fortsetzen",
"upgradeFailed": "Aktualisierung fehlgeschlagen",
"failedToApplyMigration": "Migration fehlgeschlagen ({{current}} von {{total}})",
"unknownErrorDuringUpgrade": "Bei der Aktualisierung ist ein unbekannter Fehler aufgetreten. Bitte versuche es erneut."
}
}
}

View File

@@ -0,0 +1,392 @@
{
"auth": {
"loginTitle": "Log in to AliasVault",
"username": "Username or email",
"usernamePlaceholder": "name / name@company.com",
"password": "Password",
"passwordPlaceholder": "Enter your password",
"rememberMe": "Remember me",
"loginButton": "Login",
"noAccount": "No account yet?",
"createVault": "Create new vault",
"twoFactorTitle": "Please enter the authentication code from your authenticator app.",
"authCode": "Authentication Code",
"authCodePlaceholder": "Enter 6-digit code",
"verify": "Verify",
"cancel": "Cancel",
"twoFactorNote": "Note: if you don't have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website.",
"masterPassword": "Master Password",
"unlockVault": "Unlock Vault",
"unlockTitle": "Unlock Your Vault",
"unlockDescription": "Enter your master password to unlock your vault.",
"logout": "Logout",
"logoutConfirm": "Are you sure you want to logout?",
"sessionExpired": "Your session has expired. Please log in again.",
"unlockSuccess": "Vault unlocked successfully!",
"unlockSuccessTitle": "Your vault is successfully unlocked",
"unlockSuccessDescription": "You can now use autofill in login forms in your browser.",
"closePopup": "Close this popup",
"browseVault": "Browse vault contents",
"connectingTo": "Connecting to",
"switchAccounts": "Switch accounts?",
"loggedIn": "Logged in",
"errors": {
"invalidCode": "Please enter a valid 6-digit authentication code.",
"serverError": "Could not reach AliasVault server. Please try again later or contact support if the problem persists.",
"noToken": "Login failed -- no token returned",
"migrationError": "An error occurred while checking for pending migrations.",
"wrongPassword": "Incorrect password. Please try again.",
"accountLocked": "Account temporarily locked due to too many failed attempts.",
"networkError": "Network error. Please check your connection and try again.",
"loginDataMissing": "Login session expired. Please try again."
}
},
"menu": {
"credentials": "Credentials",
"emails": "Emails",
"settings": "Settings"
},
"common": {
"appName": "AliasVault",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"cancel": "Cancel",
"use": "Use",
"delete": "Delete",
"close": "Close",
"copied": "Copied!",
"openInNewWindow": "Open in new window",
"language": "Language",
"enabled": "Enabled",
"disabled": "Disabled",
"showPassword": "Show password",
"hidePassword": "Hide password",
"copyToClipboard": "Copy to clipboard",
"loadingEmails": "Loading emails...",
"loadingTotpCodes": "Loading TOTP codes...",
"attachments": "Attachments",
"loadingAttachments": "Loading attachments...",
"settings": "Settings",
"recentEmails": "Recent emails",
"loginCredentials": "Login credentials",
"twoFactorAuthentication": "Two-factor authentication",
"alias": "Alias",
"notes": "Notes",
"fullName": "Full Name",
"firstName": "First Name",
"lastName": "Last Name",
"birthDate": "Birth Date",
"nickname": "Nickname",
"email": "Email",
"username": "Username",
"password": "Password",
"syncingVault": "Syncing vault",
"savingChangesToVault": "Saving changes to vault",
"uploadingVaultToServer": "Uploading vault to server",
"checkingVaultUpdates": "Checking for vault updates",
"syncingUpdatedVault": "Syncing updated vault",
"executingOperation": "Executing operation...",
"loadMore": "Load more",
"errors": {
"VaultOutdated": "Your vault is outdated. Please login on the AliasVault website and follow the steps.",
"serverNotAvailable": "The AliasVault server is not available. Please try again later or contact support if the problem persists.",
"clientVersionNotSupported": "This version of the AliasVault browser extension is not supported by the server anymore. Please update your browser extension to the latest version.",
"serverVersionNotSupported": "The AliasVault server needs to be updated to a newer version in order to use this browser extension. Please contact support if you need help.",
"unknownError": "An unknown error occurred",
"failedToStoreVault": "Failed to store vault",
"vaultNotAvailable": "Vault not available",
"failedToRetrieveData": "Failed to retrieve data",
"vaultIsLocked": "Vault is locked",
"failedToUploadVault": "Failed to upload vault",
"passwordChanged": "Your password has changed since the last time you logged in. Please login again for security reasons."
},
"apiErrors": {
"UNKNOWN_ERROR": "An unknown error occurred. Please try again.",
"ACCOUNT_LOCKED": "Account temporarily locked due to too many failed attempts. Please try again later.",
"ACCOUNT_BLOCKED": "Your account has been disabled. If you believe this is a mistake, please contact support.",
"USER_NOT_FOUND": "Invalid username or password. Please try again.",
"INVALID_AUTHENTICATOR_CODE": "Invalid authenticator code. Please try again.",
"INVALID_RECOVERY_CODE": "Invalid recovery code. Please try again.",
"REFRESH_TOKEN_REQUIRED": "Refresh token is required.",
"INVALID_REFRESH_TOKEN": "Invalid refresh token.",
"REFRESH_TOKEN_REVOKED_SUCCESSFULLY": "Refresh token revoked successfully.",
"PUBLIC_REGISTRATION_DISABLED": "New account registration is currently disabled on this server. Please contact the administrator.",
"USERNAME_REQUIRED": "Username is required.",
"USERNAME_ALREADY_IN_USE": "Username is already in use.",
"USERNAME_AVAILABLE": "Username is available.",
"USERNAME_MISMATCH": "Username does not match the current user.",
"PASSWORD_MISMATCH": "The provided password does not match your current password.",
"ACCOUNT_SUCCESSFULLY_DELETED": "Account successfully deleted.",
"USERNAME_EMPTY_OR_WHITESPACE": "Username cannot be empty or whitespace.",
"USERNAME_TOO_SHORT": "Username too short: must be at least 3 characters long.",
"USERNAME_TOO_LONG": "Username too long: cannot be longer than 40 characters.",
"USERNAME_INVALID_EMAIL": "Invalid email address.",
"USERNAME_INVALID_CHARACTERS": "Username is invalid, can only contain letters or digits.",
"VAULT_NOT_UP_TO_DATE": "Your vault is not up-to-date. Please synchronize your vault and try again.",
"INTERNAL_SERVER_ERROR": "Internal server error.",
"VAULT_ERROR": "The local vault is not up-to-date. Please synchronize your vault by refreshing the page and try again."
}
},
"content": {
"or": "or",
"new": "New",
"cancel": "Cancel",
"search": "Search",
"vaultLocked": "AliasVault is locked.",
"creatingNewAlias": "Creating new alias...",
"noMatchesFound": "No matches found",
"searchVault": "Search vault...",
"serviceName": "Service name",
"email": "Email",
"username": "Username",
"password": "Password",
"enterServiceName": "Enter service name",
"enterEmailAddress": "Enter email address",
"enterUsername": "Enter username",
"hideFor1Hour": "Hide for 1 hour (current site)",
"hidePermanently": "Hide permanently (current site)",
"createRandomAlias": "Create random alias",
"createUsernamePassword": "Create username/password",
"randomAlias": "Random alias",
"usernamePassword": "Username/password",
"createAndSaveAlias": "Create and save alias",
"createAndSaveCredential": "Create and save credential",
"randomIdentityDescription": "Generate a random identity with a random email address accessible in AliasVault.",
"randomIdentityDescriptionDropdown": "Random identity with random email",
"manualCredentialDescription": "Specify your own email address and username.",
"manualCredentialDescriptionDropdown": "Manual username and password",
"failedToCreateIdentity": "Failed to create identity. Please try again.",
"enterEmailAndOrUsername": "Enter email and/or username",
"autofillWithAliasVault": "Autofill with AliasVault",
"generateRandomPassword": "Generate random password (copy to clipboard)",
"generateNewPassword": "Generate new password",
"togglePasswordVisibility": "Toggle password visibility",
"passwordCopiedToClipboard": "Password copied to clipboard",
"enterEmailAndOrUsernameError": "Enter email and/or username",
"openAliasVaultToUpgrade": "Open AliasVault to upgrade",
"vaultUpgradeRequired": "Vault upgrade required.",
"dismissPopup": "Dismiss popup"
},
"credentials": {
"title": "Credentials",
"addCredential": "Add Credential",
"editCredential": "Edit Credential",
"deleteCredential": "Delete Credential",
"credentialDetails": "Credential Details",
"serviceName": "Service Name",
"serviceNamePlaceholder": "e.g., Gmail, Facebook, Bank",
"website": "Website",
"websitePlaceholder": "https://example.com",
"username": "Username",
"usernamePlaceholder": "Enter username",
"password": "Password",
"passwordPlaceholder": "Enter password",
"generatePassword": "Generate Password",
"copyPassword": "Copy Password",
"showPassword": "Show Password",
"hidePassword": "Hide Password",
"notes": "Notes",
"notesPlaceholder": "Additional notes...",
"totp": "Two-Factor Authentication",
"totpCode": "TOTP Code",
"copyTotp": "Copy TOTP",
"totpSecret": "TOTP Secret",
"totpSecretPlaceholder": "Enter TOTP secret key",
"noCredentials": "No credentials found",
"noCredentialsDescription": "Add your first credential to get started",
"searchPlaceholder": "Search credentials...",
"welcomeTitle": "Welcome to AliasVault!",
"welcomeDescription": "To use the AliasVault browser extension: navigate to a website and use the AliasVault autofill popup to create a new credential.",
"createdAt": "Created",
"updatedAt": "Last updated",
"autofill": "Autofill",
"fillForm": "Fill Form",
"deleteConfirm": "Are you sure you want to delete this credential?",
"saveSuccess": "Credential saved successfully",
"tags": "Tags",
"addTag": "Add Tag",
"removeTag": "Remove Tag",
"folder": "Folder",
"selectFolder": "Select Folder",
"createFolder": "Create Folder",
"saveCredential": "Save credential",
"deleteCredentialTitle": "Delete Credential",
"deleteCredentialConfirm": "Are you sure you want to delete this credential? This action cannot be undone.",
"randomAlias": "Random Alias",
"manual": "Manual",
"service": "Service",
"serviceUrl": "Service URL",
"loginCredentials": "Login Credentials",
"generateRandomUsername": "Generate random username",
"generateRandomPassword": "Generate random password",
"changePasswordComplexity": "Change password complexity",
"passwordLength": "Password length",
"includeLowercase": "Include lowercase letters",
"includeUppercase": "Include uppercase letters",
"includeNumbers": "Include numbers",
"includeSpecialChars": "Include special characters",
"avoidAmbiguousChars": "Avoid ambiguous characters (o, 0, etc.)",
"generateNewPreview": "Generate new preview",
"generateRandomAlias": "Generate Random Alias",
"alias": "Alias",
"firstName": "First Name",
"lastName": "Last Name",
"nickName": "Nick Name",
"gender": "Gender",
"birthDate": "Birth Date",
"birthDatePlaceholder": "YYYY-MM-DD",
"metadata": "Metadata",
"validation": {
"required": "This field is required",
"serviceNameRequired": "Service name is required",
"invalidEmail": "Invalid email format",
"invalidDateFormat": "Date must be in YYYY-MM-DD format"
},
"privateEmailTitle": "Private Email",
"privateEmailAliasVaultServer": "AliasVault server",
"privateEmailDescription": "E2E encrypted, fully private.",
"publicEmailTitle": "Public Temp Email Providers",
"publicEmailDescription": "Anonymous but limited privacy. Email content is readable by anyone that knows the address.",
"useDomainChooser": "Use domain chooser",
"enterCustomDomain": "Enter custom domain",
"enterFullEmail": "Enter full email address",
"enterEmailPrefix": "Enter email prefix"
},
"emails": {
"title": "Emails",
"deleteEmailTitle": "Delete Email",
"deleteEmailConfirm": "Are you sure you want to permanently delete this email?",
"from": "From",
"to": "To",
"date": "Date",
"emailContent": "Email content",
"attachments": "Attachments",
"emailNotFound": "Email not found",
"noEmails": "No emails found",
"noEmailsDescription": "You have not received any emails at your private email addresses yet. When you receive a new email, it will appear here.",
"dateFormat": {
"justNow": "just now",
"minutesAgo_single": "{{count}} min ago",
"minutesAgo_plural": "{{count}} mins ago",
"hoursAgo_single": "{{count}} hr ago",
"hoursAgo_plural": "{{count}} hrs ago",
"yesterday": "yesterday"
},
"errors": {
"emailLoadError": "An error occurred while loading emails. Please try again later.",
"emailUnexpectedError": "An unexpected error occurred while loading emails. Please try again later."
},
"apiErrors": {
"CLAIM_DOES_NOT_MATCH_USER": "The current chosen email address is already in use. Please change the email address by editing this credential.",
"CLAIM_DOES_NOT_EXIST": "An error occurred while trying to load the emails. Please try to edit and save the credential entry to synchronize the database, then try again."
}
},
"settings": {
"title": "Settings",
"serverUrl": "Server URL",
"language": "Language",
"autofillEnabled": "Enable Autofill",
"version": "Version",
"openInNewWindow": "Open in new window",
"openWebApp": "Open web app",
"loggedIn": "Logged in",
"logout": "Logout",
"globalSettings": "Global Settings",
"autofillPopup": "Autofill popup",
"activeOnAllSites": "Active on all sites (unless disabled below)",
"disabledOnAllSites": "Disabled on all sites",
"enabled": "Enabled",
"disabled": "Disabled",
"rightClickContextMenu": "Right-click context menu",
"autofillMatching": "Autofill Matching",
"autofillMatchingMode": "Autofill matching mode",
"autofillMatchingModeDescription": "Determines which credentials are considered a match and shown as suggestions in the autofill popup for a given website.",
"autofillMatchingDefault": "URL + subdomain + name wildcard",
"autofillMatchingUrlSubdomain": "URL + subdomain",
"autofillMatchingUrlExact": "Exact URL domain only",
"siteSpecificSettings": "Site-Specific Settings",
"autofillPopupOn": "Autofill popup on: ",
"enabledForThisSite": "Enabled for this site",
"disabledForThisSite": "Disabled for this site",
"temporarilyDisabledUntil": "Temporarily disabled until ",
"resetAllSiteSettings": "Reset all site-specific settings",
"appearance": "Appearance",
"theme": "Theme",
"useDefault": "Use default",
"light": "Light",
"dark": "Dark",
"keyboardShortcuts": "Keyboard Shortcuts",
"configureKeyboardShortcuts": "Configure keyboard shortcuts",
"configure": "Configure",
"security": "Security",
"clipboardClearTimeout": "Clear clipboard after copying",
"clipboardClearTimeoutDescription": "Automatically clear the clipboard after copying sensitive data",
"clipboardClearDisabled": "Never clear",
"clipboardClear5Seconds": "Clear after 5 seconds",
"clipboardClear10Seconds": "Clear after 10 seconds",
"clipboardClear15Seconds": "Clear after 15 seconds",
"autoLockTimeout": "Auto-lock Timeout",
"autoLockTimeoutDescription": "Automatically lock the vault after a period of inactivity",
"autoLockTimeoutHelp": "The vault will only lock after the specified period of inactivity (no autofill usage or extension popup opened). The vault will always lock when the browser is closed, regardless of this setting.",
"autoLockNever": "Never",
"autoLock15Seconds": "15 seconds",
"autoLock1Minute": "1 minute",
"autoLock5Minutes": "5 minutes",
"autoLock15Minutes": "15 minutes",
"autoLock30Minutes": "30 minutes",
"autoLock1Hour": "1 hour",
"autoLock4Hours": "4 hours",
"autoLock8Hours": "8 hours",
"autoLock24Hours": "24 hours",
"versionPrefix": "Version ",
"preferences": "Preferences",
"autofillSettings": "Autofill Settings",
"clipboardSettings": "Clipboard Settings",
"contextMenuSettings": "Context Menu Settings",
"contextMenu": "Context Menu",
"contextMenuEnabled": "Context menu is enabled",
"contextMenuDisabled": "Context menu is disabled",
"contextMenuDescription": "Right-click on input fields to access AliasVault options",
"selectLanguage": "Select Language",
"validation": {
"apiUrlRequired": "API URL is required",
"apiUrlInvalid": "Please enter a valid API URL",
"clientUrlRequired": "Client URL is required",
"clientUrlInvalid": "Please enter a valid client URL"
}
},
"upgrade": {
"title": "Upgrade Vault",
"subtitle": "AliasVault has updated and your vault needs to be upgraded. This should only take a few seconds.",
"versionInformation": "Version Information",
"yourVault": "Your vault:",
"newVersion": "New version:",
"upgrade": "Upgrade Vault",
"upgrading": "Upgrading...",
"logout": "Logout",
"whatsNew": "What's New",
"whatsNewDescription": "An upgrade is required to support the following changes:",
"noDescriptionAvailable": "No description available for this version.",
"okay": "Ok",
"status": {
"preparingUpgrade": "Preparing upgrade...",
"vaultAlreadyUpToDate": "Vault is already up to date",
"startingDatabaseTransaction": "Starting database transaction...",
"applyingDatabaseMigrations": "Applying database migrations...",
"applyingMigration": "Applying migration {{current}} of {{total}}...",
"committingChanges": "Committing changes..."
},
"alerts": {
"error": "Error",
"unableToGetVersionInfo": "Unable to get version information. Please try again.",
"selfHostedServer": "Self-Hosted Server",
"selfHostedWarning": "If you're using a self-hosted server, make sure to also update your self-hosted instance as otherwise logging in to the web client will stop working.",
"cancel": "Cancel",
"continueUpgrade": "Continue Upgrade",
"upgradeFailed": "Upgrade Failed",
"failedToApplyMigration": "Failed to apply migration ({{current}} of {{total}})",
"unknownErrorDuringUpgrade": "An unknown error occurred during the upgrade. Please try again."
}
}
}

View File

@@ -0,0 +1,392 @@
{
"auth": {
"loginTitle": "Log in to AliasVault",
"username": "Username or email",
"usernamePlaceholder": "name / name@company.com",
"password": "Contraseña",
"passwordPlaceholder": "Enter your password",
"rememberMe": "Remember me",
"loginButton": "Iniciar sesión",
"noAccount": "No account yet?",
"createVault": "Create new vault",
"twoFactorTitle": "Please enter the authentication code from your authenticator app.",
"authCode": "Authentication Code",
"authCodePlaceholder": "Enter 6-digit code",
"verify": "Verify",
"cancel": "Cancel",
"twoFactorNote": "Note: if you don't have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website.",
"masterPassword": "Contraseña maestra",
"unlockVault": "Unlock Vault",
"unlockTitle": "Unlock Your Vault",
"unlockDescription": "Enter your master password to unlock your vault.",
"logout": "Logout",
"logoutConfirm": "Are you sure you want to logout?",
"sessionExpired": "Your session has expired. Please log in again.",
"unlockSuccess": "Vault unlocked successfully!",
"unlockSuccessTitle": "Your vault is successfully unlocked",
"unlockSuccessDescription": "You can now use autofill in login forms in your browser.",
"closePopup": "Close this popup",
"browseVault": "Browse vault contents",
"connectingTo": "Connecting to",
"switchAccounts": "Switch accounts?",
"loggedIn": "Logged in",
"errors": {
"invalidCode": "Please enter a valid 6-digit authentication code.",
"serverError": "Could not reach AliasVault server. Please try again later or contact support if the problem persists.",
"noToken": "Login failed -- no token returned",
"migrationError": "An error occurred while checking for pending migrations.",
"wrongPassword": "Incorrect password. Please try again.",
"accountLocked": "Account temporarily locked due to too many failed attempts.",
"networkError": "Network error. Please check your connection and try again.",
"loginDataMissing": "Login session expired. Please try again."
}
},
"menu": {
"credentials": "Credentials",
"emails": "Emails",
"settings": "Settings"
},
"common": {
"appName": "AliasVault",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"cancel": "Cancel",
"use": "Use",
"delete": "Delete",
"close": "Close",
"copied": "Copied!",
"openInNewWindow": "Open in new window",
"language": "Language",
"enabled": "Enabled",
"disabled": "Disabled",
"showPassword": "Show password",
"hidePassword": "Hide password",
"copyToClipboard": "Copy to clipboard",
"loadingEmails": "Loading emails...",
"loadingTotpCodes": "Loading TOTP codes...",
"attachments": "Attachments",
"loadingAttachments": "Loading attachments...",
"settings": "Settings",
"recentEmails": "Recent emails",
"loginCredentials": "Login credentials",
"twoFactorAuthentication": "Two-factor authentication",
"alias": "Alias",
"notes": "Notes",
"fullName": "Full Name",
"firstName": "First Name",
"lastName": "Last Name",
"birthDate": "Birth Date",
"nickname": "Nickname",
"email": "Email",
"username": "Username",
"password": "Password",
"syncingVault": "Syncing vault",
"savingChangesToVault": "Saving changes to vault",
"uploadingVaultToServer": "Uploading vault to server",
"checkingVaultUpdates": "Checking for vault updates",
"syncingUpdatedVault": "Syncing updated vault",
"executingOperation": "Executing operation...",
"loadMore": "Load more",
"errors": {
"VaultOutdated": "Your vault is outdated. Please login on the AliasVault website and follow the steps.",
"serverNotAvailable": "The AliasVault server is not available. Please try again later or contact support if the problem persists.",
"clientVersionNotSupported": "This version of the AliasVault browser extension is not supported by the server anymore. Please update your browser extension to the latest version.",
"serverVersionNotSupported": "The AliasVault server needs to be updated to a newer version in order to use this browser extension. Please contact support if you need help.",
"unknownError": "An unknown error occurred",
"failedToStoreVault": "Failed to store vault",
"vaultNotAvailable": "Vault not available",
"failedToRetrieveData": "Failed to retrieve data",
"vaultIsLocked": "Vault is locked",
"failedToUploadVault": "Failed to upload vault",
"passwordChanged": "Your password has changed since the last time you logged in. Please login again for security reasons."
},
"apiErrors": {
"UNKNOWN_ERROR": "An unknown error occurred. Please try again.",
"ACCOUNT_LOCKED": "Account temporarily locked due to too many failed attempts. Please try again later.",
"ACCOUNT_BLOCKED": "Your account has been disabled. If you believe this is a mistake, please contact support.",
"USER_NOT_FOUND": "Invalid username or password. Please try again.",
"INVALID_AUTHENTICATOR_CODE": "Invalid authenticator code. Please try again.",
"INVALID_RECOVERY_CODE": "Invalid recovery code. Please try again.",
"REFRESH_TOKEN_REQUIRED": "Refresh token is required.",
"INVALID_REFRESH_TOKEN": "Invalid refresh token.",
"REFRESH_TOKEN_REVOKED_SUCCESSFULLY": "Refresh token revoked successfully.",
"PUBLIC_REGISTRATION_DISABLED": "New account registration is currently disabled on this server. Please contact the administrator.",
"USERNAME_REQUIRED": "Username is required.",
"USERNAME_ALREADY_IN_USE": "Username is already in use.",
"USERNAME_AVAILABLE": "Username is available.",
"USERNAME_MISMATCH": "Username does not match the current user.",
"PASSWORD_MISMATCH": "The provided password does not match your current password.",
"ACCOUNT_SUCCESSFULLY_DELETED": "Account successfully deleted.",
"USERNAME_EMPTY_OR_WHITESPACE": "Username cannot be empty or whitespace.",
"USERNAME_TOO_SHORT": "Username too short: must be at least 3 characters long.",
"USERNAME_TOO_LONG": "Username too long: cannot be longer than 40 characters.",
"USERNAME_INVALID_EMAIL": "Invalid email address.",
"USERNAME_INVALID_CHARACTERS": "Username is invalid, can only contain letters or digits.",
"VAULT_NOT_UP_TO_DATE": "Your vault is not up-to-date. Please synchronize your vault and try again.",
"INTERNAL_SERVER_ERROR": "Internal server error.",
"VAULT_ERROR": "The local vault is not up-to-date. Please synchronize your vault by refreshing the page and try again."
}
},
"content": {
"or": "or",
"new": "New",
"cancel": "Cancel",
"search": "Search",
"vaultLocked": "AliasVault is locked.",
"creatingNewAlias": "Creating new alias...",
"noMatchesFound": "No matches found",
"searchVault": "Search vault...",
"serviceName": "Service name",
"email": "Email",
"username": "Username",
"password": "Password",
"enterServiceName": "Enter service name",
"enterEmailAddress": "Enter email address",
"enterUsername": "Enter username",
"hideFor1Hour": "Hide for 1 hour (current site)",
"hidePermanently": "Hide permanently (current site)",
"createRandomAlias": "Create random alias",
"createUsernamePassword": "Create username/password",
"randomAlias": "Random alias",
"usernamePassword": "Username/password",
"createAndSaveAlias": "Create and save alias",
"createAndSaveCredential": "Create and save credential",
"randomIdentityDescription": "Generate a random identity with a random email address accessible in AliasVault.",
"randomIdentityDescriptionDropdown": "Random identity with random email",
"manualCredentialDescription": "Specify your own email address and username.",
"manualCredentialDescriptionDropdown": "Manual username and password",
"failedToCreateIdentity": "Failed to create identity. Please try again.",
"enterEmailAndOrUsername": "Enter email and/or username",
"autofillWithAliasVault": "Autofill with AliasVault",
"generateRandomPassword": "Generate random password (copy to clipboard)",
"generateNewPassword": "Generate new password",
"togglePasswordVisibility": "Toggle password visibility",
"passwordCopiedToClipboard": "Password copied to clipboard",
"enterEmailAndOrUsernameError": "Enter email and/or username",
"openAliasVaultToUpgrade": "Open AliasVault to upgrade",
"vaultUpgradeRequired": "Vault upgrade required.",
"dismissPopup": "Dismiss popup"
},
"credentials": {
"title": "Credentials",
"addCredential": "Add Credential",
"editCredential": "Edit Credential",
"deleteCredential": "Delete Credential",
"credentialDetails": "Credential Details",
"serviceName": "Service Name",
"serviceNamePlaceholder": "e.g., Gmail, Facebook, Bank",
"website": "Website",
"websitePlaceholder": "https://example.com",
"username": "Username",
"usernamePlaceholder": "Enter username",
"password": "Password",
"passwordPlaceholder": "Enter password",
"generatePassword": "Generate Password",
"copyPassword": "Copy Password",
"showPassword": "Show Password",
"hidePassword": "Hide Password",
"notes": "Notes",
"notesPlaceholder": "Additional notes...",
"totp": "Two-Factor Authentication",
"totpCode": "TOTP Code",
"copyTotp": "Copy TOTP",
"totpSecret": "TOTP Secret",
"totpSecretPlaceholder": "Enter TOTP secret key",
"noCredentials": "No credentials found",
"noCredentialsDescription": "Add your first credential to get started",
"searchPlaceholder": "Search credentials...",
"welcomeTitle": "Welcome to AliasVault!",
"welcomeDescription": "To use the AliasVault browser extension: navigate to a website and use the AliasVault autofill popup to create a new credential.",
"createdAt": "Created",
"updatedAt": "Last updated",
"autofill": "Autofill",
"fillForm": "Fill Form",
"deleteConfirm": "Are you sure you want to delete this credential?",
"saveSuccess": "Credential saved successfully",
"tags": "Tags",
"addTag": "Add Tag",
"removeTag": "Remove Tag",
"folder": "Folder",
"selectFolder": "Select Folder",
"createFolder": "Create Folder",
"saveCredential": "Save credential",
"deleteCredentialTitle": "Delete Credential",
"deleteCredentialConfirm": "Are you sure you want to delete this credential? This action cannot be undone.",
"randomAlias": "Random Alias",
"manual": "Manual",
"service": "Service",
"serviceUrl": "Service URL",
"loginCredentials": "Login Credentials",
"generateRandomUsername": "Generate random username",
"generateRandomPassword": "Generate random password",
"changePasswordComplexity": "Change password complexity",
"passwordLength": "Password length",
"includeLowercase": "Include lowercase letters",
"includeUppercase": "Include uppercase letters",
"includeNumbers": "Include numbers",
"includeSpecialChars": "Include special characters",
"avoidAmbiguousChars": "Avoid ambiguous characters (o, 0, etc.)",
"generateNewPreview": "Generate new preview",
"generateRandomAlias": "Generate Random Alias",
"alias": "Alias",
"firstName": "First Name",
"lastName": "Last Name",
"nickName": "Nick Name",
"gender": "Gender",
"birthDate": "Birth Date",
"birthDatePlaceholder": "YYYY-MM-DD",
"metadata": "Metadata",
"validation": {
"required": "This field is required",
"serviceNameRequired": "Service name is required",
"invalidEmail": "Invalid email format",
"invalidDateFormat": "Date must be in YYYY-MM-DD format"
},
"privateEmailTitle": "Private Email",
"privateEmailAliasVaultServer": "AliasVault server",
"privateEmailDescription": "E2E encrypted, fully private.",
"publicEmailTitle": "Public Temp Email Providers",
"publicEmailDescription": "Anonymous but limited privacy. Email content is readable by anyone that knows the address.",
"useDomainChooser": "Use domain chooser",
"enterCustomDomain": "Enter custom domain",
"enterFullEmail": "Enter full email address",
"enterEmailPrefix": "Enter email prefix"
},
"emails": {
"title": "Emails",
"deleteEmailTitle": "Delete Email",
"deleteEmailConfirm": "Are you sure you want to permanently delete this email?",
"from": "From",
"to": "To",
"date": "Date",
"emailContent": "Email content",
"attachments": "Attachments",
"emailNotFound": "Email not found",
"noEmails": "No emails found",
"noEmailsDescription": "You have not received any emails at your private email addresses yet. When you receive a new email, it will appear here.",
"dateFormat": {
"justNow": "just now",
"minutesAgo_single": "{{count}} min ago",
"minutesAgo_plural": "{{count}} mins ago",
"hoursAgo_single": "{{count}} hr ago",
"hoursAgo_plural": "{{count}} hrs ago",
"yesterday": "yesterday"
},
"errors": {
"emailLoadError": "An error occurred while loading emails. Please try again later.",
"emailUnexpectedError": "An unexpected error occurred while loading emails. Please try again later."
},
"apiErrors": {
"CLAIM_DOES_NOT_MATCH_USER": "The current chosen email address is already in use. Please change the email address by editing this credential.",
"CLAIM_DOES_NOT_EXIST": "An error occurred while trying to load the emails. Please try to edit and save the credential entry to synchronize the database, then try again."
}
},
"settings": {
"title": "Settings",
"serverUrl": "Server URL",
"language": "Language",
"autofillEnabled": "Enable Autofill",
"version": "Version",
"openInNewWindow": "Open in new window",
"openWebApp": "Open web app",
"loggedIn": "Logged in",
"logout": "Logout",
"globalSettings": "Global Settings",
"autofillPopup": "Autofill popup",
"activeOnAllSites": "Active on all sites (unless disabled below)",
"disabledOnAllSites": "Disabled on all sites",
"enabled": "Enabled",
"disabled": "Disabled",
"rightClickContextMenu": "Right-click context menu",
"autofillMatching": "Autofill Matching",
"autofillMatchingMode": "Autofill matching mode",
"autofillMatchingModeDescription": "Determines which credentials are considered a match and shown as suggestions in the autofill popup for a given website.",
"autofillMatchingDefault": "URL + subdomain + name wildcard",
"autofillMatchingUrlSubdomain": "URL + subdomain",
"autofillMatchingUrlExact": "Exact URL domain only",
"siteSpecificSettings": "Site-Specific Settings",
"autofillPopupOn": "Autofill popup on: ",
"enabledForThisSite": "Enabled for this site",
"disabledForThisSite": "Disabled for this site",
"temporarilyDisabledUntil": "Temporarily disabled until ",
"resetAllSiteSettings": "Reset all site-specific settings",
"appearance": "Appearance",
"theme": "Theme",
"useDefault": "Use default",
"light": "Light",
"dark": "Dark",
"keyboardShortcuts": "Keyboard Shortcuts",
"configureKeyboardShortcuts": "Configure keyboard shortcuts",
"configure": "Configure",
"security": "Security",
"clipboardClearTimeout": "Clear clipboard after copying",
"clipboardClearTimeoutDescription": "Automatically clear the clipboard after copying sensitive data",
"clipboardClearDisabled": "Never clear",
"clipboardClear5Seconds": "Clear after 5 seconds",
"clipboardClear10Seconds": "Clear after 10 seconds",
"clipboardClear15Seconds": "Clear after 15 seconds",
"autoLockTimeout": "Auto-lock Timeout",
"autoLockTimeoutDescription": "Automatically lock the vault after a period of inactivity",
"autoLockTimeoutHelp": "The vault will only lock after the specified period of inactivity (no autofill usage or extension popup opened). The vault will always lock when the browser is closed, regardless of this setting.",
"autoLockNever": "Never",
"autoLock15Seconds": "15 seconds",
"autoLock1Minute": "1 minute",
"autoLock5Minutes": "5 minutes",
"autoLock15Minutes": "15 minutes",
"autoLock30Minutes": "30 minutes",
"autoLock1Hour": "1 hour",
"autoLock4Hours": "4 hours",
"autoLock8Hours": "8 hours",
"autoLock24Hours": "24 hours",
"versionPrefix": "Version ",
"preferences": "Preferences",
"autofillSettings": "Autofill Settings",
"clipboardSettings": "Clipboard Settings",
"contextMenuSettings": "Context Menu Settings",
"contextMenu": "Context Menu",
"contextMenuEnabled": "Context menu is enabled",
"contextMenuDisabled": "Context menu is disabled",
"contextMenuDescription": "Right-click on input fields to access AliasVault options",
"selectLanguage": "Select Language",
"validation": {
"apiUrlRequired": "API URL is required",
"apiUrlInvalid": "Please enter a valid API URL",
"clientUrlRequired": "Client URL is required",
"clientUrlInvalid": "Please enter a valid client URL"
}
},
"upgrade": {
"title": "Upgrade Vault",
"subtitle": "AliasVault has updated and your vault needs to be upgraded. This should only take a few seconds.",
"versionInformation": "Version Information",
"yourVault": "Your vault:",
"newVersion": "New version:",
"upgrade": "Upgrade Vault",
"upgrading": "Upgrading...",
"logout": "Logout",
"whatsNew": "What's New",
"whatsNewDescription": "An upgrade is required to support the following changes:",
"noDescriptionAvailable": "No description available for this version.",
"okay": "Ok",
"status": {
"preparingUpgrade": "Preparing upgrade...",
"vaultAlreadyUpToDate": "Vault is already up to date",
"startingDatabaseTransaction": "Starting database transaction...",
"applyingDatabaseMigrations": "Applying database migrations...",
"applyingMigration": "Applying migration {{current}} of {{total}}...",
"committingChanges": "Committing changes..."
},
"alerts": {
"error": "Error",
"unableToGetVersionInfo": "Unable to get version information. Please try again.",
"selfHostedServer": "Self-Hosted Server",
"selfHostedWarning": "If you're using a self-hosted server, make sure to also update your self-hosted instance as otherwise logging in to the web client will stop working.",
"cancel": "Cancel",
"continueUpgrade": "Continue Upgrade",
"upgradeFailed": "Upgrade Failed",
"failedToApplyMigration": "Failed to apply migration ({{current}} of {{total}})",
"unknownErrorDuringUpgrade": "An unknown error occurred during the upgrade. Please try again."
}
}
}

View File

@@ -0,0 +1,392 @@
{
"auth": {
"loginTitle": "Kirjaudu sisään AliasVaultiin",
"username": "Käyttäjänimi tai sähköposti",
"usernamePlaceholder": "nimi / nimi@yritys.fi",
"password": "Salasana",
"passwordPlaceholder": "Syötä salasanasi",
"rememberMe": "Muista minut",
"loginButton": "Kirjaudu",
"noAccount": "Eikö sinulla ole vielä tiliä?",
"createVault": "Luo uusi holvi",
"twoFactorTitle": "Ole hyvä ja syötä tunnistautumiskoodi tunnistautumissovelluksestasi.",
"authCode": "Tunnistautumiskoodi",
"authCodePlaceholder": "Syötä 6-numeroinen koodi",
"verify": "Vahvista",
"cancel": "Peruuta",
"twoFactorNote": "Huomautus: jos sinulla ei ole pääsyä tunnistautumislaitteeseen, voit palauttaa 2FA:n palautuskoodilla kirjautumalla sisään sivuston kautta.",
"masterPassword": "Pääsalasana",
"unlockVault": "Avaa holvi",
"unlockTitle": "Avaa Holvisi",
"unlockDescription": "Syötä pääsalasanasi avataksesi holvisi lukituksen.",
"logout": "Kirjaudu ulos",
"logoutConfirm": "Oletko varma, että haluat kirjautua ulos?",
"sessionExpired": "Istuntosi on vanhentunut. Ole hyvä ja kirjaudu uudelleen.",
"unlockSuccess": "Holvi avattu onnistuneesti!",
"unlockSuccessTitle": "Holvisi lukitus on onnistuneesti avattu",
"unlockSuccessDescription": "Voit nyt käyttää selaimessasi olevia kirjautumislomakkeita automaattisesti.",
"closePopup": "Sulje tämä ponnahdusikkuna",
"browseVault": "Selaa holvin sisältöä",
"connectingTo": "Yhdistetään palvelimeen",
"switchAccounts": "Vaihdetaanko tiliä?",
"loggedIn": "Kirjautuneena",
"errors": {
"invalidCode": "Anna kelvollinen 6-numeroinen tunnistautumiskoodi.",
"serverError": "AliasVault-palvelimeen ei saatu yhteyttä. Yritä myöhemmin uudelleen tai ota yhteyttä tukeen, jos ongelma jatkuu.",
"noToken": "Kirjautuminen epäonnistui -- tunnusta ei palautettu",
"migrationError": "Tapahtui virhe tarkistettaessa odottavia siirtoja.",
"wrongPassword": "Virheellinen salasana. Yritä uudelleen.",
"accountLocked": "Tili on tilapäisesti lukittu liian monen epäonnistuneen yrityksen vuoksi. Yritä myöhemmin uudelleen.",
"networkError": "Verkkovirhe: tarkista yhteytesi ja yritä uudelleen.",
"loginDataMissing": "Kirjautumisistunto on vanhentunut. Yritä uudelleen."
}
},
"menu": {
"credentials": "Käyttäjätunnukset",
"emails": "Sähköpostit",
"settings": "Asetukset"
},
"common": {
"appName": "AliasVault",
"loading": "Ladataan...",
"error": "Virhe",
"success": "Onnistui",
"cancel": "Peruuta",
"use": "Käytä",
"delete": "Poista",
"close": "Sulje",
"copied": "Kopioitu!",
"openInNewWindow": "Avaa uudessa ikkunassa",
"language": "Kieli",
"enabled": "Käytössä",
"disabled": "Pois käytöstä",
"showPassword": "Näytä salasana",
"hidePassword": "Piilota salasana",
"copyToClipboard": "Kopioi leikepöydälle",
"loadingEmails": "Ladataan sähköposteja...",
"loadingTotpCodes": "Ladataan TOTP-koodeja...",
"attachments": "Liitteet",
"loadingAttachments": "Ladataan liitteitä...",
"settings": "Asetukset",
"recentEmails": "Viimeisimmät sähköpostit",
"loginCredentials": "Sisäänkirjautumistiedot",
"twoFactorAuthentication": "Kaksivaiheinen tunnistautuminen",
"alias": "Alias",
"notes": "Muistiinpanot",
"fullName": "Koko nimi",
"firstName": "Etunimi",
"lastName": "Sukunimi",
"birthDate": "Syntymäpäivä",
"nickname": "Lempinimi",
"email": "Sähköposti",
"username": "Käyttäjänimi",
"password": "Salasana",
"syncingVault": "Synkronoidaan holvia",
"savingChangesToVault": "Tallennetaan muutoksia holviin",
"uploadingVaultToServer": "Lähetetään holvi palvelimelle",
"checkingVaultUpdates": "Tarkistetaan holvin päivityksiä",
"syncingUpdatedVault": "Synkronoidaan päivitettyä holvia",
"executingOperation": "Suoritetaan toimintoa...",
"loadMore": "Lataa lisää",
"errors": {
"VaultOutdated": "Holvisi on vanhentunut. Kirjaudu AliasVaultin kotisivulle ja noudata ohjeita.",
"serverNotAvailable": "AliasVault-palvelin ei ole käytettävissä. Yritä myöhemmin uudelleen tai ota yhteyttä tukeen, jos ongelma jatkuu.",
"clientVersionNotSupported": "Palvelin ei enää tue tätä AliasVault-selainlaajennuksen versiota. Ole hyvä ja päivitä selaimen laajennus uusimpaan versioon.",
"serverVersionNotSupported": "AliasVault-palvelin on päivitettävä uudempaan versioon, jotta voit käyttää tätä selainlaajennusta. Ota yhteyttä tukeen, jos tarvitset apua.",
"unknownError": "Tapahtui tuntematon virhe",
"failedToStoreVault": "Holvin tallentaminen epäonnistui",
"vaultNotAvailable": "Holvi ei ole käytettävissä",
"failedToRetrieveData": "Tietojen nouto epäonnistui",
"vaultIsLocked": "Holvi on lukittu",
"failedToUploadVault": "Holvin lataaminen epäonnistui",
"passwordChanged": "Salasanasi on muuttunut edellisen kirjautumisen jälkeen. Ole hyvä ja kirjaudu uudelleen turvallisuussyistä."
},
"apiErrors": {
"UNKNOWN_ERROR": "Tapahtui tuntematon virhe. Yritä uudelleen.",
"ACCOUNT_LOCKED": "Tili on tilapäisesti lukittu liian monen epäonnistuneen yrityksen vuoksi. Yritä myöhemmin uudelleen.",
"ACCOUNT_BLOCKED": "Tilisi on poistettu käytöstä. Jos uskot, että tämä on virhe, ota yhteyttä tukeen.",
"USER_NOT_FOUND": "Virheellinen käyttäjänimi tai salasana. Yritä uudelleen.",
"INVALID_AUTHENTICATOR_CODE": "Virheellinen tunnistautumiskoodi. Yritä uudelleen.",
"INVALID_RECOVERY_CODE": "Virheellinen palautuskoodi. Yritä uudelleen.",
"REFRESH_TOKEN_REQUIRED": "Päivitysavain vaaditaan.",
"INVALID_REFRESH_TOKEN": "Virheellinen päivitysavain.",
"REFRESH_TOKEN_REVOKED_SUCCESSFULLY": "Päivitysavain peruutettu onnistuneesti.",
"PUBLIC_REGISTRATION_DISABLED": "Uuden tilin rekisteröinti on poistettu käytöstä tällä palvelimella. Ota yhteyttä järjestelmänvalvojaan.",
"USERNAME_REQUIRED": "Käyttäjänimi vaaditaan.",
"USERNAME_ALREADY_IN_USE": "Käyttäjätunnus on jo käytössä",
"USERNAME_AVAILABLE": "Käyttäjänimi on saatavilla.",
"USERNAME_MISMATCH": "Käyttäjänimi ei vastaa nykyistä käyttäjää.",
"PASSWORD_MISMATCH": "Annettu salasana ei vastaa nykyistä salasanaasi.",
"ACCOUNT_SUCCESSFULLY_DELETED": "Käyttäjätili onnistuneesti poistettu,.",
"USERNAME_EMPTY_OR_WHITESPACE": "Käyttäjätunnus ei voi olla tyhjä.",
"USERNAME_TOO_SHORT": "Käyttäjätunnus on liian lyhyt: sen on oltava vähintään 3 merkkiä pitkä.",
"USERNAME_TOO_LONG": "Käyttäjätunnus on liian pitkä: se voi olla enintään 40 merkkiä.",
"USERNAME_INVALID_EMAIL": "Virheellinen sähköpostiosoite.",
"USERNAME_INVALID_CHARACTERS": "Käyttäjätunnus on virheellinen, voi sisältää vain kirjaimia tai numeroita.",
"VAULT_NOT_UP_TO_DATE": "Holvisi ei ole ajan tasalla. Synkronoi holvisi ja yritä uudelleen.",
"INTERNAL_SERVER_ERROR": "Sisäinen palvelinvirhe.",
"VAULT_ERROR": "Paikallinen holvi ei ole ajan tasalla. Synkronoi holvisi päivittämällä sivu ja yritä uudelleen."
}
},
"content": {
"or": "tai",
"new": "Uusi",
"cancel": "Peruuta",
"search": "Etsi",
"vaultLocked": "AliasVault on lukittu.",
"creatingNewAlias": "Luodaan uutta aliasta...",
"noMatchesFound": "Hakutuloksia ei löytynyt",
"searchVault": "Etsi holvi...",
"serviceName": "Palvelun nimi",
"email": "Sähköposti",
"username": "Käyttäjänimi",
"password": "Salasana",
"enterServiceName": "Syötä palvelun nimi",
"enterEmailAddress": "Syötä sähköpostiosoite",
"enterUsername": "Syötä käyttäjänimi",
"hideFor1Hour": "Piilota 1 tunniksi (nykyinen sivusto)",
"hidePermanently": "Piilota pysyvästi (nykyinen sivu)",
"createRandomAlias": "Luo sattumanvarainen alias",
"createUsernamePassword": "Luo käyttäjänimi/salasana",
"randomAlias": "Sattumanvarainen alias",
"usernamePassword": "Käyttäjänimi/Salasana",
"createAndSaveAlias": "Luo ja tallenna alias",
"createAndSaveCredential": "Luo ja tallenna käyttäjätunnus",
"randomIdentityDescription": "Luo satunnainen identiteetti, jolla on satunnainen sähköpostiosoite, johon on pääsy AliasVaultissa.",
"randomIdentityDescriptionDropdown": "Satunnainen identiteetti satunnaisella sähköpostiosoitteella",
"manualCredentialDescription": "Määritä oma sähköpostiosoitteesi ja käyttäjänimesi.",
"manualCredentialDescriptionDropdown": "Manuaalinen käyttäjänimi ja salasana",
"failedToCreateIdentity": "Henkilöllisyyden luonti epäonnistui. Yritä uudelleen.",
"enterEmailAndOrUsername": "Syötä sähköpostiosoite ja/tai käyttäjänimi",
"autofillWithAliasVault": "Automaattinen täyttö AliasVaultilla",
"generateRandomPassword": "Luo sattumanvarainen salasana (kopioi leikepöydälle)",
"generateNewPassword": "Luo uusi salasana",
"togglePasswordVisibility": "Vaihda salasanan näkyvyyttä",
"passwordCopiedToClipboard": "Salasana kopioitu leikepöydälle",
"enterEmailAndOrUsernameError": "Syötä sähköpostiosoite ja/tai käyttäjänimi",
"openAliasVaultToUpgrade": "Avaa AliasVault päivittääksesi",
"vaultUpgradeRequired": "Holvin päivitys vaaditaan.",
"dismissPopup": "Hylkää ponnahdusikkuna"
},
"credentials": {
"title": "Käyttäjätunnukset",
"addCredential": "Lisää käyttäjätunnus",
"editCredential": "Muokkaa käyttäjätunnusta",
"deleteCredential": "Poista käyttäjätunnus",
"credentialDetails": "Käyttäjätunnuksen tiedot",
"serviceName": "Palvelun nimi",
"serviceNamePlaceholder": "esim. Gmail, Facebook, Pankki",
"website": "Verkkosivusto",
"websitePlaceholder": "https://esimerkki.fi",
"username": "Käyttäjänimi",
"usernamePlaceholder": "Syötä käyttäjänimi",
"password": "Salasana",
"passwordPlaceholder": "Syötä salasana",
"generatePassword": "Luo salasana",
"copyPassword": "Kopioi salasana",
"showPassword": "Näytä salasana",
"hidePassword": "Piilota salasana",
"notes": "Muistiinpanot",
"notesPlaceholder": "Muut huomautukset...",
"totp": "Kaksivaiheinen tunnistautuminen",
"totpCode": "TOTP koodi",
"copyTotp": "Kopioi TOTP-koodi",
"totpSecret": "TOTP Salaus",
"totpSecretPlaceholder": "Syötä TOTP salainen avain",
"noCredentials": "Käyttäjätunnuksia ei löytynyt",
"noCredentialsDescription": "Lisää ensimmäinen käyttäjätunnuksesi aloittaaksesi",
"searchPlaceholder": "Etsi käyttäjätunnuksia...",
"welcomeTitle": "Tervetuloa AliasVaultiin!",
"welcomeDescription": "Käyttääksesi AliasVault-selainlaajennusta: Siirry sivustolle ja käytä AliasVaultin automaattisen täytön ponnahdusikkunaa luodaksesi uuden käyttäjätunnuksen.",
"createdAt": "Luotu",
"updatedAt": "Viimeksi päivitetty",
"autofill": "Automaattinen täyttö",
"fillForm": "Täytä lomake",
"deleteConfirm": "Oletko varma, että haluat poistaa tämän käyttäjätunnuksen?",
"saveSuccess": "Käyttäjätunnus tallennettu onnistuneesti.",
"tags": "Tunnisteet",
"addTag": "Lisää tunniste",
"removeTag": "Poista tunniste",
"folder": "Kansio",
"selectFolder": "Valitse kansio",
"createFolder": "Luo kansio",
"saveCredential": "Tallenna käyttäjätunnus",
"deleteCredentialTitle": "Poista käyttäjätunnus",
"deleteCredentialConfirm": "Oletko varma, että haluat poistaa tämän tunnuksen? Tätä toimintoa ei voi perua.",
"randomAlias": "Sattumanvarainen Alias",
"manual": "Käyttöopas",
"service": "Palvelu",
"serviceUrl": "Palvelun URL-osoite",
"loginCredentials": "Sisäänkirjautumistiedot",
"generateRandomUsername": "Luo sattumanvarainen käyttäjätunnus",
"generateRandomPassword": "Luo sattumanvarainen salasana",
"changePasswordComplexity": "Muuta salasanan monimutkaisuutta",
"passwordLength": "Salasanan pituus",
"includeLowercase": "Sisällytä pienet kirjaimet",
"includeUppercase": "Sisällytä isot kirjaimet",
"includeNumbers": "Sisällytä numerot",
"includeSpecialChars": "Sisällytä erikoismerkit",
"avoidAmbiguousChars": "Vältä epäselviä merkkejä (o, 0 jne.)",
"generateNewPreview": "Luo uusi esikatselu",
"generateRandomAlias": "Luo sattumanvarainen alias",
"alias": "Alias",
"firstName": "Etunimi",
"lastName": "Sukunimi",
"nickName": "Lempinimi",
"gender": "Sukupuoli",
"birthDate": "Syntymäpäivä",
"birthDatePlaceholder": "VVVV-KK-PP.",
"metadata": "Metatiedot",
"validation": {
"required": "Tämä kenttä on pakollinen.",
"serviceNameRequired": "Palvelun nimi on pakollinen",
"invalidEmail": "Virheellinen sähköpostiosoitteen muoto",
"invalidDateFormat": "Päivämäärän on oltava muodossa VVVV-KK-PP."
},
"privateEmailTitle": "Yksityinen sähköposti",
"privateEmailAliasVaultServer": "AliasVault-palvelin",
"privateEmailDescription": "E2E salattu, täysin yksityinen.",
"publicEmailTitle": "Julkiset väliaikaisen sähköpostiosoitteen tarjoajat",
"publicEmailDescription": "Anonyymi mutta rajoitettu yksityisyys. Käytettävissä kaikille, jotka tuntevat osoitteen.",
"useDomainChooser": "Käytä verkkotunnuksen valintaa",
"enterCustomDomain": "Anna oma verkkotunnus",
"enterFullEmail": "Syötä täysi sähköpostiosoite",
"enterEmailPrefix": "Syötä sähköpostin etuliite"
},
"emails": {
"title": "Sähköpostit",
"deleteEmailTitle": "Poista sähköposti",
"deleteEmailConfirm": "Oletko varma, että haluat poistaa tämän kuvan pysyvästi?",
"from": "Lähettäjä",
"to": "Vastaanottaja",
"date": "Päivämäärä",
"emailContent": "Sähköpostin sisältö",
"attachments": "Liitteet",
"emailNotFound": "Sähköpostia ei löytynyt",
"noEmails": "Sähköposteja ei löytynyt",
"noEmailsDescription": "Et ole vielä vastaanottanut sähköposteja yksityisissä sähköpostiosoitteissasi. Kun saat uuden sähköpostiviestin, se näkyy täällä.",
"dateFormat": {
"justNow": "juuri nyt",
"minutesAgo_single": "{{count}} min sitten",
"minutesAgo_plural": "{{count}} minuuttia sitten",
"hoursAgo_single": "{{count}} h sitten",
"hoursAgo_plural": "{{count}} tuntia sitten",
"yesterday": "eilen"
},
"errors": {
"emailLoadError": "Sähköpostien lataamisessa tapahtui virhe. Yritä myöhemmin uudelleen.",
"emailUnexpectedError": "Odottamaton virhe sähköpostien latauksen aikana. Yritä myöhemmin uudelleen."
},
"apiErrors": {
"CLAIM_DOES_NOT_MATCH_USER": "Nykyinen valittu sähköpostiosoite on jo käytössä. Ole hyvä ja vaihda sähköpostiosoite muokkaamalla tätä tunnusta.",
"CLAIM_DOES_NOT_EXIST": "Tapahtui virhe ladattaessa sähköposteja. Yritä muokata ja tallentaa tunnistetiedot synkronoidaksesi tietokannan, ja yritä sitten uudelleen."
}
},
"settings": {
"title": "Asetukset",
"serverUrl": "Palvelimen URL-osoite",
"language": "Kieli",
"autofillEnabled": "Ota automaattitäyttö käyttöön",
"version": "Versio",
"openInNewWindow": "Avaa uudessa ikkunassa",
"openWebApp": "Avaa verkkosovellus",
"loggedIn": "Kirjautuneena",
"logout": "Kirjaudu ulos",
"globalSettings": "Yleiset asetukset",
"autofillPopup": "Automaattisen täytön ponnahdusikkuna",
"activeOnAllSites": "Aktiivinen kaikilla sivustoilla (paitsi jos pois päältä alla)",
"disabledOnAllSites": "Poistettu käytöstä kaikilla sivustoilla",
"enabled": "Käytössä",
"disabled": "Pois käytöstä",
"rightClickContextMenu": "Oikea-klikkauksen kontekstivalikko",
"autofillMatching": "Autofill osuma",
"autofillMatchingMode": "Autofill osumat käytössä",
"autofillMatchingModeDescription": "Määrittää mitkä käyttäjätunnukset katsotaan osumaksi ja näytetään automaattisen täytön ponnahdusikkunan ehdotuksina tietylle sivustolle.",
"autofillMatchingDefault": "URL + alitoimialue + nimi jokerimerkki",
"autofillMatchingUrlSubdomain": "URL + alitoimialue",
"autofillMatchingUrlExact": "Tarkka URL-verkkotunnus vain",
"siteSpecificSettings": "Sivukohtaiset asetukset",
"autofillPopupOn": "Automaattisen täytön ponnahdusikkuna päällä: ",
"enabledForThisSite": "Käytössä tällä sivustolla",
"disabledForThisSite": "Ei käytössä tällä sivustolla",
"temporarilyDisabledUntil": "Tilapäisesti pois päältä ",
"resetAllSiteSettings": "Nollaa kaikki sivustokohtaiset asetukset",
"appearance": "Ulkoasu",
"theme": "Teema",
"useDefault": "Käytä oletusta",
"light": "Vaalea",
"dark": "Tumma",
"keyboardShortcuts": "Pikanäppäimet",
"configureKeyboardShortcuts": "Määritä pikanäppäimet",
"configure": "Määritä",
"security": "Tietoturva",
"clipboardClearTimeout": "Tyhjennä leikepöytä kopioinnin jälkeen",
"clipboardClearTimeoutDescription": "Tyhjennä leikepöytä automaattisesti arkaluonteisten tietojen kopioinnin jälkeen",
"clipboardClearDisabled": "Älä tyhjennä koskaan",
"clipboardClear5Seconds": "Tyhjennä 5 sekunnin jälkeen",
"clipboardClear10Seconds": "Tyhjennä 10 sekunnin jälkeen",
"clipboardClear15Seconds": "Tyhjennä 15 sekunnin jälkeen",
"autoLockTimeout": "Automaattisen lukituksen aikakatkaisu",
"autoLockTimeoutDescription": "Lukitse holvi automaattisesti käyttämättä jäämisen jälkeen",
"autoLockTimeoutHelp": "Holvi lukittuu vain määritellyn käyttöajan jälkeen (ei automaattisen täytön käyttöä tai laajennuksen ponnahdusikkunaa auki). Holvi lukittuu aina, kun selain on suljettu, tästä asetuksesta riippumatta.",
"autoLockNever": "Ei koskaan",
"autoLock15Seconds": "15 sekuntia",
"autoLock1Minute": "1 minuutti",
"autoLock5Minutes": "5 minuuttia",
"autoLock15Minutes": "15 minuuttia",
"autoLock30Minutes": "30 minuuttia",
"autoLock1Hour": "1 tunti",
"autoLock4Hours": "4 tuntia",
"autoLock8Hours": "8 tuntia",
"autoLock24Hours": "24 tuntia",
"versionPrefix": "Versio",
"preferences": "Määritykset",
"autofillSettings": "Automaatisen täytön asetukset",
"clipboardSettings": "Leikepöydän asetukset",
"contextMenuSettings": "Sisältövalikon asetukset",
"contextMenu": "Sisältövalikko",
"contextMenuEnabled": "Sisältövalikko käytössä",
"contextMenuDisabled": "Sisältövalikko pois käytöstä",
"contextMenuDescription": "Napsauta syöttökenttiä hiiren kakkospainikkeella päästäksesi käsiksi AliasVaultin valintoihin",
"selectLanguage": "Valitse kieli",
"validation": {
"apiUrlRequired": "API URL-osoite vaaditaan",
"apiUrlInvalid": "Anna kelvollinen API URL-osoite",
"clientUrlRequired": "Asiakkaan URL-osoite vaaditaan",
"clientUrlInvalid": "Anna kelvollinen asiakkaan URL-osoite"
}
},
"upgrade": {
"title": "Päivitä holvi",
"subtitle": "AliasVault on päivitetty ja holvisi on päivitettävä. Tämän pitäisi kestää vain muutama sekunti.",
"versionInformation": "Versiotiedot",
"yourVault": "Sinun holvisi:",
"newVersion": "Uusi versio:",
"upgrade": "Päivitä Holvi",
"upgrading": "Päivitetään...",
"logout": "Kirjaudu ulos",
"whatsNew": "Mitä uutta?",
"whatsNewDescription": "Päivitys on tarpeen, jotta voidaan tukea seuraavia muutoksia:",
"noDescriptionAvailable": "Kuvausta ei ole saatavilla tälle versiolle.",
"okay": "Ok",
"status": {
"preparingUpgrade": "Valmistellaan päivityksiä...",
"vaultAlreadyUpToDate": "Holvi on jo ajan tasalla",
"startingDatabaseTransaction": "Aloitetaan tietokannan siirtoa...",
"applyingDatabaseMigrations": "Toteutetaan tietokannan siirtoja...",
"applyingMigration": "Siirretään tietoja: {{current}} / {{total}}...",
"committingChanges": "Suoritetaan muutoksia..."
},
"alerts": {
"error": "Virhe",
"unableToGetVersionInfo": "Versiotietoja ei löytynyt. Yritä uudelleen.",
"selfHostedServer": "Itsehallinnoitu palvelin",
"selfHostedWarning": "Jos käytät itsehallintoitua palvelina, varmista myös että päivität itsehallinnoidun palvelimesi, jos muutoin kirjautuminen web-asiakkaan kautta lakkaa toimimasta.",
"cancel": "Peruuta",
"continueUpgrade": "Jatka päivitystä",
"upgradeFailed": "Päivitys epäonnistui",
"failedToApplyMigration": "Tietojen siirto epäonnistui {{current}} / {{total}} ",
"unknownErrorDuringUpgrade": "Päivityksen aikana tapahtui tuntematon virhe. Yritä uudelleen."
}
}
}

View File

@@ -0,0 +1,392 @@
{
"auth": {
"loginTitle": "Log in to AliasVault",
"username": "Username or email",
"usernamePlaceholder": "name / name@company.com",
"password": "Password",
"passwordPlaceholder": "Enter your password",
"rememberMe": "Remember me",
"loginButton": "Login",
"noAccount": "No account yet?",
"createVault": "Create new vault",
"twoFactorTitle": "Please enter the authentication code from your authenticator app.",
"authCode": "Authentication Code",
"authCodePlaceholder": "Enter 6-digit code",
"verify": "Verify",
"cancel": "Cancel",
"twoFactorNote": "Note: if you don't have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website.",
"masterPassword": "Master Password",
"unlockVault": "Unlock Vault",
"unlockTitle": "Unlock Your Vault",
"unlockDescription": "Enter your master password to unlock your vault.",
"logout": "Logout",
"logoutConfirm": "Are you sure you want to logout?",
"sessionExpired": "Your session has expired. Please log in again.",
"unlockSuccess": "Vault unlocked successfully!",
"unlockSuccessTitle": "Your vault is successfully unlocked",
"unlockSuccessDescription": "You can now use autofill in login forms in your browser.",
"closePopup": "Close this popup",
"browseVault": "Browse vault contents",
"connectingTo": "Connecting to",
"switchAccounts": "Switch accounts?",
"loggedIn": "Logged in",
"errors": {
"invalidCode": "Please enter a valid 6-digit authentication code.",
"serverError": "Could not reach AliasVault server. Please try again later or contact support if the problem persists.",
"noToken": "Login failed -- no token returned",
"migrationError": "An error occurred while checking for pending migrations.",
"wrongPassword": "Incorrect password. Please try again.",
"accountLocked": "Account temporarily locked due to too many failed attempts.",
"networkError": "Network error. Please check your connection and try again.",
"loginDataMissing": "Login session expired. Please try again."
}
},
"menu": {
"credentials": "Credentials",
"emails": "Emails",
"settings": "Settings"
},
"common": {
"appName": "AliasVault",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"cancel": "Cancel",
"use": "Use",
"delete": "Delete",
"close": "Close",
"copied": "Copied!",
"openInNewWindow": "Open in new window",
"language": "Language",
"enabled": "Enabled",
"disabled": "Disabled",
"showPassword": "Show password",
"hidePassword": "Hide password",
"copyToClipboard": "Copy to clipboard",
"loadingEmails": "Loading emails...",
"loadingTotpCodes": "Loading TOTP codes...",
"attachments": "Attachments",
"loadingAttachments": "Loading attachments...",
"settings": "Settings",
"recentEmails": "Recent emails",
"loginCredentials": "Login credentials",
"twoFactorAuthentication": "Two-factor authentication",
"alias": "Alias",
"notes": "Notes",
"fullName": "Full Name",
"firstName": "First Name",
"lastName": "Last Name",
"birthDate": "Birth Date",
"nickname": "Nickname",
"email": "Email",
"username": "Username",
"password": "Password",
"syncingVault": "Syncing vault",
"savingChangesToVault": "Saving changes to vault",
"uploadingVaultToServer": "Uploading vault to server",
"checkingVaultUpdates": "Checking for vault updates",
"syncingUpdatedVault": "Syncing updated vault",
"executingOperation": "Executing operation...",
"loadMore": "Voir plus",
"errors": {
"VaultOutdated": "Your vault is outdated. Please login on the AliasVault website and follow the steps.",
"serverNotAvailable": "The AliasVault server is not available. Please try again later or contact support if the problem persists.",
"clientVersionNotSupported": "This version of the AliasVault browser extension is not supported by the server anymore. Please update your browser extension to the latest version.",
"serverVersionNotSupported": "The AliasVault server needs to be updated to a newer version in order to use this browser extension. Please contact support if you need help.",
"unknownError": "An unknown error occurred",
"failedToStoreVault": "Failed to store vault",
"vaultNotAvailable": "Vault not available",
"failedToRetrieveData": "Failed to retrieve data",
"vaultIsLocked": "Vault is locked",
"failedToUploadVault": "Failed to upload vault",
"passwordChanged": "Your password has changed since the last time you logged in. Please login again for security reasons."
},
"apiErrors": {
"UNKNOWN_ERROR": "An unknown error occurred. Please try again.",
"ACCOUNT_LOCKED": "Account temporarily locked due to too many failed attempts. Please try again later.",
"ACCOUNT_BLOCKED": "Your account has been disabled. If you believe this is a mistake, please contact support.",
"USER_NOT_FOUND": "Invalid username or password. Please try again.",
"INVALID_AUTHENTICATOR_CODE": "Invalid authenticator code. Please try again.",
"INVALID_RECOVERY_CODE": "Invalid recovery code. Please try again.",
"REFRESH_TOKEN_REQUIRED": "Refresh token is required.",
"INVALID_REFRESH_TOKEN": "Invalid refresh token.",
"REFRESH_TOKEN_REVOKED_SUCCESSFULLY": "Refresh token revoked successfully.",
"PUBLIC_REGISTRATION_DISABLED": "New account registration is currently disabled on this server. Please contact the administrator.",
"USERNAME_REQUIRED": "Username is required.",
"USERNAME_ALREADY_IN_USE": "Username is already in use.",
"USERNAME_AVAILABLE": "Username is available.",
"USERNAME_MISMATCH": "Username does not match the current user.",
"PASSWORD_MISMATCH": "The provided password does not match your current password.",
"ACCOUNT_SUCCESSFULLY_DELETED": "Account successfully deleted.",
"USERNAME_EMPTY_OR_WHITESPACE": "Username cannot be empty or whitespace.",
"USERNAME_TOO_SHORT": "Username too short: must be at least 3 characters long.",
"USERNAME_TOO_LONG": "Username too long: cannot be longer than 40 characters.",
"USERNAME_INVALID_EMAIL": "Invalid email address.",
"USERNAME_INVALID_CHARACTERS": "Username is invalid, can only contain letters or digits.",
"VAULT_NOT_UP_TO_DATE": "Your vault is not up-to-date. Please synchronize your vault and try again.",
"INTERNAL_SERVER_ERROR": "Internal server error.",
"VAULT_ERROR": "The local vault is not up-to-date. Please synchronize your vault by refreshing the page and try again."
}
},
"content": {
"or": "or",
"new": "New",
"cancel": "Cancel",
"search": "Search",
"vaultLocked": "AliasVault is locked.",
"creatingNewAlias": "Creating new alias...",
"noMatchesFound": "No matches found",
"searchVault": "Search vault...",
"serviceName": "Service name",
"email": "Email",
"username": "Username",
"password": "Password",
"enterServiceName": "Enter service name",
"enterEmailAddress": "Enter email address",
"enterUsername": "Enter username",
"hideFor1Hour": "Hide for 1 hour (current site)",
"hidePermanently": "Hide permanently (current site)",
"createRandomAlias": "Create random alias",
"createUsernamePassword": "Create username/password",
"randomAlias": "Random alias",
"usernamePassword": "Username/password",
"createAndSaveAlias": "Create and save alias",
"createAndSaveCredential": "Create and save credential",
"randomIdentityDescription": "Generate a random identity with a random email address accessible in AliasVault.",
"randomIdentityDescriptionDropdown": "Random identity with random email",
"manualCredentialDescription": "Specify your own email address and username.",
"manualCredentialDescriptionDropdown": "Manual username and password",
"failedToCreateIdentity": "Failed to create identity. Please try again.",
"enterEmailAndOrUsername": "Enter email and/or username",
"autofillWithAliasVault": "Autofill with AliasVault",
"generateRandomPassword": "Generate random password (copy to clipboard)",
"generateNewPassword": "Generate new password",
"togglePasswordVisibility": "Toggle password visibility",
"passwordCopiedToClipboard": "Password copied to clipboard",
"enterEmailAndOrUsernameError": "Enter email and/or username",
"openAliasVaultToUpgrade": "Open AliasVault to upgrade",
"vaultUpgradeRequired": "Vault upgrade required.",
"dismissPopup": "Dismiss popup"
},
"credentials": {
"title": "Credentials",
"addCredential": "Add Credential",
"editCredential": "Edit Credential",
"deleteCredential": "Delete Credential",
"credentialDetails": "Credential Details",
"serviceName": "Service Name",
"serviceNamePlaceholder": "e.g., Gmail, Facebook, Bank",
"website": "Website",
"websitePlaceholder": "https://example.com",
"username": "Username",
"usernamePlaceholder": "Enter username",
"password": "Password",
"passwordPlaceholder": "Enter password",
"generatePassword": "Generate Password",
"copyPassword": "Copy Password",
"showPassword": "Show Password",
"hidePassword": "Hide Password",
"notes": "Notes",
"notesPlaceholder": "Additional notes...",
"totp": "Two-Factor Authentication",
"totpCode": "TOTP Code",
"copyTotp": "Copy TOTP",
"totpSecret": "TOTP Secret",
"totpSecretPlaceholder": "Enter TOTP secret key",
"noCredentials": "No credentials found",
"noCredentialsDescription": "Add your first credential to get started",
"searchPlaceholder": "Search credentials...",
"welcomeTitle": "Welcome to AliasVault!",
"welcomeDescription": "To use the AliasVault browser extension: navigate to a website and use the AliasVault autofill popup to create a new credential.",
"createdAt": "Created",
"updatedAt": "Last updated",
"autofill": "Autofill",
"fillForm": "Fill Form",
"deleteConfirm": "Are you sure you want to delete this credential?",
"saveSuccess": "Credential saved successfully",
"tags": "Tags",
"addTag": "Add Tag",
"removeTag": "Remove Tag",
"folder": "Folder",
"selectFolder": "Select Folder",
"createFolder": "Create Folder",
"saveCredential": "Save credential",
"deleteCredentialTitle": "Delete Credential",
"deleteCredentialConfirm": "Are you sure you want to delete this credential? This action cannot be undone.",
"randomAlias": "Random Alias",
"manual": "Manual",
"service": "Service",
"serviceUrl": "Service URL",
"loginCredentials": "Login Credentials",
"generateRandomUsername": "Generate random username",
"generateRandomPassword": "Generate random password",
"changePasswordComplexity": "Change password complexity",
"passwordLength": "Password length",
"includeLowercase": "Include lowercase letters",
"includeUppercase": "Include uppercase letters",
"includeNumbers": "Include numbers",
"includeSpecialChars": "Include special characters",
"avoidAmbiguousChars": "Avoid ambiguous characters (o, 0, etc.)",
"generateNewPreview": "Generate new preview",
"generateRandomAlias": "Generate Random Alias",
"alias": "Alias",
"firstName": "First Name",
"lastName": "Last Name",
"nickName": "Nick Name",
"gender": "Gender",
"birthDate": "Birth Date",
"birthDatePlaceholder": "YYYY-MM-DD",
"metadata": "Metadata",
"validation": {
"required": "This field is required",
"serviceNameRequired": "Service name is required",
"invalidEmail": "Invalid email format",
"invalidDateFormat": "Date must be in YYYY-MM-DD format"
},
"privateEmailTitle": "Private Email",
"privateEmailAliasVaultServer": "AliasVault server",
"privateEmailDescription": "E2E encrypted, fully private.",
"publicEmailTitle": "Public Temp Email Providers",
"publicEmailDescription": "Anonymous but limited privacy. Email content is readable by anyone that knows the address.",
"useDomainChooser": "Use domain chooser",
"enterCustomDomain": "Enter custom domain",
"enterFullEmail": "Enter full email address",
"enterEmailPrefix": "Enter email prefix"
},
"emails": {
"title": "Emails",
"deleteEmailTitle": "Delete Email",
"deleteEmailConfirm": "Are you sure you want to permanently delete this email?",
"from": "From",
"to": "To",
"date": "Date",
"emailContent": "Email content",
"attachments": "Attachments",
"emailNotFound": "Email not found",
"noEmails": "No emails found",
"noEmailsDescription": "You have not received any emails at your private email addresses yet. When you receive a new email, it will appear here.",
"dateFormat": {
"justNow": "just now",
"minutesAgo_single": "{{count}} min ago",
"minutesAgo_plural": "{{count}} mins ago",
"hoursAgo_single": "{{count}} hr ago",
"hoursAgo_plural": "{{count}} hrs ago",
"yesterday": "yesterday"
},
"errors": {
"emailLoadError": "An error occurred while loading emails. Please try again later.",
"emailUnexpectedError": "An unexpected error occurred while loading emails. Please try again later."
},
"apiErrors": {
"CLAIM_DOES_NOT_MATCH_USER": "The current chosen email address is already in use. Please change the email address by editing this credential.",
"CLAIM_DOES_NOT_EXIST": "An error occurred while trying to load the emails. Please try to edit and save the credential entry to synchronize the database, then try again."
}
},
"settings": {
"title": "Settings",
"serverUrl": "Server URL",
"language": "Language",
"autofillEnabled": "Enable Autofill",
"version": "Version",
"openInNewWindow": "Open in new window",
"openWebApp": "Open web app",
"loggedIn": "Logged in",
"logout": "Logout",
"globalSettings": "Global Settings",
"autofillPopup": "Autofill popup",
"activeOnAllSites": "Active on all sites (unless disabled below)",
"disabledOnAllSites": "Disabled on all sites",
"enabled": "Enabled",
"disabled": "Disabled",
"rightClickContextMenu": "Right-click context menu",
"autofillMatching": "Autofill Matching",
"autofillMatchingMode": "Autofill matching mode",
"autofillMatchingModeDescription": "Determines which credentials are considered a match and shown as suggestions in the autofill popup for a given website.",
"autofillMatchingDefault": "URL + subdomain + name wildcard",
"autofillMatchingUrlSubdomain": "URL + subdomain",
"autofillMatchingUrlExact": "Exact URL domain only",
"siteSpecificSettings": "Site-Specific Settings",
"autofillPopupOn": "Autofill popup on: ",
"enabledForThisSite": "Enabled for this site",
"disabledForThisSite": "Disabled for this site",
"temporarilyDisabledUntil": "Temporarily disabled until ",
"resetAllSiteSettings": "Reset all site-specific settings",
"appearance": "Appearance",
"theme": "Theme",
"useDefault": "Use default",
"light": "Light",
"dark": "Dark",
"keyboardShortcuts": "Keyboard Shortcuts",
"configureKeyboardShortcuts": "Configure keyboard shortcuts",
"configure": "Configure",
"security": "Security",
"clipboardClearTimeout": "Clear clipboard after copying",
"clipboardClearTimeoutDescription": "Automatically clear the clipboard after copying sensitive data",
"clipboardClearDisabled": "Never clear",
"clipboardClear5Seconds": "Clear after 5 seconds",
"clipboardClear10Seconds": "Clear after 10 seconds",
"clipboardClear15Seconds": "Clear after 15 seconds",
"autoLockTimeout": "Auto-lock Timeout",
"autoLockTimeoutDescription": "Automatically lock the vault after a period of inactivity",
"autoLockTimeoutHelp": "The vault will only lock after the specified period of inactivity (no autofill usage or extension popup opened). The vault will always lock when the browser is closed, regardless of this setting.",
"autoLockNever": "Never",
"autoLock15Seconds": "15 seconds",
"autoLock1Minute": "1 minute",
"autoLock5Minutes": "5 minutes",
"autoLock15Minutes": "15 minutes",
"autoLock30Minutes": "30 minutes",
"autoLock1Hour": "1 hour",
"autoLock4Hours": "4 hours",
"autoLock8Hours": "8 hours",
"autoLock24Hours": "24 hours",
"versionPrefix": "Version ",
"preferences": "Preferences",
"autofillSettings": "Autofill Settings",
"clipboardSettings": "Clipboard Settings",
"contextMenuSettings": "Context Menu Settings",
"contextMenu": "Context Menu",
"contextMenuEnabled": "Context menu is enabled",
"contextMenuDisabled": "Context menu is disabled",
"contextMenuDescription": "Right-click on input fields to access AliasVault options",
"selectLanguage": "Select Language",
"validation": {
"apiUrlRequired": "API URL is required",
"apiUrlInvalid": "Please enter a valid API URL",
"clientUrlRequired": "Client URL is required",
"clientUrlInvalid": "Please enter a valid client URL"
}
},
"upgrade": {
"title": "Upgrade Vault",
"subtitle": "AliasVault has updated and your vault needs to be upgraded. This should only take a few seconds.",
"versionInformation": "Version Information",
"yourVault": "Your vault:",
"newVersion": "New version:",
"upgrade": "Upgrade Vault",
"upgrading": "Upgrading...",
"logout": "Logout",
"whatsNew": "What's New",
"whatsNewDescription": "An upgrade is required to support the following changes:",
"noDescriptionAvailable": "No description available for this version.",
"okay": "Ok",
"status": {
"preparingUpgrade": "Preparing upgrade...",
"vaultAlreadyUpToDate": "Vault is already up to date",
"startingDatabaseTransaction": "Starting database transaction...",
"applyingDatabaseMigrations": "Applying database migrations...",
"applyingMigration": "Applying migration {{current}} of {{total}}...",
"committingChanges": "Committing changes..."
},
"alerts": {
"error": "Error",
"unableToGetVersionInfo": "Unable to get version information. Please try again.",
"selfHostedServer": "Self-Hosted Server",
"selfHostedWarning": "If you're using a self-hosted server, make sure to also update your self-hosted instance as otherwise logging in to the web client will stop working.",
"cancel": "Cancel",
"continueUpgrade": "Continue Upgrade",
"upgradeFailed": "Upgrade Failed",
"failedToApplyMigration": "Failed to apply migration ({{current}} of {{total}})",
"unknownErrorDuringUpgrade": "An unknown error occurred during the upgrade. Please try again."
}
}
}

View File

@@ -0,0 +1,392 @@
{
"auth": {
"loginTitle": "Accedi ad AliasVaultriem",
"username": "Nome utente o mail",
"usernamePlaceholder": "nome / nome@azienda.com",
"password": "Password",
"passwordPlaceholder": "Inserisci la tua password",
"rememberMe": "Ricordati di me",
"loginButton": "Accedi",
"noAccount": "Non hai ancora un account?",
"createVault": "Crea una nuova cassaforte",
"twoFactorTitle": "Inserisci il codice di autenticazione dalla tua app di autenticazione.",
"authCode": "Codice di Autenticazione",
"authCodePlaceholder": "Inserisci il codice a 6 cifre",
"verify": "Verifica",
"cancel": "Annulla",
"twoFactorNote": "Nota: se non hai accesso al tuo dispositivo di autenticazione, puoi reimpostare il tuo 2FA con un codice di recupero accedendo tramite il sito web.",
"masterPassword": "Password principale",
"unlockVault": "Sblocca Cassaforte",
"unlockTitle": "Sblocca la tua cassaforte",
"unlockDescription": "Inserisci la tua password principale per sbloccare la tua cassaforte.",
"logout": "Disconnetti",
"logoutConfirm": "Sei sicuro di volerti disconnettere?",
"sessionExpired": "La sessione è scaduta. Effettua di nuovo il login.",
"unlockSuccess": "Cassaforte sbloccata con successo!",
"unlockSuccessTitle": "La cassaforte è stata sbloccata con successo",
"unlockSuccessDescription": "Ora puoi usare l'auto-riempimento nei moduli di accesso nel tuo browser.",
"closePopup": "Chiudi questo popup",
"browseVault": "Sfoglia i contenuti della cassaforte",
"connectingTo": "Connessione a",
"switchAccounts": "Cambia account",
"loggedIn": "Accesso effettuato",
"errors": {
"invalidCode": "Inserisci un codice di autenticazione a 6 cifre valido.",
"serverError": "Impossibile connettersi al server di AliasVault. Riprova più tardi o contatta il supporto se il problema persiste.",
"noToken": "Accesso fallito — nessun token ricevuto",
"migrationError": "Si è verificato un errore nel controllo delle migrazioni pendenti.",
"wrongPassword": "Password non corretta. Riprova nuovamente.",
"accountLocked": "Account temporaneamente bloccato a causa di troppi tentativi falliti.",
"networkError": "Errore di rete: Controlla la tua connessione e riprova.",
"loginDataMissing": "Sessione di accesso scaduta. Effettua nuovamente l'accesso."
}
},
"menu": {
"credentials": "Credenziali",
"emails": "E-Mail",
"settings": "Impostazioni"
},
"common": {
"appName": "AliasVault",
"loading": "Caricamento in corso...",
"error": "Errore",
"success": "Riuscito",
"cancel": "Annulla",
"use": "Usa",
"delete": "Elimina",
"close": "Chiudi",
"copied": "Copiato!",
"openInNewWindow": "Apri in una nuova finestra",
"language": "Lingua",
"enabled": "Abilitato",
"disabled": "Disabilitato",
"showPassword": "Mostra password",
"hidePassword": "Nascondi password",
"copyToClipboard": "Copia negli appunti",
"loadingEmails": "Caricamento e-mail in corso...",
"loadingTotpCodes": "Caricamento codici TOTP in corso...",
"attachments": "Allegati",
"loadingAttachments": "Caricamento allegati in corso...",
"settings": "Impostazioni",
"recentEmails": "E-mail recenti",
"loginCredentials": "Credenziali di accesso",
"twoFactorAuthentication": "Autenticazione a due fattori",
"alias": "Alias",
"notes": "Note",
"fullName": "Nome completo",
"firstName": "Nome",
"lastName": "Cognome",
"birthDate": "Data di nascita",
"nickname": "Soprannome",
"email": "E-mail",
"username": "Nome utente",
"password": "Password",
"syncingVault": "Sincronizzazione cassaforte",
"savingChangesToVault": "Salvataggio modifiche cassaforte",
"uploadingVaultToServer": "Caricamento cassaforte sul server",
"checkingVaultUpdates": "Controllo aggiornamenti cassaforte",
"syncingUpdatedVault": "Sincronizzazione cassaforte aggiornata",
"executingOperation": "Esecuzione operazione...",
"loadMore": "Carica altro",
"errors": {
"VaultOutdated": "La tua cassaforte è obsoleta. Per favore accedi al sito di AliasVault e segui le istruzioni.",
"serverNotAvailable": "Il server di AliasVault non è disponibile. Riprova più tardi o contatta il supporto se il problema persiste.",
"clientVersionNotSupported": "Questa versione dell'estensione del browser AliasVault non è più supportata dal server. Aggiorna l'estensione alla versione più recente.",
"serverVersionNotSupported": "Il server di AliasVault necessita un aggiornamento a una versione più recente per poter usare questa estensione. Contatta il supporto se hai bisogno di assistenza.",
"unknownError": "Si è verificato un errore sconosciuto",
"failedToStoreVault": "Salvataggio cassaforte non riuscito",
"vaultNotAvailable": "Cassaforte non disponibile",
"failedToRetrieveData": "Recupero dati non riuscito",
"vaultIsLocked": "La cassaforte è bloccata",
"failedToUploadVault": "Caricare della cassaforte non riuscito.",
"passwordChanged": "La tua password è cambiata dall'ultima volta che hai effettuato l'accesso. Effettua nuovamente l'accesso per motivi di sicurezza."
},
"apiErrors": {
"UNKNOWN_ERROR": "Si è verificato un errore sconosciuto. Riprova.",
"ACCOUNT_LOCKED": "Account temporaneamente bloccato a causa di troppi tentativi falliti. Riprova più tardi.",
"ACCOUNT_BLOCKED": "Il tuo account è stato disabilitato. Se ritieni che sia un errore, contatta il supporto.",
"USER_NOT_FOUND": "Nome utente o password non validi. Riprova.",
"INVALID_AUTHENTICATOR_CODE": "Codice di autenticazione non valido. Riprova.",
"INVALID_RECOVERY_CODE": "Codice di recupero non valido. Riprova.",
"REFRESH_TOKEN_REQUIRED": "È necessario aggiornare il token.",
"INVALID_REFRESH_TOKEN": "Token di aggiornamento non valido",
"REFRESH_TOKEN_REVOKED_SUCCESSFULLY": "Aggiornamento token revocato con successo.",
"PUBLIC_REGISTRATION_DISABLED": "La registrazione di nuovi account è attualmente disabilitata su questo server. Contatta l'amministratore.",
"USERNAME_REQUIRED": "È richiesto il nome utente.",
"USERNAME_ALREADY_IN_USE": "Il nome utente è già in uso.",
"USERNAME_AVAILABLE": "Il nome utente è disponibile.",
"USERNAME_MISMATCH": "Il nome utente non corrisponde all'utente corrente.",
"PASSWORD_MISMATCH": "La password fornita non corrisponde alla password attuale.",
"ACCOUNT_SUCCESSFULLY_DELETED": "Account eliminato con successo.",
"USERNAME_EMPTY_OR_WHITESPACE": "Il nome utente non può essere vuoto o contenere spazi.",
"USERNAME_TOO_SHORT": "Nome utente troppo corto: deve contenere almeno 3 caratteri.",
"USERNAME_TOO_LONG": "Nome utente troppo lungo: non può superare i 40 caratteri.",
"USERNAME_INVALID_EMAIL": "Indirizzo email non valido.",
"USERNAME_INVALID_CHARACTERS": "Il nome utente non è valido, può contenere solo lettere o cifre.",
"VAULT_NOT_UP_TO_DATE": "La tua cassaforte non è aggiornata. Sincronizzala e riprova.",
"INTERNAL_SERVER_ERROR": "Errore interno del server.",
"VAULT_ERROR": "La cassaforte locale non è aggiornata. Sincronizzala ricaricando la pagina e riprova."
}
},
"content": {
"or": "o",
"new": "Nuovo",
"cancel": "Annulla",
"search": "Cerca",
"vaultLocked": "AliasVault è bloccato.",
"creatingNewAlias": "Creazione nuovo alias...",
"noMatchesFound": "Nessun risultato trovato",
"searchVault": "Cerca nella cassaforte...",
"serviceName": "Nome servizio",
"email": "E-mail",
"username": "Nome utente",
"password": "Password",
"enterServiceName": "Inserisci nome servizio",
"enterEmailAddress": "Inserisci indirizzo email",
"enterUsername": "Inserisci nome utente",
"hideFor1Hour": "Nascondi per 1 ora (sito corrente)",
"hidePermanently": "Nascondi permanentemente (sito corrente)",
"createRandomAlias": "Crea alias casuale",
"createUsernamePassword": "Crea nome utente/password",
"randomAlias": "Alias casuale",
"usernamePassword": "Nome utente/password",
"createAndSaveAlias": "Crea e salva alias",
"createAndSaveCredential": "Crea e salva credenziali",
"randomIdentityDescription": "Genera un'identità casuale con un indirizzo email casuale accessibile in AliasVault.",
"randomIdentityDescriptionDropdown": "Identità casuale con email casuale",
"manualCredentialDescription": "Specifica il tuo indirizzo email e nome utente.",
"manualCredentialDescriptionDropdown": "Nome utente e password manuali",
"failedToCreateIdentity": "Impossibile creare identità. Riprova.",
"enterEmailAndOrUsername": "Inserisci email e/o nome utente",
"autofillWithAliasVault": "Compilazione automatica con AliasVault",
"generateRandomPassword": "Genera password casuale (copia negli appunti)",
"generateNewPassword": "Genera nuova password",
"togglePasswordVisibility": "Mostra/Nascondi password",
"passwordCopiedToClipboard": "Password copiata negli appunti",
"enterEmailAndOrUsernameError": "Inserisci email e/o nome utente",
"openAliasVaultToUpgrade": "Apri AliasVault per aggiornare",
"vaultUpgradeRequired": "Aggiornamento della cassaforte richiesto.",
"dismissPopup": "Chiudi finestra"
},
"credentials": {
"title": "Credenziali",
"addCredential": "Aggiungi credenziali",
"editCredential": "Modifica credenziali",
"deleteCredential": "Elimina credenziali",
"credentialDetails": "Dettagli credenziali",
"serviceName": "Nome servizio",
"serviceNamePlaceholder": "es. Gmail, Facebook, Banca",
"website": "Sito web",
"websitePlaceholder": "https://esempio.com",
"username": "Nome utente",
"usernamePlaceholder": "Inserisci nome utente",
"password": "Password",
"passwordPlaceholder": "Inserisci password",
"generatePassword": "Genera password",
"copyPassword": "Copia password",
"showPassword": "Mostra password",
"hidePassword": "Nascondi password",
"notes": "Note",
"notesPlaceholder": "Note aggiuntive...",
"totp": "Autenticazione a due fattori",
"totpCode": "Codice TOTP",
"copyTotp": "Copia TOTP",
"totpSecret": "Segreto TOTP",
"totpSecretPlaceholder": "Inserisci chiave segreta TOTP",
"noCredentials": "Credenziali non trovate",
"noCredentialsDescription": "Aggiungi le tue prime credenziali per iniziare",
"searchPlaceholder": "Cerca credenziali...",
"welcomeTitle": "Benvenuto in AliasVault!",
"welcomeDescription": "Per usare l'estensione browser AliasVault: naviga su un sito e usa la finestra di compilazione automatica per creare una nuova credenziale.",
"createdAt": "Creato",
"updatedAt": "Ultimo aggiornamento",
"autofill": "Compilazione automatica",
"fillForm": "Compila modulo",
"deleteConfirm": "Sei sicuro di voler eliminare questa credenziale?",
"saveSuccess": "Credenziali salvate con successo",
"tags": "Tag",
"addTag": "Aggiungi tag",
"removeTag": "Rimuovi tag",
"folder": "Cartella",
"selectFolder": "Seleziona cartella",
"createFolder": "Crea cartella",
"saveCredential": "Salva credenziale",
"deleteCredentialTitle": "Elimina credenziale",
"deleteCredentialConfirm": "Sei sicuro di voler eliminare queste credenziali? Questa azione non può essere annullata.",
"randomAlias": "Alias casuale",
"manual": "Manuale",
"service": "Servizio",
"serviceUrl": "URL del servizio",
"loginCredentials": "Credenziali di accesso",
"generateRandomUsername": "Genera nome utente casuale",
"generateRandomPassword": "Genera password casuale",
"changePasswordComplexity": "Modifica complessità password",
"passwordLength": "Lunghezza password",
"includeLowercase": "Includi lettere minuscole",
"includeUppercase": "Includi lettere maiuscole",
"includeNumbers": "Includi numeri",
"includeSpecialChars": "Includi caratteri speciali",
"avoidAmbiguousChars": "Evita caratteri ambigui (o, 0, ecc.)",
"generateNewPreview": "Genera nuova anteprima",
"generateRandomAlias": "Genera alias casuale",
"alias": "Alias",
"firstName": "Nome",
"lastName": "Cognome",
"nickName": "Soprannome",
"gender": "Genere",
"birthDate": "Data di nascita",
"birthDatePlaceholder": "AAAA-MM-GG",
"metadata": "Metadati",
"validation": {
"required": "Questo campo è obbligatorio",
"serviceNameRequired": "Il nome del servizio è obbligatorio",
"invalidEmail": "Formato email non valido",
"invalidDateFormat": "La data deve essere nel formato AAAA-MM-GG"
},
"privateEmailTitle": "Email privata",
"privateEmailAliasVaultServer": "Server AliasVault",
"privateEmailDescription": "E2E crittografato, completamente privato.",
"publicEmailTitle": "Fornitori Pubblici di Email Temporanee",
"publicEmailDescription": "Anonimi ma con privacy ridotta. Accessibile a chiunque conosca l'indirizzo.",
"useDomainChooser": "Usa selettore di dominio",
"enterCustomDomain": "Inserisci un dominio personalizzato",
"enterFullEmail": "Inserisci l'indirizzo email completo",
"enterEmailPrefix": "Inserisci prefisso email"
},
"emails": {
"title": "Email",
"deleteEmailTitle": "Elimina Email",
"deleteEmailConfirm": "Sei sicuro di voler eliminare definitivamente questa email?",
"from": "Da",
"to": "A",
"date": "Data",
"emailContent": "Contenuto email",
"attachments": "Allegati",
"emailNotFound": "Email non trovata",
"noEmails": "Nessuna email trovata",
"noEmailsDescription": "Non hai ancora ricevuto email ai tuoi indirizzi email privati. Quando ne riceverai una nuova, apparirà qui.",
"dateFormat": {
"justNow": "proprio ora",
"minutesAgo_single": "{{count}} min fa",
"minutesAgo_plural": "{{count}} min fa",
"hoursAgo_single": "{{count}} ora fa",
"hoursAgo_plural": "{{count}} ore fa",
"yesterday": "ieri"
},
"errors": {
"emailLoadError": "Si è verificato un errore durante il caricamento delle email. Riprova più tardi.",
"emailUnexpectedError": "Si è verificato un errore imprevisto durante il caricamento delle email. Riprova più tardi."
},
"apiErrors": {
"CLAIM_DOES_NOT_MATCH_USER": "L'indirizzo email attualmente scelto è già in uso. Cambia l'indirizzo modificando queste credenziali.",
"CLAIM_DOES_NOT_EXIST": "Si è verificato un errore durante il caricamento delle email. Prova a modificare e salvare le credenziali per sincronizzare il database, poi riprova."
}
},
"settings": {
"title": "Impostazioni",
"serverUrl": "URL del server",
"language": "Lingua",
"autofillEnabled": "Abilita compilazione automatica",
"version": "Versione",
"openInNewWindow": "Apri in una nuova finestra",
"openWebApp": "Apri app web",
"loggedIn": "Accesso effettuato",
"logout": "Disconnetti",
"globalSettings": "Impostazioni globali",
"autofillPopup": "Popup compilazione automatica",
"activeOnAllSites": "Attivo su tutti i siti (a meno che non sia disabilitato sotto)",
"disabledOnAllSites": "Disabilitato su tutti i siti",
"enabled": "Abilitato",
"disabled": "Disabilitato",
"rightClickContextMenu": "Menu contestuale clic destro",
"autofillMatching": "Riconoscimento campi automatica.",
"autofillMatchingMode": "Modalità riconoscimento capi automatica",
"autofillMatchingModeDescription": "Determina quali credenziali vengono considerate corrispondenti e mostrate come suggerimenti nel popup di compilazione automatica per un determinato sito web.",
"autofillMatchingDefault": "URL + sottodominio + nome wildcard",
"autofillMatchingUrlSubdomain": "URL + sottodominio",
"autofillMatchingUrlExact": "Solo dominio URL esatto",
"siteSpecificSettings": "Impostazioni specifiche per sito",
"autofillPopupOn": "Finestra compilazione automatica su: ",
"enabledForThisSite": "Abilitato per questo sito",
"disabledForThisSite": "Disabilitato per questo sito",
"temporarilyDisabledUntil": "Disabilitato temporaneamente fino a ",
"resetAllSiteSettings": "Reimposta tutte le impostazioni specifiche per sito",
"appearance": "Aspetto",
"theme": "Tema",
"useDefault": "Usa predefinito",
"light": "Chiaro",
"dark": "Scuro",
"keyboardShortcuts": "Scorciatoie da tastiera",
"configureKeyboardShortcuts": "Configura scorciatoie da tastiera",
"configure": "Configura",
"security": "Sicurezza",
"clipboardClearTimeout": "Cancella appunti dopo la copia",
"clipboardClearTimeoutDescription": "Cancella automaticamente gli appunti dopo aver copiato i dati sensibili",
"clipboardClearDisabled": "Non pulire mai",
"clipboardClear5Seconds": "Cancella dopo 5 secondi",
"clipboardClear10Seconds": "Cancella dopo 10 secondi",
"clipboardClear15Seconds": "Cancella dopo 15 secondi",
"autoLockTimeout": "Timeout Blocco Automatico",
"autoLockTimeoutDescription": "Blocca automaticamente la cassaforte dopo un periodo di inattività",
"autoLockTimeoutHelp": "La cassaforte si bloccherà solo dopo il periodo specificato di inattività (nessun utilizzo di riempimento automatico o estensione popup aperto). La cassaforte si bloccherà sempre quando il browser è chiuso, indipendentemente da questa impostazione.",
"autoLockNever": "Mai",
"autoLock15Seconds": "15 secondi",
"autoLock1Minute": "1 minuto",
"autoLock5Minutes": "5 minuti",
"autoLock15Minutes": "15 minuti",
"autoLock30Minutes": "30 minuti",
"autoLock1Hour": "1 ora",
"autoLock4Hours": "4 ore",
"autoLock8Hours": "8 ore",
"autoLock24Hours": "24 ore",
"versionPrefix": "Versione ",
"preferences": "Preferenze",
"autofillSettings": "Impostazioni di riempimento automatico",
"clipboardSettings": "Impostazioni appunti",
"contextMenuSettings": "Preferenze menu contestuale",
"contextMenu": "Menu contestuale",
"contextMenuEnabled": "Il menu contestuale è attivato",
"contextMenuDisabled": "Il menu contestuale è disabilitato",
"contextMenuDescription": "Click destro sui campi di input per accedere alle opzioni di AliasVault",
"selectLanguage": "Seleziona la lingua",
"validation": {
"apiUrlRequired": "L'URL API è obbligatorio",
"apiUrlInvalid": "Inserisci un URL API valido",
"clientUrlRequired": "L'URL del client è obbligatorio",
"clientUrlInvalid": "Inserisci un URL del client valido"
}
},
"upgrade": {
"title": "Aggiorna Cassaforte",
"subtitle": "AliasVault è stato aggiornato e la tua cassaforte deve essere aggiornata. Dovrebbe richiedere solo pochi secondi.",
"versionInformation": "Informazioni sulla versione",
"yourVault": "La tua cassaforte:",
"newVersion": "Nuova versione:",
"upgrade": "Aggiorna cassaforte",
"upgrading": "Aggiornamento in corso...",
"logout": "Disconnetti",
"whatsNew": "Novità",
"whatsNewDescription": "È richiesto un aggiornamento per supportare le seguenti modifiche:",
"noDescriptionAvailable": "Nessuna descrizione disponibile per questa versione.",
"okay": "Ok",
"status": {
"preparingUpgrade": "Preparazione aggiornamento...",
"vaultAlreadyUpToDate": "La cassaforte è già aggiornata",
"startingDatabaseTransaction": "Avvio transazione database...",
"applyingDatabaseMigrations": "Applicazione migrazioni database...",
"applyingMigration": "Applicazione migrazione {{current}} di {{total}}...",
"committingChanges": "Modifica in corso..."
},
"alerts": {
"error": "Errore",
"unableToGetVersionInfo": "Impossibile ottenere informazioni sulla versione. Riprova.",
"selfHostedServer": "Server Autospitato",
"selfHostedWarning": "Se usi un server autospitato, assicurati di aggiornare anche la tua istanza, altrimenti l'accesso al client web smetterà di funzionare.",
"cancel": "Annulla",
"continueUpgrade": "Continua aggiornamento",
"upgradeFailed": "Aggiornamento non riuscito",
"failedToApplyMigration": "Impossibile eseguire la migrazione ({{current}} di {{total}})",
"unknownErrorDuringUpgrade": "Si è verificato un errore sconosciuto durante l'aggiornamento. Riprova."
}
}
}

View File

@@ -0,0 +1,392 @@
{
"auth": {
"loginTitle": "Inloggen bij AliasVault",
"username": "Gebruikersnaam of e-mail",
"usernamePlaceholder": "naam / naam@bedrijf.com",
"password": "Wachtwoord",
"passwordPlaceholder": "Voer je wachtwoord in",
"rememberMe": "Onthoud mij",
"loginButton": "Inloggen",
"noAccount": "Nog geen account?",
"createVault": "Nieuwe vault aanmaken",
"twoFactorTitle": "Voer de authenticatiecode van je authenticator-app in.",
"authCode": "Authenticatiecode",
"authCodePlaceholder": "Voer 6-cijferige code in",
"verify": "Verifiëren",
"cancel": "Annuleren",
"twoFactorNote": "Opmerking: als je geen toegang hebt tot je authenticator, kunt je je 2FA resetten door met een in te loggen via de website.",
"masterPassword": "Hoofdwachtwoord",
"unlockVault": "Vault ontgrendelen",
"unlockTitle": "Ontgrendel je vault",
"unlockDescription": "Voer je hoofdwachtwoord in om je vault te ontgrendelen.",
"logout": "Uitloggen",
"logoutConfirm": "Weet je zeker dat je wilt uitloggen?",
"sessionExpired": "Je sessie is verlopen. Log opnieuw in.",
"unlockSuccess": "Vault succesvol ontgrendeld!",
"unlockSuccessTitle": "Je vault is succesvol ontgrendeld",
"unlockSuccessDescription": "Je kunt nu automatisch invullen gebruiken in inlogformulieren in je browser.",
"closePopup": "Sluit deze popup",
"browseVault": "Bekijk vault inhoud",
"connectingTo": "Verbinden met",
"switchAccounts": "Wisselen van account?",
"loggedIn": "Ingelogd",
"errors": {
"invalidCode": "Voer een geldige 6-cijferige code in.",
"serverError": "Kon de AliasVault server niet bereiken. Probeer het later opnieuw of neem contact op met support als het probleem aanhoudt.",
"noToken": "Inloggen mislukt -- geen token ontvangen",
"migrationError": "Er is een fout opgetreden bij het controleren op updates.",
"wrongPassword": "Onjuist wachtwoord. Probeer het opnieuw.",
"accountLocked": "Account tijdelijk vergrendeld vanwege te veel mislukte pogingen.",
"networkError": "Netwerkfout. Controleer de verbinding en probeer het opnieuw.",
"loginDataMissing": "Sessie verlopen. Probeer het opnieuw."
}
},
"menu": {
"credentials": "Credentials",
"emails": "E-mails",
"settings": "Instellingen"
},
"common": {
"appName": "AliasVault",
"loading": "Laden...",
"error": "Fout",
"success": "Succes",
"cancel": "Annuleren",
"use": "Gebruik",
"delete": "Verwijderen",
"close": "Sluiten",
"copied": "Gekopieerd!",
"openInNewWindow": "Openen in nieuw venster",
"language": "Taal",
"enabled": "Ingeschakeld",
"disabled": "Uitgeschakeld",
"showPassword": "Wachtwoord tonen",
"hidePassword": "Wachtwoord verbergen",
"copyToClipboard": "Naar klembord kopiëren",
"loadingEmails": "E-mails laden...",
"loadingTotpCodes": "TOTP-codes laden...",
"attachments": "Bijlagen",
"loadingAttachments": "Bijlagen laden...",
"settings": "Instellingen",
"recentEmails": "Recente e-mails",
"loginCredentials": "Inloggegevens",
"twoFactorAuthentication": "Tweestapsverificatie",
"alias": "Alias",
"notes": "Notities",
"fullName": "Volledige naam",
"firstName": "Voornaam",
"lastName": "Achternaam",
"birthDate": "Geboortedatum",
"nickname": "Bijnaam",
"email": "E-mail",
"username": "Gebruikersnaam",
"password": "Wachtwoord",
"syncingVault": "Vault synchroniseren",
"savingChangesToVault": "Wijzigingen opslaan in vault",
"uploadingVaultToServer": "Vault uploaden naar server",
"checkingVaultUpdates": "Controleren op vault updates",
"syncingUpdatedVault": "Bijgewerkte vault synchroniseren",
"executingOperation": "Actie uitvoeren...",
"loadMore": "Laad meer",
"errors": {
"VaultOutdated": "Je vault is verouderd. Log in op de AliasVault website en volg de stappen.",
"serverNotAvailable": "De AliasVault server is niet beschikbaar. Probeer het later opnieuw of neem contact op met de ondersteuning als het probleem aanhoudt.",
"clientVersionNotSupported": "Deze versie van de AliasVault browserextensie wordt niet meer ondersteund door de server. Update je browserextensie naar de nieuwste versie.",
"serverVersionNotSupported": "De AliasVault server moet worden bijgewerkt naar een nieuwere versie om deze browserextensie te kunnen gebruiken. Neem contact op met support als je hulp nodig hebt.",
"unknownError": "Er is een onbekende fout opgetreden",
"failedToStoreVault": "Vault opslaan mislukt",
"vaultNotAvailable": "Vault niet beschikbaar",
"failedToRetrieveData": "Gegevens ophalen mislukt",
"vaultIsLocked": "Vault is vergrendeld",
"failedToUploadVault": "Vault uploaden mislukt",
"passwordChanged": "Je wachtwoord is veranderd sinds de laatste keer dat je bent ingelogd. Log opnieuw in."
},
"apiErrors": {
"UNKNOWN_ERROR": "Er is een onbekende fout opgetreden. Probeer het opnieuw.",
"ACCOUNT_LOCKED": "Account tijdelijk vergrendeld vanwege te veel mislukte pogingen. Probeer het later opnieuw.",
"ACCOUNT_BLOCKED": "Je account is uitgeschakeld. Als je denkt dat dit een vergissing is, neem dan contact op met support.",
"USER_NOT_FOUND": "Gebruikersnaam of wachtwoord is onjuist. Probeer het opnieuw.",
"INVALID_AUTHENTICATOR_CODE": "Ongeldige authenticator code. Probeer het opnieuw.",
"INVALID_RECOVERY_CODE": "Ongeldige herstelcode. Probeer het opnieuw.",
"REFRESH_TOKEN_REQUIRED": "Refresh token is vereist.",
"INVALID_REFRESH_TOKEN": "Ongeldig refresh token.",
"REFRESH_TOKEN_REVOKED_SUCCESSFULLY": "Refresh token succesvol ingetrokken.",
"PUBLIC_REGISTRATION_DISABLED": "Registratie van nieuwe accounts is momenteel uitgeschakeld op deze server. Neem contact op met de beheerder.",
"USERNAME_REQUIRED": "Gebruikersnaam is vereist.",
"USERNAME_ALREADY_IN_USE": "Gebruikersnaam is al in gebruik.",
"USERNAME_AVAILABLE": "Gebruikersnaam is beschikbaar.",
"USERNAME_MISMATCH": "Gebruikersnaam komt niet overeen met de huidige gebruiker.",
"PASSWORD_MISMATCH": "Het opgegeven wachtwoord komt niet overeen met je huidige wachtwoord.",
"ACCOUNT_SUCCESSFULLY_DELETED": "Account succesvol verwijderd.",
"USERNAME_EMPTY_OR_WHITESPACE": "Gebruikersnaam mag niet leeg zijn of alleen uit spaties bestaan.",
"USERNAME_TOO_SHORT": "Gebruikersnaam te kort: moet minimaal 3 tekens lang zijn.",
"USERNAME_TOO_LONG": "Gebruikersnaam te lang: mag niet langer zijn dan 40 tekens.",
"USERNAME_INVALID_EMAIL": "Ongeldig e-mailadres.",
"USERNAME_INVALID_CHARACTERS": "Gebruikersnaam is ongeldig, mag alleen letters of cijfers bevatten.",
"VAULT_NOT_UP_TO_DATE": "Je vault is niet up-to-date. Synchroniseer je vault en probeer het opnieuw.",
"INTERNAL_SERVER_ERROR": "Interne serverfout.",
"VAULT_ERROR": "Je lokale vault is niet up-to-date. Synchroniseer je vault door de pagina te vernieuwen en probeer het opnieuw."
}
},
"content": {
"or": "of",
"new": "Nieuw",
"cancel": "Annuleren",
"search": "Zoeken",
"vaultLocked": "AliasVault is vergrendeld.",
"creatingNewAlias": "Nieuwe alias aanmaken...",
"noMatchesFound": "Geen resultaten gevonden",
"searchVault": "Vault doorzoeken...",
"serviceName": "Servicenaam",
"email": "E-mail",
"username": "Gebruikersnaam",
"password": "Wachtwoord",
"enterServiceName": "Voer servicenaam in",
"enterEmailAddress": "Voer e-mailadres in",
"enterUsername": "Voer gebruikersnaam in",
"hideFor1Hour": "Verberg voor 1 uur (huidige site)",
"hidePermanently": "Permanent verbergen (huidige site)",
"createRandomAlias": "Willekeurige alias aanmaken",
"createUsernamePassword": "Gebruikersnaam/wachtwoord aanmaken",
"randomAlias": "Alias",
"usernamePassword": "Gebruikersnaam/wachtwoord",
"createAndSaveAlias": "Alias aanmaken",
"createAndSaveCredential": "Credential aanmaken",
"randomIdentityDescription": "Genereer een willekeurige identiteit met een willekeurig e-mailadres toegankelijk in AliasVault.",
"randomIdentityDescriptionDropdown": "Willekeurige identiteit met willekeurige e-mail",
"manualCredentialDescription": "Specificeer je eigen e-mailadres en/of gebruikersnaam.",
"manualCredentialDescriptionDropdown": "Handmatige gebruikersnaam en wachtwoord",
"failedToCreateIdentity": "Identiteit aanmaken mislukt. Probeer opnieuw.",
"enterEmailAndOrUsername": "Voer e-mail en/of gebruikersnaam in",
"autofillWithAliasVault": "Autofill met AliasVault",
"generateRandomPassword": "Willekeurig wachtwoord genereren (kopiëren naar klembord)",
"generateNewPassword": "Genereer nieuw wachtwoord",
"togglePasswordVisibility": "Schakel zichtbaarheid van wachtwoord in/uit",
"passwordCopiedToClipboard": "Wachtwoord gekopieerd naar klembord",
"enterEmailAndOrUsernameError": "Voer e-mail en/of gebruikersnaam in",
"openAliasVaultToUpgrade": "Open AliasVault om te upgraden",
"vaultUpgradeRequired": "Update is vereist.",
"dismissPopup": "Pop-up sluiten"
},
"credentials": {
"title": "Credentials",
"addCredential": "Credential toevoegen",
"editCredential": "Credential bewerken",
"deleteCredential": "Credential verwijderen",
"credentialDetails": "Credential details",
"serviceName": "Naam",
"serviceNamePlaceholder": "bijv. Gmail, Facebook, Bank",
"website": "Website",
"websitePlaceholder": "https://voorbeeld.nl",
"username": "Gebruikersnaam",
"usernamePlaceholder": "Voer gebruikersnaam in",
"password": "Wachtwoord",
"passwordPlaceholder": "Voer wachtwoord in",
"generatePassword": "Wachtwoord genereren",
"copyPassword": "Wachtwoord kopiëren",
"showPassword": "Wachtwoord tonen",
"hidePassword": "Wachtwoord verbergen",
"notes": "Notities",
"notesPlaceholder": "Aanvullende notities...",
"totp": "Tweestapsverificatie",
"totpCode": "TOTP-code",
"copyTotp": "Kopiëren",
"totpSecret": "TOTP secret",
"totpSecretPlaceholder": "Voer TOTP secret in",
"noCredentials": "Geen credentials gevonden",
"noCredentialsDescription": "Voeg je eerste credentials toe om te beginnen",
"searchPlaceholder": "Credentials zoeken...",
"welcomeTitle": "Welkom bij AliasVault!",
"welcomeDescription": "Om de AliasVault browser extensie te gebruiken: navigeer naar een website en gebruik de AliasVault autofill popup om nieuwe credentials aan te maken.",
"createdAt": "Aangemaakt",
"updatedAt": "Laatst bijgewerkt",
"autofill": "Autofill",
"fillForm": "Formulier invullen",
"deleteConfirm": "Weet je zeker dat je deze credential wilt verwijderen?",
"saveSuccess": "Credential succesvol opgeslagen",
"tags": "Labels",
"addTag": "Label toevoegen",
"removeTag": "Label verwijderen",
"folder": "Map",
"selectFolder": "Map selecteren",
"createFolder": "Map aanmaken",
"saveCredential": "Credential opslaan",
"deleteCredentialTitle": "Credential verwijderen",
"deleteCredentialConfirm": "Weet je zeker dat je deze credential wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"randomAlias": "Alias",
"manual": "Handmatig",
"service": "Naam",
"serviceUrl": "URL",
"loginCredentials": "Inloggegevens",
"generateRandomUsername": "Gebruikersnaam genereren",
"generateRandomPassword": "Wachtwoord genereren",
"changePasswordComplexity": "Wijzig wachtwoord complexiteit",
"passwordLength": "Wachtwoordlengte",
"includeLowercase": "Inclusief kleine letters",
"includeUppercase": "Inclusief hoofdletters",
"includeNumbers": "Inclusief cijfers",
"includeSpecialChars": "Inclusief speciale karakters",
"avoidAmbiguousChars": "Onduidelijke tekens vermijden (o, 0, etc.)",
"generateNewPreview": "Genereer nieuw voorbeeld",
"generateRandomAlias": "Alias genereren",
"alias": "Alias",
"firstName": "Voornaam",
"lastName": "Achternaam",
"nickName": "Bijnaam",
"gender": "Geslacht",
"birthDate": "Geboortedatum",
"birthDatePlaceholder": "YYYY-MM-DD",
"metadata": "Metadata",
"validation": {
"required": "Dit veld is verplicht",
"serviceNameRequired": "Servicenaam is verplicht",
"invalidEmail": "Ongeldig e-mailformaat",
"invalidDateFormat": "Datum moet in YYYY-MM-DD formaat zijn"
},
"privateEmailTitle": "Privé e-mail",
"privateEmailAliasVaultServer": "AliasVault server",
"privateEmailDescription": "E2E versleuteld, volledig privé.",
"publicEmailTitle": "Publieke tijdelijke e-mailproviders",
"publicEmailDescription": "Anoniem maar beperkte privacy. E-mail inhoud is leesbaar door iedereen die het adres kent.",
"useDomainChooser": "Domein kiezen",
"enterCustomDomain": "Voer aangepast domein in",
"enterFullEmail": "Voer volledig e-mailadres in",
"enterEmailPrefix": "E-mailprefix invoeren"
},
"emails": {
"title": "E-mails",
"deleteEmailTitle": "E-mail verwijderen",
"deleteEmailConfirm": "Weet je zeker dat je deze e-mail definitief wilt verwijderen?",
"from": "Van",
"to": "Naar",
"date": "Datum",
"emailContent": "E-mailinhoud",
"attachments": "Bijlagen",
"emailNotFound": "E-mail niet gevonden",
"noEmails": "Geen e-mails gevonden",
"noEmailsDescription": "Je hebt nog geen e-mails ontvangen op je privé e-mailadressen. Wanneer je een nieuwe e-mail ontvangt, zal deze hier verschijnen.",
"dateFormat": {
"justNow": "zojuist",
"minutesAgo_single": "{{count}} min geleden",
"minutesAgo_plural": "{{count}} min. geleden",
"hoursAgo_single": "{{count}} uur geleden",
"hoursAgo_plural": "{{count}} uur geleden",
"yesterday": "gisteren"
},
"errors": {
"emailLoadError": "Er is een fout opgetreden bij het laden van e-mails. Probeer het later opnieuw.",
"emailUnexpectedError": "Er is een onverwachte fout opgetreden bij het laden van e-mails. Probeer het later opnieuw."
},
"apiErrors": {
"CLAIM_DOES_NOT_MATCH_USER": "Het huidige gekozen e-mailadres is al in gebruik. Wijzig het e-mailadres door deze credential te bewerken.",
"CLAIM_DOES_NOT_EXIST": "Er is een fout opgetreden bij het laden van e-mails. Probeer de credential te bewerken en op te slaan om de database te synchroniseren, en probeer het opnieuw."
}
},
"settings": {
"title": "Instellingen",
"serverUrl": "Server URL",
"language": "Taal",
"autofillEnabled": "Autofill",
"version": "Versie",
"openInNewWindow": "Openen in nieuw venster",
"openWebApp": "Web-app openen",
"loggedIn": "Ingelogd",
"logout": "Uitloggen",
"globalSettings": "Globale Instellingen",
"autofillPopup": "Autofill popup",
"activeOnAllSites": "Actief voor alle sites (tenzij hieronder uitgeschakeld)",
"disabledOnAllSites": "Uitgeschakeld op alle sites",
"enabled": "Ingeschakeld",
"disabled": "Uitgeschakeld",
"rightClickContextMenu": "Rechtermuisknop menu",
"autofillMatching": "Autofill matching",
"autofillMatchingMode": "Autofill matching modus",
"autofillMatchingModeDescription": "Bepaalt op welke manier credentials worden beschouwd als matches en worden getoond als suggestie in de autofill popup voor een bepaalde website.",
"autofillMatchingDefault": "URL + subdomein + naam wildcard",
"autofillMatchingUrlSubdomain": "URL + subdomein",
"autofillMatchingUrlExact": "Exacte URL-domein",
"siteSpecificSettings": "Site-specifieke Instellingen",
"autofillPopupOn": "Autofill popup op: ",
"enabledForThisSite": "Ingeschakeld voor deze site",
"disabledForThisSite": "Uitgeschakeld voor deze site",
"temporarilyDisabledUntil": "Tijdelijk uitgeschakeld tot ",
"resetAllSiteSettings": "Alle site-specifieke instellingen resetten",
"appearance": "Uiterlijk",
"theme": "Thema",
"useDefault": "Standaard gebruiken",
"light": "Licht",
"dark": "Donker",
"keyboardShortcuts": "Snelkoppelingen",
"configureKeyboardShortcuts": "Snelkoppelingen configureren",
"configure": "Configureren",
"security": "Beveiliging",
"clipboardClearTimeout": "Automatisch klembord wissen na kopiëren",
"clipboardClearTimeoutDescription": "Automatisch het klembord wissen na kopiëren van gevoelige gegevens",
"clipboardClearDisabled": "Nooit wissen",
"clipboardClear5Seconds": "Wis na 5 seconden",
"clipboardClear10Seconds": "Wis na 10 seconden",
"clipboardClear15Seconds": "Wis na 15 seconden",
"autoLockTimeout": "Automatisch vergrendelen",
"autoLockTimeoutDescription": "Vergrendel de vault automatisch na inactiviteit",
"autoLockTimeoutHelp": "De vault zal alleen vergrendelen na de opgegeven periode van inactiviteit (geen automatisch invullen of extensie geopend). De vault wordt altijd vergrendeld wanneer de browser wordt afgesloten, ongeacht deze instelling.",
"autoLockNever": "Nooit",
"autoLock15Seconds": "15 seconden",
"autoLock1Minute": "1 minuut",
"autoLock5Minutes": "5 minuten",
"autoLock15Minutes": "15 minuten",
"autoLock30Minutes": "30 minuten",
"autoLock1Hour": "1 uur",
"autoLock4Hours": "4 uur",
"autoLock8Hours": "8 uur",
"autoLock24Hours": "24 uur",
"versionPrefix": "Versie ",
"preferences": "Voorkeuren",
"autofillSettings": "Autofill instellingen",
"clipboardSettings": "Klembord instellingen",
"contextMenuSettings": "Context menu instellingen",
"contextMenu": "Context menu",
"contextMenuEnabled": "Context menu is ingeschakeld",
"contextMenuDisabled": "Context menu is uitgeschakeld",
"contextMenuDescription": "Klik met de rechtermuisknop op invoervelden om AliasVault opties te zien",
"selectLanguage": "Selecteer taal",
"validation": {
"apiUrlRequired": "API URL is vereist",
"apiUrlInvalid": "Voer een geldige API URL in",
"clientUrlRequired": "Client URL is vereist",
"clientUrlInvalid": "Voer een geldige client URL in"
}
},
"upgrade": {
"title": "Vault upgraden",
"subtitle": "AliasVault is vernieuwd en je vault moet worden bijgewerkt. Dit kan enkele seconden duren.",
"versionInformation": "Versie-informatie",
"yourVault": "Jouw vault:",
"newVersion": "Nieuwe versie:",
"upgrade": "Vault upgraden",
"upgrading": "Aan het upgraden...",
"logout": "Uitloggen",
"whatsNew": "Wat is er nieuw",
"whatsNewDescription": "Een upgrade is vereist vanwege de volgende wijzigingen:",
"noDescriptionAvailable": "Voor deze versie is geen beschrijving beschikbaar.",
"okay": "Ok",
"status": {
"preparingUpgrade": "Upgrade voorbereiden...",
"vaultAlreadyUpToDate": "De vault is al bijgewerkt",
"startingDatabaseTransaction": "Starten van database transactie...",
"applyingDatabaseMigrations": "Databasemigratie toepassen...",
"applyingMigration": "Toepassen van migratie {{current}} van {{total}}...",
"committingChanges": "Wijzigingen doorvoeren..."
},
"alerts": {
"error": "Fout",
"unableToGetVersionInfo": "Kan versie-informatie niet ophalen. Probeer het opnieuw.",
"selfHostedServer": "Self-hosted server",
"selfHostedWarning": "Als je een self-hosted server gebruikt, zorg er dan voor dat je ook je eigen self-hosted instantie bijwerkt, omdat anders het inloggen via de web client niet meer zal werken.",
"cancel": "Annuleren",
"continueUpgrade": "Verdergaan",
"upgradeFailed": "Upgrade mislukt",
"failedToApplyMigration": "Kon migratie niet toepassen ({{current}} van {{total}})",
"unknownErrorDuringUpgrade": "Er is een onbekende fout opgetreden tijdens de upgrade. Probeer het opnieuw."
}
}
}

View File

@@ -0,0 +1,392 @@
{
"auth": {
"loginTitle": "Войдите в AliasVault",
"username": "Имя пользователя или почта",
"usernamePlaceholder": "имя / имя@company.com",
"password": "Пароль",
"passwordPlaceholder": "Введите ваш пароль",
"rememberMe": "Запомнить меня",
"loginButton": "Логин",
"noAccount": "Нет аккаунта?",
"createVault": "Создать новое хранилище",
"twoFactorTitle": "Пожалуйста, введите код аутентификации из вашего приложения-аутентификатора.",
"authCode": "Код аутентификации",
"authCodePlaceholder": "Введите 6-значный код",
"verify": "Проверить",
"cancel": "Отменить",
"twoFactorNote": "Примечание: если у вас нет доступа к устройству аутентификации, вы можете сбросить ваш 2FA с помощью кода восстановления, войдя в систему через сайт.",
"masterPassword": "Мастер пароль",
"unlockVault": "Разблокировать хранилище",
"unlockTitle": "Разблокировать ваше хранилище",
"unlockDescription": "Введите ваш мастер пароль для разблокировки вашего хранилища.",
"logout": "Выйти",
"logoutConfirm": "Вы уверены, что хотите выйти?",
"sessionExpired": "Время сеанса истекло. Пожалуйста, войдите снова.",
"unlockSuccess": "Хранилище успешно разблокировано!",
"unlockSuccessTitle": "Ваше хранилище успешно разблокировано",
"unlockSuccessDescription": "Теперь вы можете использовать автозаполнение форм входа в Вашем браузере.",
"closePopup": "Закрыть окно",
"browseVault": "Обзор содержимого хранилища",
"connectingTo": "Подключение к",
"switchAccounts": "Переключить аккаунт?",
"loggedIn": "Вход выполнен",
"errors": {
"invalidCode": "Пожалуйста, введите правильный 6-значный код аутентификации.",
"serverError": "Не удалось подключиться к серверу AliasVault. Пожалуйста, повторите попытку позже или обратитесь в службу поддержки, если проблема не устранится.",
"noToken": "Вход не удался -- токен не возвращён",
"migrationError": "Возникла ошибка при проверке ожидающих перемещений.",
"wrongPassword": "Неверный пароль. Пожалуйста, повторите попытку.",
"accountLocked": "Аккаунт временно заблокирован из-за слишком большого числа неудачных попыток.",
"networkError": "Ошибка сети. Пожалуйста, проверьте соединение и повторите еще раз.",
"loginDataMissing": "Время входа истекло. Пожалуйста, повторите попытку."
}
},
"menu": {
"credentials": "Учетные данные",
"emails": "Почта",
"settings": "Настройки"
},
"common": {
"appName": "AliasVault",
"loading": "Загрузка...",
"error": "Ошибка",
"success": "Успешно",
"cancel": "Отмена",
"use": "Использовать",
"delete": "Удалить",
"close": "Закрыть",
"copied": "Скопировано!",
"openInNewWindow": "Открыть в новом окне",
"language": "Язык",
"enabled": "Включено",
"disabled": "Отключено",
"showPassword": "Показать пароль",
"hidePassword": "Скрыть пароль",
"copyToClipboard": "Скопировать в буфер обмена",
"loadingEmails": "Загрузка писем...",
"loadingTotpCodes": "Загрузка TOTP кодов...",
"attachments": "Вложения",
"loadingAttachments": "Загрузка вложений...",
"settings": "Настройки",
"recentEmails": "Последние письма",
"loginCredentials": "Данные для авторизации",
"twoFactorAuthentication": "Двухфакторная аутентификация",
"alias": "Псевдоним",
"notes": "Заметки",
"fullName": "Полное имя",
"firstName": "Имя",
"lastName": "Фамилия",
"birthDate": "Дата рождения",
"nickname": "Никнейм",
"email": "Электронная почта",
"username": "Имя пользователя",
"password": "Пароль",
"syncingVault": "Синхронизация хранилища",
"savingChangesToVault": "Сохранение изменений в хранилище",
"uploadingVaultToServer": "Загрузка хранилища на сервер",
"checkingVaultUpdates": "Проверка наличия обновлений в хранилище",
"syncingUpdatedVault": "Синхронизация обновленного хранилища",
"executingOperation": "Выполнение операций...",
"loadMore": "Загрузить ещё",
"errors": {
"VaultOutdated": "Ваше хранилище устарело. Пожалуйста, войдите на сайт AliasVault и следуйте инструкциям.",
"serverNotAvailable": "Сервер AliasVault недоступен. Пожалуйста, повторите попытку позже или обратитесь в службу поддержки, если проблема не устранится.",
"clientVersionNotSupported": "Эта версия браузерного расширения AliasVault больше не поддерживается сервером. Пожалуйста, обновите расширение вашего браузера до последней версии.",
"serverVersionNotSupported": "Чтобы использовать это расширение для браузера, сервер AliasVault необходимо обновить до более новой версии. Пожалуйста, обратитесь в службу поддержки, если вам нужна помощь.",
"unknownError": "Произошла неизвестная ошибка",
"failedToStoreVault": "Не удалось сохранить хранилище",
"vaultNotAvailable": "Хранилище недоступно",
"failedToRetrieveData": "Failed to retrieve data",
"vaultIsLocked": "Хранилище заблокировано",
"failedToUploadVault": "Не удалось загрузить хранилище",
"passwordChanged": "Your password has changed since the last time you logged in. Please login again for security reasons."
},
"apiErrors": {
"UNKNOWN_ERROR": "Произошла неизвестная ошибка. Пожалуйста, попробуйте снова.",
"ACCOUNT_LOCKED": "Учетная запись временно заблокирована из-за слишком большого количества неудачных попыток. Пожалуйста, повторите попытку позже.",
"ACCOUNT_BLOCKED": "Ваша учетная запись была заблокирована. Если вы считаете, что это ошибка, пожалуйста, свяжитесь со службой поддержки.",
"USER_NOT_FOUND": "Неверное имя пользователя или пароль. Пожалуйста, попробуйте снова.",
"INVALID_AUTHENTICATOR_CODE": "Неверный код аутентификатора. Пожалуйста, попробуйте снова.",
"INVALID_RECOVERY_CODE": "Неверный код восстановления. Пожалуйста, попробуйте снова.",
"REFRESH_TOKEN_REQUIRED": "Требуется токен обновления.",
"INVALID_REFRESH_TOKEN": "Недопустимый токен обновления.",
"REFRESH_TOKEN_REVOKED_SUCCESSFULLY": "Токен обновления успешно отозван.",
"PUBLIC_REGISTRATION_DISABLED": "В настоящее время регистрация новой учетной записи на этом сервере отключена. Пожалуйста, свяжитесь с администратором.",
"USERNAME_REQUIRED": "Требуется ввести имя пользователя.",
"USERNAME_ALREADY_IN_USE": "Имя пользователя уже используется.",
"USERNAME_AVAILABLE": "Имя пользователя доступно.",
"USERNAME_MISMATCH": "Имя пользователя не соответствует текущему пользователю.",
"PASSWORD_MISMATCH": "Указанный пароль не совпадает с вашим текущим паролем.",
"ACCOUNT_SUCCESSFULLY_DELETED": "Аккаунт успешно удалена.",
"USERNAME_EMPTY_OR_WHITESPACE": "Имя пользователя не может быть пустым или через пробел.",
"USERNAME_TOO_SHORT": "Слишком короткое имя пользователя: должно быть не менее 3-х символов.",
"USERNAME_TOO_LONG": "Слишком длинное имя пользователя: оно не может быть длиннее 40 символов.",
"USERNAME_INVALID_EMAIL": "Неверный адрес электронной почты.",
"USERNAME_INVALID_CHARACTERS": "Имя пользователя неверно, может содержать только буквы или цифры.",
"VAULT_NOT_UP_TO_DATE": "Ваше хранилище не обновлено. Пожалуйста, синхронизируйте ваше хранилище и повторите попытку.",
"INTERNAL_SERVER_ERROR": "Внутренняя ошибка сервера.",
"VAULT_ERROR": "Локальное хранилище не обновлено. Пожалуйста, синхронизируйте ваше хранилище, обновив страницу, и повторите попытку."
}
},
"content": {
"or": "или",
"new": "Новый",
"cancel": "отмена",
"search": "Поиск",
"vaultLocked": "AliasVault заблокирован.",
"creatingNewAlias": "Создание нового псевдонима...",
"noMatchesFound": "Совпадений не найдено",
"searchVault": "Поиск хранилища...",
"serviceName": "Имя сервиса",
"email": "Электронная почта",
"username": "Имя пользователя",
"password": "Пароль",
"enterServiceName": "Введите имя сервиса",
"enterEmailAddress": "Введите адрес электронной почты",
"enterUsername": "Введите имя пользователя",
"hideFor1Hour": "Скрыть на 1 час (текущий сайт)",
"hidePermanently": "Скрыть навсегда (текущий сайт)",
"createRandomAlias": "Создать случайный псевдоним",
"createUsernamePassword": "Создайте имя пользователя/пароль",
"randomAlias": "Случайный псевдоним",
"usernamePassword": "Имя пользователя/пароль",
"createAndSaveAlias": "Создать и сохранить псевдоним",
"createAndSaveCredential": "Создать и сохранить учетные данные",
"randomIdentityDescription": "Сгенерировать случайную личность со случайным адресом электронной почты, доступным в AliasVault.",
"randomIdentityDescriptionDropdown": "Случайная личность со случайным адресом электронной почты",
"manualCredentialDescription": "Укажите свой собственный адрес электронной почты и имя пользователя.",
"manualCredentialDescriptionDropdown": "Ручной ввод имени пользователя и пароля",
"failedToCreateIdentity": "Не удалось создать личность. Пожалуйста, попробуйте снова.",
"enterEmailAndOrUsername": "Введите адрес электронной почты и/или имя пользователя",
"autofillWithAliasVault": "Автозаполнение с помощью AliasVault",
"generateRandomPassword": "Сгенерировать случайный пароль (скопировать в буфер обмена)",
"generateNewPassword": "Сгенерировать новый пароль",
"togglePasswordVisibility": "Переключение видимости пароля",
"passwordCopiedToClipboard": "Пароль скопирован в буфер обмена",
"enterEmailAndOrUsernameError": "Введите адрес электронной почты и/или имя пользователя",
"openAliasVaultToUpgrade": "Откройте AliasVault для обновления",
"vaultUpgradeRequired": "Требуется обновление хранилища.",
"dismissPopup": "Закрыть окно"
},
"credentials": {
"title": "Учетные данные",
"addCredential": "Добавить учетные данные",
"editCredential": "Редактировать учетные данные",
"deleteCredential": "Удалить учетные данные",
"credentialDetails": "Подробности учетных данных",
"serviceName": "Название сервиса",
"serviceNamePlaceholder": "например, Gmail, Facebook, Банк",
"website": "Сайт",
"websitePlaceholder": "https://example.com",
"username": "Имя пользователя",
"usernamePlaceholder": "Введите имя пользователя",
"password": "Пароль",
"passwordPlaceholder": "Введите пароль",
"generatePassword": "Сгенерировать пароль",
"copyPassword": "Скопировать пароль",
"showPassword": "Показать пароль",
"hidePassword": "Скрыть пароль",
"notes": "Заметки",
"notesPlaceholder": "Дополнительные заметки...",
"totp": "Двухфакторная аутентификация",
"totpCode": "TOTP код",
"copyTotp": "Скопировать TOTP",
"totpSecret": "TOTP секрет",
"totpSecretPlaceholder": "Введите секретный ключ TOTP",
"noCredentials": "Учетные данные не найдены",
"noCredentialsDescription": "Добавьте свои первые учетные данные, чтобы начать работу",
"searchPlaceholder": "Поиск учетных данных...",
"welcomeTitle": "Добро пожаловать в AliasVault!",
"welcomeDescription": "Чтобы использовать браузерное расширение AliasVault: перейдите на сайт и используйте всплывающее окно автозаполнения AliasVault для создания новых учетных данных.",
"createdAt": "Создан",
"updatedAt": "Последнее обновление",
"autofill": "Автозаполнение",
"fillForm": "Заполнить форму",
"deleteConfirm": "Вы уверены, что хотите удалить эти учетные данные?",
"saveSuccess": "Учетные данные успешно сохранены",
"tags": "Теги",
"addTag": "Добавить тег",
"removeTag": "Удалить тег",
"folder": "Папка",
"selectFolder": "Выбрать папку",
"createFolder": "Создать папку",
"saveCredential": "Сохранить учетные данные",
"deleteCredentialTitle": "Удалить учетные данные",
"deleteCredentialConfirm": "Вы уверены, что хотите удалить эти учетные данные? Это действие невозможно отменить.",
"randomAlias": "Случайный псевдоним",
"manual": "Инструкция",
"service": "Сервис",
"serviceUrl": "URL сервиса",
"loginCredentials": "Учетные данные для авторизации",
"generateRandomUsername": "Сгенерировать случайное имя пользователя",
"generateRandomPassword": "Сгенерировать случайный пароль",
"changePasswordComplexity": "Изменить сложность пароля",
"passwordLength": "Длина пароля",
"includeLowercase": "Включить строчные буквы",
"includeUppercase": "Включить заглавные буквы",
"includeNumbers": "Включить числа",
"includeSpecialChars": "Включить специальные символы",
"avoidAmbiguousChars": "Избегать двусмысленных символов (o, 0 и т.д.).",
"generateNewPreview": "Создать новый предварительный просмотр",
"generateRandomAlias": "Сгенерировать случайный псевдоним",
"alias": "Псевдоним",
"firstName": "Имя",
"lastName": "Фамилия",
"nickName": "Никнейм",
"gender": "Пол",
"birthDate": "Дата рождения",
"birthDatePlaceholder": "ГГГГ-ММ-ДД",
"metadata": "Метаданные",
"validation": {
"required": "Это поле является обязательным",
"serviceNameRequired": "Требуется указать название сервиса",
"invalidEmail": "Неверный формат электронной почты",
"invalidDateFormat": "Дата должна быть указана в формате ГГГГ-ММ-ДД"
},
"privateEmailTitle": "Private Email",
"privateEmailAliasVaultServer": "AliasVault server",
"privateEmailDescription": "E2E encrypted, fully private.",
"publicEmailTitle": "Public Temp Email Providers",
"publicEmailDescription": "Anonymous but limited privacy. Email content is readable by anyone that knows the address.",
"useDomainChooser": "Use domain chooser",
"enterCustomDomain": "Enter custom domain",
"enterFullEmail": "Enter full email address",
"enterEmailPrefix": "Enter email prefix"
},
"emails": {
"title": "Письма",
"deleteEmailTitle": "Удалить письмо",
"deleteEmailConfirm": "Вы уверены, что хотите навсегда удалить это письмо?",
"from": "От",
"to": "К",
"date": "Дата",
"emailContent": "Содержимое письма",
"attachments": "Вложения",
"emailNotFound": "Адрес электронной почты не найден",
"noEmails": "Электронные письма не найдены",
"noEmailsDescription": "Вы еще не получали никаких электронных писем на свои личные адреса электронной почты. Когда вы получите новое электронное письмо, оно появится здесь.",
"dateFormat": {
"justNow": "прямо сейчас",
"minutesAgo_single": "{{count}} мин назад",
"minutesAgo_plural": "{{count}} минут назад",
"hoursAgo_single": "{{count}} часов назад",
"hoursAgo_plural": "{{count}} часов назад",
"yesterday": "вчера"
},
"errors": {
"emailLoadError": "Произошла ошибка при загрузке писем. Пожалуйста, повторите попытку позже.",
"emailUnexpectedError": "При загрузке писем произошла непредвиденная ошибка. Пожалуйста, повторите попытку позже."
},
"apiErrors": {
"CLAIM_DOES_NOT_MATCH_USER": "Текущий выбранный адрес электронной почты уже используется. Пожалуйста, измените адрес электронной почты, отредактировав эти учетные данные.",
"CLAIM_DOES_NOT_EXIST": "При попытке загрузить письма произошла ошибка. Пожалуйста, попробуйте отредактировать и сохранить данные для синхронизации базы данных, затем повторите попытку."
}
},
"settings": {
"title": "Настройки",
"serverUrl": "URL-адрес сервера",
"language": "Язык",
"autofillEnabled": "Включить автозаполнение",
"version": "Версия",
"openInNewWindow": "Открыть в новом окне",
"openWebApp": "Открыть веб-приложение",
"loggedIn": "Вход выполнен",
"logout": "Выйти",
"globalSettings": "Глобальные настройки",
"autofillPopup": "Всплывающее окно автозаполнения",
"activeOnAllSites": "Активен на всех сайтах (если не отключен ниже)",
"disabledOnAllSites": "Отключено на всех сайтах",
"enabled": "Включен",
"disabled": "Выключен",
"rightClickContextMenu": "Контекстное меню правым щелчком мыши",
"autofillMatching": "Autofill Matching",
"autofillMatchingMode": "Autofill matching mode",
"autofillMatchingModeDescription": "Determines which credentials are considered a match and shown as suggestions in the autofill popup for a given website.",
"autofillMatchingDefault": "URL + subdomain + name wildcard",
"autofillMatchingUrlSubdomain": "URL + subdomain",
"autofillMatchingUrlExact": "Exact URL domain only",
"siteSpecificSettings": "Настройки для конкретного сайта",
"autofillPopupOn": "Всплывающее окно автозаполнения: ",
"enabledForThisSite": "Включено для этого сайта",
"disabledForThisSite": "Отключено для этого сайта",
"temporarilyDisabledUntil": "Временно отключен до тех пор, пока",
"resetAllSiteSettings": "Сбросить все настройки для сайтов",
"appearance": "Внешний вид",
"theme": "Тема",
"useDefault": "Использовать по умолчанию",
"light": "Светлая",
"dark": "Темная",
"keyboardShortcuts": "Горячие клавиши",
"configureKeyboardShortcuts": "Настройка горячих клавиш",
"configure": "Настройка",
"security": "Security",
"clipboardClearTimeout": "Clear clipboard after copying",
"clipboardClearTimeoutDescription": "Automatically clear the clipboard after copying sensitive data",
"clipboardClearDisabled": "Never clear",
"clipboardClear5Seconds": "Clear after 5 seconds",
"clipboardClear10Seconds": "Clear after 10 seconds",
"clipboardClear15Seconds": "Clear after 15 seconds",
"autoLockTimeout": "Auto-lock Timeout",
"autoLockTimeoutDescription": "Automatically lock the vault after a period of inactivity",
"autoLockTimeoutHelp": "The vault will only lock after the specified period of inactivity (no autofill usage or extension popup opened). The vault will always lock when the browser is closed, regardless of this setting.",
"autoLockNever": "Never",
"autoLock15Seconds": "15 seconds",
"autoLock1Minute": "1 minute",
"autoLock5Minutes": "5 minutes",
"autoLock15Minutes": "15 minutes",
"autoLock30Minutes": "30 minutes",
"autoLock1Hour": "1 hour",
"autoLock4Hours": "4 hours",
"autoLock8Hours": "8 hours",
"autoLock24Hours": "24 hours",
"versionPrefix": "Версия ",
"preferences": "Preferences",
"autofillSettings": "Autofill Settings",
"clipboardSettings": "Clipboard Settings",
"contextMenuSettings": "Context Menu Settings",
"contextMenu": "Context Menu",
"contextMenuEnabled": "Context menu is enabled",
"contextMenuDisabled": "Context menu is disabled",
"contextMenuDescription": "Right-click on input fields to access AliasVault options",
"selectLanguage": "Select Language",
"validation": {
"apiUrlRequired": "Требуется URL-адрес API",
"apiUrlInvalid": "Пожалуйста, введите корректный URL-адрес API",
"clientUrlRequired": "Требуется URL-адрес клиента",
"clientUrlInvalid": "Пожалуйста, введите корректный URL-адрес клиента"
}
},
"upgrade": {
"title": "Обновить хранилище",
"subtitle": "AliasVault обновился, и ваше хранилище необходимо обновить. Это займет всего несколько секунд.",
"versionInformation": "Информация о версии",
"yourVault": "Ваше хранилище:",
"newVersion": "Новая версия:",
"upgrade": "Обновить хранилище",
"upgrading": "Обновление...",
"logout": "Выйти",
"whatsNew": "Что нового",
"whatsNewDescription": "Для поддержки следующих изменений требуется обновление:",
"noDescriptionAvailable": "Описание для этой версии недоступно.",
"okay": "ОК",
"status": {
"preparingUpgrade": "Подготовка обновления...",
"vaultAlreadyUpToDate": "Хранилище уже обновлено",
"startingDatabaseTransaction": "Запуск операции с базой данных...",
"applyingDatabaseMigrations": "Применение перемещения базы данных...",
"applyingMigration": "Применяя перемещение {{current}} из {{total}}...",
"committingChanges": "Фиксация изменений..."
},
"alerts": {
"error": "Ошибка",
"unableToGetVersionInfo": "Не удалось получить информацию о версии. Пожалуйста, попробуйте снова.",
"selfHostedServer": "Автономный сервер",
"selfHostedWarning": "Если вы используете автономный сервер, обязательно обновите свой автономный экземпляр, так как в противном случае вход в веб-клиент перестанет работать.",
"cancel": "Отменить",
"continueUpgrade": "Продолжить обновление",
"upgradeFailed": "Ошибка обновления",
"failedToApplyMigration": "Не удалось применить перенос ({{current}} из {{total}})",
"unknownErrorDuringUpgrade": "Во время обновления произошла неизвестная ошибка. Пожалуйста, попробуйте снова."
}
}
}

View File

@@ -0,0 +1,392 @@
{
"auth": {
"loginTitle": "Log in to AliasVault",
"username": "Username or email",
"usernamePlaceholder": "name / name@company.com",
"password": "Password",
"passwordPlaceholder": "Enter your password",
"rememberMe": "Remember me",
"loginButton": "Login",
"noAccount": "No account yet?",
"createVault": "Create new vault",
"twoFactorTitle": "Please enter the authentication code from your authenticator app.",
"authCode": "Authentication Code",
"authCodePlaceholder": "Enter 6-digit code",
"verify": "Verify",
"cancel": "Cancel",
"twoFactorNote": "Note: if you don't have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website.",
"masterPassword": "Master Password",
"unlockVault": "Unlock Vault",
"unlockTitle": "Unlock Your Vault",
"unlockDescription": "Enter your master password to unlock your vault.",
"logout": "Logout",
"logoutConfirm": "Are you sure you want to logout?",
"sessionExpired": "Your session has expired. Please log in again.",
"unlockSuccess": "Vault unlocked successfully!",
"unlockSuccessTitle": "Your vault is successfully unlocked",
"unlockSuccessDescription": "You can now use autofill in login forms in your browser.",
"closePopup": "Close this popup",
"browseVault": "Browse vault contents",
"connectingTo": "Connecting to",
"switchAccounts": "Switch accounts?",
"loggedIn": "Logged in",
"errors": {
"invalidCode": "Please enter a valid 6-digit authentication code.",
"serverError": "Could not reach AliasVault server. Please try again later or contact support if the problem persists.",
"noToken": "Login failed -- no token returned",
"migrationError": "An error occurred while checking for pending migrations.",
"wrongPassword": "Incorrect password. Please try again.",
"accountLocked": "Account temporarily locked due to too many failed attempts.",
"networkError": "Network error. Please check your connection and try again.",
"loginDataMissing": "Login session expired. Please try again."
}
},
"menu": {
"credentials": "Credentials",
"emails": "Emails",
"settings": "Settings"
},
"common": {
"appName": "AliasVault",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"cancel": "Cancel",
"use": "Use",
"delete": "Delete",
"close": "Close",
"copied": "Copied!",
"openInNewWindow": "Open in new window",
"language": "Language",
"enabled": "Enabled",
"disabled": "Disabled",
"showPassword": "Show password",
"hidePassword": "Hide password",
"copyToClipboard": "Copy to clipboard",
"loadingEmails": "Loading emails...",
"loadingTotpCodes": "Loading TOTP codes...",
"attachments": "Attachments",
"loadingAttachments": "Loading attachments...",
"settings": "Settings",
"recentEmails": "Recent emails",
"loginCredentials": "Login credentials",
"twoFactorAuthentication": "Two-factor authentication",
"alias": "Alias",
"notes": "Notes",
"fullName": "Full Name",
"firstName": "First Name",
"lastName": "Last Name",
"birthDate": "Birth Date",
"nickname": "Nickname",
"email": "Email",
"username": "Username",
"password": "Password",
"syncingVault": "Syncing vault",
"savingChangesToVault": "Saving changes to vault",
"uploadingVaultToServer": "Uploading vault to server",
"checkingVaultUpdates": "Checking for vault updates",
"syncingUpdatedVault": "Syncing updated vault",
"executingOperation": "Executing operation...",
"loadMore": "Load more",
"errors": {
"VaultOutdated": "Your vault is outdated. Please login on the AliasVault website and follow the steps.",
"serverNotAvailable": "The AliasVault server is not available. Please try again later or contact support if the problem persists.",
"clientVersionNotSupported": "This version of the AliasVault browser extension is not supported by the server anymore. Please update your browser extension to the latest version.",
"serverVersionNotSupported": "The AliasVault server needs to be updated to a newer version in order to use this browser extension. Please contact support if you need help.",
"unknownError": "An unknown error occurred",
"failedToStoreVault": "Failed to store vault",
"vaultNotAvailable": "Vault not available",
"failedToRetrieveData": "Failed to retrieve data",
"vaultIsLocked": "Vault is locked",
"failedToUploadVault": "Failed to upload vault",
"passwordChanged": "Your password has changed since the last time you logged in. Please login again for security reasons."
},
"apiErrors": {
"UNKNOWN_ERROR": "An unknown error occurred. Please try again.",
"ACCOUNT_LOCKED": "Account temporarily locked due to too many failed attempts. Please try again later.",
"ACCOUNT_BLOCKED": "Your account has been disabled. If you believe this is a mistake, please contact support.",
"USER_NOT_FOUND": "Invalid username or password. Please try again.",
"INVALID_AUTHENTICATOR_CODE": "Invalid authenticator code. Please try again.",
"INVALID_RECOVERY_CODE": "Invalid recovery code. Please try again.",
"REFRESH_TOKEN_REQUIRED": "Refresh token is required.",
"INVALID_REFRESH_TOKEN": "Invalid refresh token.",
"REFRESH_TOKEN_REVOKED_SUCCESSFULLY": "Refresh token revoked successfully.",
"PUBLIC_REGISTRATION_DISABLED": "New account registration is currently disabled on this server. Please contact the administrator.",
"USERNAME_REQUIRED": "Username is required.",
"USERNAME_ALREADY_IN_USE": "Username is already in use.",
"USERNAME_AVAILABLE": "Username is available.",
"USERNAME_MISMATCH": "Username does not match the current user.",
"PASSWORD_MISMATCH": "The provided password does not match your current password.",
"ACCOUNT_SUCCESSFULLY_DELETED": "Account successfully deleted.",
"USERNAME_EMPTY_OR_WHITESPACE": "Username cannot be empty or whitespace.",
"USERNAME_TOO_SHORT": "Username too short: must be at least 3 characters long.",
"USERNAME_TOO_LONG": "Username too long: cannot be longer than 40 characters.",
"USERNAME_INVALID_EMAIL": "Invalid email address.",
"USERNAME_INVALID_CHARACTERS": "Username is invalid, can only contain letters or digits.",
"VAULT_NOT_UP_TO_DATE": "Your vault is not up-to-date. Please synchronize your vault and try again.",
"INTERNAL_SERVER_ERROR": "Internal server error.",
"VAULT_ERROR": "The local vault is not up-to-date. Please synchronize your vault by refreshing the page and try again."
}
},
"content": {
"or": "or",
"new": "New",
"cancel": "Cancel",
"search": "Search",
"vaultLocked": "AliasVault is locked.",
"creatingNewAlias": "Creating new alias...",
"noMatchesFound": "No matches found",
"searchVault": "Search vault...",
"serviceName": "Service name",
"email": "Email",
"username": "Username",
"password": "Password",
"enterServiceName": "Enter service name",
"enterEmailAddress": "Enter email address",
"enterUsername": "Enter username",
"hideFor1Hour": "Hide for 1 hour (current site)",
"hidePermanently": "Hide permanently (current site)",
"createRandomAlias": "Create random alias",
"createUsernamePassword": "Create username/password",
"randomAlias": "Random alias",
"usernamePassword": "Username/password",
"createAndSaveAlias": "Create and save alias",
"createAndSaveCredential": "Create and save credential",
"randomIdentityDescription": "Generate a random identity with a random email address accessible in AliasVault.",
"randomIdentityDescriptionDropdown": "Random identity with random email",
"manualCredentialDescription": "Specify your own email address and username.",
"manualCredentialDescriptionDropdown": "Manual username and password",
"failedToCreateIdentity": "Failed to create identity. Please try again.",
"enterEmailAndOrUsername": "Enter email and/or username",
"autofillWithAliasVault": "Autofill with AliasVault",
"generateRandomPassword": "Generate random password (copy to clipboard)",
"generateNewPassword": "Generate new password",
"togglePasswordVisibility": "Toggle password visibility",
"passwordCopiedToClipboard": "Password copied to clipboard",
"enterEmailAndOrUsernameError": "Enter email and/or username",
"openAliasVaultToUpgrade": "Open AliasVault to upgrade",
"vaultUpgradeRequired": "Vault upgrade required.",
"dismissPopup": "Dismiss popup"
},
"credentials": {
"title": "Credentials",
"addCredential": "Add Credential",
"editCredential": "Edit Credential",
"deleteCredential": "Delete Credential",
"credentialDetails": "Credential Details",
"serviceName": "Service Name",
"serviceNamePlaceholder": "e.g., Gmail, Facebook, Bank",
"website": "Website",
"websitePlaceholder": "https://example.com",
"username": "Username",
"usernamePlaceholder": "Enter username",
"password": "Password",
"passwordPlaceholder": "Enter password",
"generatePassword": "Generate Password",
"copyPassword": "Copy Password",
"showPassword": "Show Password",
"hidePassword": "Hide Password",
"notes": "Notes",
"notesPlaceholder": "Additional notes...",
"totp": "Two-Factor Authentication",
"totpCode": "TOTP Code",
"copyTotp": "Copy TOTP",
"totpSecret": "TOTP Secret",
"totpSecretPlaceholder": "Enter TOTP secret key",
"noCredentials": "No credentials found",
"noCredentialsDescription": "Add your first credential to get started",
"searchPlaceholder": "Search credentials...",
"welcomeTitle": "Welcome to AliasVault!",
"welcomeDescription": "To use the AliasVault browser extension: navigate to a website and use the AliasVault autofill popup to create a new credential.",
"createdAt": "Created",
"updatedAt": "Last updated",
"autofill": "Autofill",
"fillForm": "Fill Form",
"deleteConfirm": "Are you sure you want to delete this credential?",
"saveSuccess": "Credential saved successfully",
"tags": "Tags",
"addTag": "Add Tag",
"removeTag": "Remove Tag",
"folder": "Folder",
"selectFolder": "Select Folder",
"createFolder": "Create Folder",
"saveCredential": "Save credential",
"deleteCredentialTitle": "Delete Credential",
"deleteCredentialConfirm": "Are you sure you want to delete this credential? This action cannot be undone.",
"randomAlias": "Random Alias",
"manual": "Manual",
"service": "Service",
"serviceUrl": "Service URL",
"loginCredentials": "Login Credentials",
"generateRandomUsername": "Generate random username",
"generateRandomPassword": "Generate random password",
"changePasswordComplexity": "Change password complexity",
"passwordLength": "Password length",
"includeLowercase": "Include lowercase letters",
"includeUppercase": "Include uppercase letters",
"includeNumbers": "Include numbers",
"includeSpecialChars": "Include special characters",
"avoidAmbiguousChars": "Avoid ambiguous characters (o, 0, etc.)",
"generateNewPreview": "Generate new preview",
"generateRandomAlias": "Generate Random Alias",
"alias": "Alias",
"firstName": "First Name",
"lastName": "Last Name",
"nickName": "Nick Name",
"gender": "Gender",
"birthDate": "Birth Date",
"birthDatePlaceholder": "YYYY-MM-DD",
"metadata": "Metadata",
"validation": {
"required": "This field is required",
"serviceNameRequired": "Service name is required",
"invalidEmail": "Invalid email format",
"invalidDateFormat": "Date must be in YYYY-MM-DD format"
},
"privateEmailTitle": "Private Email",
"privateEmailAliasVaultServer": "AliasVault server",
"privateEmailDescription": "E2E encrypted, fully private.",
"publicEmailTitle": "Public Temp Email Providers",
"publicEmailDescription": "Anonymous but limited privacy. Email content is readable by anyone that knows the address.",
"useDomainChooser": "Use domain chooser",
"enterCustomDomain": "Enter custom domain",
"enterFullEmail": "Enter full email address",
"enterEmailPrefix": "Enter email prefix"
},
"emails": {
"title": "Emails",
"deleteEmailTitle": "Delete Email",
"deleteEmailConfirm": "Are you sure you want to permanently delete this email?",
"from": "From",
"to": "To",
"date": "Date",
"emailContent": "Email content",
"attachments": "Attachments",
"emailNotFound": "Email not found",
"noEmails": "No emails found",
"noEmailsDescription": "You have not received any emails at your private email addresses yet. When you receive a new email, it will appear here.",
"dateFormat": {
"justNow": "just now",
"minutesAgo_single": "{{count}} min ago",
"minutesAgo_plural": "{{count}} mins ago",
"hoursAgo_single": "{{count}} hr ago",
"hoursAgo_plural": "{{count}} hrs ago",
"yesterday": "yesterday"
},
"errors": {
"emailLoadError": "An error occurred while loading emails. Please try again later.",
"emailUnexpectedError": "An unexpected error occurred while loading emails. Please try again later."
},
"apiErrors": {
"CLAIM_DOES_NOT_MATCH_USER": "The current chosen email address is already in use. Please change the email address by editing this credential.",
"CLAIM_DOES_NOT_EXIST": "An error occurred while trying to load the emails. Please try to edit and save the credential entry to synchronize the database, then try again."
}
},
"settings": {
"title": "Settings",
"serverUrl": "Server URL",
"language": "Language",
"autofillEnabled": "Enable Autofill",
"version": "Version",
"openInNewWindow": "Open in new window",
"openWebApp": "Open web app",
"loggedIn": "Logged in",
"logout": "Logout",
"globalSettings": "Global Settings",
"autofillPopup": "Autofill popup",
"activeOnAllSites": "Active on all sites (unless disabled below)",
"disabledOnAllSites": "Disabled on all sites",
"enabled": "Enabled",
"disabled": "Disabled",
"rightClickContextMenu": "Right-click context menu",
"autofillMatching": "Autofill Matching",
"autofillMatchingMode": "Autofill matching mode",
"autofillMatchingModeDescription": "Determines which credentials are considered a match and shown as suggestions in the autofill popup for a given website.",
"autofillMatchingDefault": "URL + subdomain + name wildcard",
"autofillMatchingUrlSubdomain": "URL + subdomain",
"autofillMatchingUrlExact": "Exact URL domain only",
"siteSpecificSettings": "Site-Specific Settings",
"autofillPopupOn": "Autofill popup on: ",
"enabledForThisSite": "Enabled for this site",
"disabledForThisSite": "Disabled for this site",
"temporarilyDisabledUntil": "Temporarily disabled until ",
"resetAllSiteSettings": "Reset all site-specific settings",
"appearance": "Appearance",
"theme": "Theme",
"useDefault": "Use default",
"light": "Light",
"dark": "Dark",
"keyboardShortcuts": "Keyboard Shortcuts",
"configureKeyboardShortcuts": "Configure keyboard shortcuts",
"configure": "Configure",
"security": "Security",
"clipboardClearTimeout": "Clear clipboard after copying",
"clipboardClearTimeoutDescription": "Automatically clear the clipboard after copying sensitive data",
"clipboardClearDisabled": "Never clear",
"clipboardClear5Seconds": "Clear after 5 seconds",
"clipboardClear10Seconds": "Clear after 10 seconds",
"clipboardClear15Seconds": "Clear after 15 seconds",
"autoLockTimeout": "Auto-lock Timeout",
"autoLockTimeoutDescription": "Automatically lock the vault after a period of inactivity",
"autoLockTimeoutHelp": "The vault will only lock after the specified period of inactivity (no autofill usage or extension popup opened). The vault will always lock when the browser is closed, regardless of this setting.",
"autoLockNever": "Never",
"autoLock15Seconds": "15 seconds",
"autoLock1Minute": "1 minute",
"autoLock5Minutes": "5 minutes",
"autoLock15Minutes": "15 minutes",
"autoLock30Minutes": "30 minutes",
"autoLock1Hour": "1 hour",
"autoLock4Hours": "4 hours",
"autoLock8Hours": "8 hours",
"autoLock24Hours": "24 hours",
"versionPrefix": "Version ",
"preferences": "Preferences",
"autofillSettings": "Autofill Settings",
"clipboardSettings": "Clipboard Settings",
"contextMenuSettings": "Context Menu Settings",
"contextMenu": "Context Menu",
"contextMenuEnabled": "Context menu is enabled",
"contextMenuDisabled": "Context menu is disabled",
"contextMenuDescription": "Right-click on input fields to access AliasVault options",
"selectLanguage": "Select Language",
"validation": {
"apiUrlRequired": "API URL is required",
"apiUrlInvalid": "Please enter a valid API URL",
"clientUrlRequired": "Client URL is required",
"clientUrlInvalid": "Please enter a valid client URL"
}
},
"upgrade": {
"title": "Upgrade Vault",
"subtitle": "AliasVault has updated and your vault needs to be upgraded. This should only take a few seconds.",
"versionInformation": "Version Information",
"yourVault": "Your vault:",
"newVersion": "New version:",
"upgrade": "Upgrade Vault",
"upgrading": "Upgrading...",
"logout": "Logout",
"whatsNew": "What's New",
"whatsNewDescription": "An upgrade is required to support the following changes:",
"noDescriptionAvailable": "No description available for this version.",
"okay": "Ok",
"status": {
"preparingUpgrade": "Preparing upgrade...",
"vaultAlreadyUpToDate": "Vault is already up to date",
"startingDatabaseTransaction": "Starting database transaction...",
"applyingDatabaseMigrations": "Applying database migrations...",
"applyingMigration": "Applying migration {{current}} of {{total}}...",
"committingChanges": "Committing changes..."
},
"alerts": {
"error": "Error",
"unableToGetVersionInfo": "Unable to get version information. Please try again.",
"selfHostedServer": "Self-Hosted Server",
"selfHostedWarning": "If you're using a self-hosted server, make sure to also update your self-hosted instance as otherwise logging in to the web client will stop working.",
"cancel": "Cancel",
"continueUpgrade": "Continue Upgrade",
"upgradeFailed": "Upgrade Failed",
"failedToApplyMigration": "Failed to apply migration ({{current}} of {{total}})",
"unknownErrorDuringUpgrade": "An unknown error occurred during the upgrade. Please try again."
}
}
}

View File

@@ -0,0 +1,392 @@
{
"auth": {
"loginTitle": "Log in to AliasVault",
"username": "Username or email",
"usernamePlaceholder": "name / name@company.com",
"password": "Password",
"passwordPlaceholder": "Enter your password",
"rememberMe": "Remember me",
"loginButton": "Login",
"noAccount": "No account yet?",
"createVault": "Create new vault",
"twoFactorTitle": "Please enter the authentication code from your authenticator app.",
"authCode": "Authentication Code",
"authCodePlaceholder": "Enter 6-digit code",
"verify": "Verify",
"cancel": "Cancel",
"twoFactorNote": "Note: if you don't have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website.",
"masterPassword": "Master Password",
"unlockVault": "Unlock Vault",
"unlockTitle": "Unlock Your Vault",
"unlockDescription": "Enter your master password to unlock your vault.",
"logout": "Logout",
"logoutConfirm": "Are you sure you want to logout?",
"sessionExpired": "Your session has expired. Please log in again.",
"unlockSuccess": "Vault unlocked successfully!",
"unlockSuccessTitle": "Your vault is successfully unlocked",
"unlockSuccessDescription": "You can now use autofill in login forms in your browser.",
"closePopup": "Close this popup",
"browseVault": "Browse vault contents",
"connectingTo": "Connecting to",
"switchAccounts": "Switch accounts?",
"loggedIn": "Logged in",
"errors": {
"invalidCode": "Please enter a valid 6-digit authentication code.",
"serverError": "Could not reach AliasVault server. Please try again later or contact support if the problem persists.",
"noToken": "Login failed -- no token returned",
"migrationError": "An error occurred while checking for pending migrations.",
"wrongPassword": "Incorrect password. Please try again.",
"accountLocked": "Account temporarily locked due to too many failed attempts.",
"networkError": "Network error. Please check your connection and try again.",
"loginDataMissing": "Login session expired. Please try again."
}
},
"menu": {
"credentials": "Credentials",
"emails": "Emails",
"settings": "Settings"
},
"common": {
"appName": "AliasVault",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"cancel": "Cancel",
"use": "Use",
"delete": "Delete",
"close": "Close",
"copied": "Copied!",
"openInNewWindow": "Open in new window",
"language": "Language",
"enabled": "Enabled",
"disabled": "Disabled",
"showPassword": "Show password",
"hidePassword": "Hide password",
"copyToClipboard": "Copy to clipboard",
"loadingEmails": "Loading emails...",
"loadingTotpCodes": "Loading TOTP codes...",
"attachments": "Attachments",
"loadingAttachments": "Loading attachments...",
"settings": "Settings",
"recentEmails": "Recent emails",
"loginCredentials": "Login credentials",
"twoFactorAuthentication": "Two-factor authentication",
"alias": "Alias",
"notes": "Notes",
"fullName": "Full Name",
"firstName": "First Name",
"lastName": "Last Name",
"birthDate": "Birth Date",
"nickname": "Nickname",
"email": "Email",
"username": "Username",
"password": "Password",
"syncingVault": "Syncing vault",
"savingChangesToVault": "Saving changes to vault",
"uploadingVaultToServer": "Uploading vault to server",
"checkingVaultUpdates": "Checking for vault updates",
"syncingUpdatedVault": "Syncing updated vault",
"executingOperation": "Executing operation...",
"loadMore": "Load more",
"errors": {
"VaultOutdated": "Your vault is outdated. Please login on the AliasVault website and follow the steps.",
"serverNotAvailable": "The AliasVault server is not available. Please try again later or contact support if the problem persists.",
"clientVersionNotSupported": "This version of the AliasVault browser extension is not supported by the server anymore. Please update your browser extension to the latest version.",
"serverVersionNotSupported": "The AliasVault server needs to be updated to a newer version in order to use this browser extension. Please contact support if you need help.",
"unknownError": "An unknown error occurred",
"failedToStoreVault": "Failed to store vault",
"vaultNotAvailable": "Vault not available",
"failedToRetrieveData": "Failed to retrieve data",
"vaultIsLocked": "Vault is locked",
"failedToUploadVault": "Failed to upload vault",
"passwordChanged": "Your password has changed since the last time you logged in. Please login again for security reasons."
},
"apiErrors": {
"UNKNOWN_ERROR": "An unknown error occurred. Please try again.",
"ACCOUNT_LOCKED": "Account temporarily locked due to too many failed attempts. Please try again later.",
"ACCOUNT_BLOCKED": "Your account has been disabled. If you believe this is a mistake, please contact support.",
"USER_NOT_FOUND": "Invalid username or password. Please try again.",
"INVALID_AUTHENTICATOR_CODE": "Invalid authenticator code. Please try again.",
"INVALID_RECOVERY_CODE": "Invalid recovery code. Please try again.",
"REFRESH_TOKEN_REQUIRED": "Refresh token is required.",
"INVALID_REFRESH_TOKEN": "Invalid refresh token.",
"REFRESH_TOKEN_REVOKED_SUCCESSFULLY": "Refresh token revoked successfully.",
"PUBLIC_REGISTRATION_DISABLED": "New account registration is currently disabled on this server. Please contact the administrator.",
"USERNAME_REQUIRED": "Username is required.",
"USERNAME_ALREADY_IN_USE": "Username is already in use.",
"USERNAME_AVAILABLE": "Username is available.",
"USERNAME_MISMATCH": "Username does not match the current user.",
"PASSWORD_MISMATCH": "The provided password does not match your current password.",
"ACCOUNT_SUCCESSFULLY_DELETED": "Account successfully deleted.",
"USERNAME_EMPTY_OR_WHITESPACE": "Username cannot be empty or whitespace.",
"USERNAME_TOO_SHORT": "Username too short: must be at least 3 characters long.",
"USERNAME_TOO_LONG": "Username too long: cannot be longer than 40 characters.",
"USERNAME_INVALID_EMAIL": "Invalid email address.",
"USERNAME_INVALID_CHARACTERS": "Username is invalid, can only contain letters or digits.",
"VAULT_NOT_UP_TO_DATE": "Your vault is not up-to-date. Please synchronize your vault and try again.",
"INTERNAL_SERVER_ERROR": "Internal server error.",
"VAULT_ERROR": "The local vault is not up-to-date. Please synchronize your vault by refreshing the page and try again."
}
},
"content": {
"or": "or",
"new": "New",
"cancel": "Cancel",
"search": "Search",
"vaultLocked": "AliasVault is locked.",
"creatingNewAlias": "Creating new alias...",
"noMatchesFound": "No matches found",
"searchVault": "Search vault...",
"serviceName": "Service name",
"email": "Email",
"username": "Username",
"password": "Password",
"enterServiceName": "Enter service name",
"enterEmailAddress": "Enter email address",
"enterUsername": "Enter username",
"hideFor1Hour": "Hide for 1 hour (current site)",
"hidePermanently": "Hide permanently (current site)",
"createRandomAlias": "Create random alias",
"createUsernamePassword": "Create username/password",
"randomAlias": "Random alias",
"usernamePassword": "Username/password",
"createAndSaveAlias": "Create and save alias",
"createAndSaveCredential": "Create and save credential",
"randomIdentityDescription": "Generate a random identity with a random email address accessible in AliasVault.",
"randomIdentityDescriptionDropdown": "Random identity with random email",
"manualCredentialDescription": "Specify your own email address and username.",
"manualCredentialDescriptionDropdown": "Manual username and password",
"failedToCreateIdentity": "Failed to create identity. Please try again.",
"enterEmailAndOrUsername": "Enter email and/or username",
"autofillWithAliasVault": "Autofill with AliasVault",
"generateRandomPassword": "Generate random password (copy to clipboard)",
"generateNewPassword": "Generate new password",
"togglePasswordVisibility": "Toggle password visibility",
"passwordCopiedToClipboard": "Password copied to clipboard",
"enterEmailAndOrUsernameError": "Enter email and/or username",
"openAliasVaultToUpgrade": "Open AliasVault to upgrade",
"vaultUpgradeRequired": "Vault upgrade required.",
"dismissPopup": "Dismiss popup"
},
"credentials": {
"title": "Credentials",
"addCredential": "Add Credential",
"editCredential": "Edit Credential",
"deleteCredential": "Delete Credential",
"credentialDetails": "Credential Details",
"serviceName": "Service Name",
"serviceNamePlaceholder": "e.g., Gmail, Facebook, Bank",
"website": "Website",
"websitePlaceholder": "https://example.com",
"username": "Username",
"usernamePlaceholder": "Enter username",
"password": "Password",
"passwordPlaceholder": "Enter password",
"generatePassword": "Generate Password",
"copyPassword": "Copy Password",
"showPassword": "Show Password",
"hidePassword": "Hide Password",
"notes": "Notes",
"notesPlaceholder": "Additional notes...",
"totp": "Two-Factor Authentication",
"totpCode": "TOTP Code",
"copyTotp": "Copy TOTP",
"totpSecret": "TOTP Secret",
"totpSecretPlaceholder": "Enter TOTP secret key",
"noCredentials": "No credentials found",
"noCredentialsDescription": "Add your first credential to get started",
"searchPlaceholder": "Search credentials...",
"welcomeTitle": "Welcome to AliasVault!",
"welcomeDescription": "To use the AliasVault browser extension: navigate to a website and use the AliasVault autofill popup to create a new credential.",
"createdAt": "Created",
"updatedAt": "Last updated",
"autofill": "Autofill",
"fillForm": "Fill Form",
"deleteConfirm": "Are you sure you want to delete this credential?",
"saveSuccess": "Credential saved successfully",
"tags": "Tags",
"addTag": "Add Tag",
"removeTag": "Remove Tag",
"folder": "Folder",
"selectFolder": "Select Folder",
"createFolder": "Create Folder",
"saveCredential": "Save credential",
"deleteCredentialTitle": "Delete Credential",
"deleteCredentialConfirm": "Are you sure you want to delete this credential? This action cannot be undone.",
"randomAlias": "Random Alias",
"manual": "Manual",
"service": "Service",
"serviceUrl": "Service URL",
"loginCredentials": "Login Credentials",
"generateRandomUsername": "Generate random username",
"generateRandomPassword": "Generate random password",
"changePasswordComplexity": "Change password complexity",
"passwordLength": "Password length",
"includeLowercase": "Include lowercase letters",
"includeUppercase": "Include uppercase letters",
"includeNumbers": "Include numbers",
"includeSpecialChars": "Include special characters",
"avoidAmbiguousChars": "Avoid ambiguous characters (o, 0, etc.)",
"generateNewPreview": "Generate new preview",
"generateRandomAlias": "Generate Random Alias",
"alias": "Alias",
"firstName": "First Name",
"lastName": "Last Name",
"nickName": "Nick Name",
"gender": "Gender",
"birthDate": "Birth Date",
"birthDatePlaceholder": "YYYY-MM-DD",
"metadata": "Metadata",
"validation": {
"required": "This field is required",
"serviceNameRequired": "Service name is required",
"invalidEmail": "Invalid email format",
"invalidDateFormat": "Date must be in YYYY-MM-DD format"
},
"privateEmailTitle": "Private Email",
"privateEmailAliasVaultServer": "AliasVault server",
"privateEmailDescription": "E2E encrypted, fully private.",
"publicEmailTitle": "Public Temp Email Providers",
"publicEmailDescription": "Anonymous but limited privacy. Email content is readable by anyone that knows the address.",
"useDomainChooser": "Use domain chooser",
"enterCustomDomain": "Enter custom domain",
"enterFullEmail": "Enter full email address",
"enterEmailPrefix": "Enter email prefix"
},
"emails": {
"title": "Emails",
"deleteEmailTitle": "Delete Email",
"deleteEmailConfirm": "Are you sure you want to permanently delete this email?",
"from": "From",
"to": "To",
"date": "Date",
"emailContent": "Email content",
"attachments": "Attachments",
"emailNotFound": "Email not found",
"noEmails": "No emails found",
"noEmailsDescription": "You have not received any emails at your private email addresses yet. When you receive a new email, it will appear here.",
"dateFormat": {
"justNow": "just now",
"minutesAgo_single": "{{count}} min ago",
"minutesAgo_plural": "{{count}} mins ago",
"hoursAgo_single": "{{count}} hr ago",
"hoursAgo_plural": "{{count}} hrs ago",
"yesterday": "yesterday"
},
"errors": {
"emailLoadError": "An error occurred while loading emails. Please try again later.",
"emailUnexpectedError": "An unexpected error occurred while loading emails. Please try again later."
},
"apiErrors": {
"CLAIM_DOES_NOT_MATCH_USER": "The current chosen email address is already in use. Please change the email address by editing this credential.",
"CLAIM_DOES_NOT_EXIST": "An error occurred while trying to load the emails. Please try to edit and save the credential entry to synchronize the database, then try again."
}
},
"settings": {
"title": "Settings",
"serverUrl": "Server URL",
"language": "Language",
"autofillEnabled": "Enable Autofill",
"version": "Version",
"openInNewWindow": "Open in new window",
"openWebApp": "Open web app",
"loggedIn": "Logged in",
"logout": "Logout",
"globalSettings": "Global Settings",
"autofillPopup": "Autofill popup",
"activeOnAllSites": "Active on all sites (unless disabled below)",
"disabledOnAllSites": "Disabled on all sites",
"enabled": "Enabled",
"disabled": "Disabled",
"rightClickContextMenu": "Right-click context menu",
"autofillMatching": "Autofill Matching",
"autofillMatchingMode": "Autofill matching mode",
"autofillMatchingModeDescription": "Determines which credentials are considered a match and shown as suggestions in the autofill popup for a given website.",
"autofillMatchingDefault": "URL + subdomain + name wildcard",
"autofillMatchingUrlSubdomain": "URL + subdomain",
"autofillMatchingUrlExact": "Exact URL domain only",
"siteSpecificSettings": "Site-Specific Settings",
"autofillPopupOn": "Autofill popup on: ",
"enabledForThisSite": "Enabled for this site",
"disabledForThisSite": "Disabled for this site",
"temporarilyDisabledUntil": "Temporarily disabled until ",
"resetAllSiteSettings": "Reset all site-specific settings",
"appearance": "Appearance",
"theme": "Theme",
"useDefault": "Use default",
"light": "Light",
"dark": "Dark",
"keyboardShortcuts": "Keyboard Shortcuts",
"configureKeyboardShortcuts": "Configure keyboard shortcuts",
"configure": "Configure",
"security": "Security",
"clipboardClearTimeout": "Clear clipboard after copying",
"clipboardClearTimeoutDescription": "Automatically clear the clipboard after copying sensitive data",
"clipboardClearDisabled": "Never clear",
"clipboardClear5Seconds": "Clear after 5 seconds",
"clipboardClear10Seconds": "Clear after 10 seconds",
"clipboardClear15Seconds": "Clear after 15 seconds",
"autoLockTimeout": "Auto-lock Timeout",
"autoLockTimeoutDescription": "Automatically lock the vault after a period of inactivity",
"autoLockTimeoutHelp": "Kasa, yalnızca belirtilen süre boyunca herhangi bir işlem yapılmadığında (otomatik doldurma kullanılmadığında veya uzantıılmadığında) kilitlenecektir. Ancak, bu ayardan bağımsız olarak tarayıcı kapatıldığında her zaman kilitlenir.",
"autoLockNever": "Never",
"autoLock15Seconds": "15 seconds",
"autoLock1Minute": "1 minute",
"autoLock5Minutes": "5 minutes",
"autoLock15Minutes": "15 minutes",
"autoLock30Minutes": "30 minutes",
"autoLock1Hour": "1 hour",
"autoLock4Hours": "4 hours",
"autoLock8Hours": "8 hours",
"autoLock24Hours": "24 hours",
"versionPrefix": "Version ",
"preferences": "Preferences",
"autofillSettings": "Autofill Settings",
"clipboardSettings": "Clipboard Settings",
"contextMenuSettings": "Context Menu Settings",
"contextMenu": "Context Menu",
"contextMenuEnabled": "Context menu is enabled",
"contextMenuDisabled": "Context menu is disabled",
"contextMenuDescription": "Right-click on input fields to access AliasVault options",
"selectLanguage": "Select Language",
"validation": {
"apiUrlRequired": "API URL is required",
"apiUrlInvalid": "Please enter a valid API URL",
"clientUrlRequired": "Client URL is required",
"clientUrlInvalid": "Please enter a valid client URL"
}
},
"upgrade": {
"title": "Upgrade Vault",
"subtitle": "AliasVault has updated and your vault needs to be upgraded. This should only take a few seconds.",
"versionInformation": "Version Information",
"yourVault": "Your vault:",
"newVersion": "New version:",
"upgrade": "Upgrade Vault",
"upgrading": "Upgrading...",
"logout": "Logout",
"whatsNew": "What's New",
"whatsNewDescription": "An upgrade is required to support the following changes:",
"noDescriptionAvailable": "No description available for this version.",
"okay": "Ok",
"status": {
"preparingUpgrade": "Preparing upgrade...",
"vaultAlreadyUpToDate": "Vault is already up to date",
"startingDatabaseTransaction": "Starting database transaction...",
"applyingDatabaseMigrations": "Applying database migrations...",
"applyingMigration": "Applying migration {{current}} of {{total}}...",
"committingChanges": "Committing changes..."
},
"alerts": {
"error": "Error",
"unableToGetVersionInfo": "Unable to get version information. Please try again.",
"selfHostedServer": "Self-Hosted Server",
"selfHostedWarning": "If you're using a self-hosted server, make sure to also update your self-hosted instance as otherwise logging in to the web client will stop working.",
"cancel": "Cancel",
"continueUpgrade": "Continue Upgrade",
"upgradeFailed": "Upgrade Failed",
"failedToApplyMigration": "Failed to apply migration ({{current}} of {{total}})",
"unknownErrorDuringUpgrade": "An unknown error occurred during the upgrade. Please try again."
}
}
}

View File

@@ -0,0 +1,392 @@
{
"auth": {
"loginTitle": "Увійти до AliasVault",
"username": "Ім'я користувача або електронна пошта",
"usernamePlaceholder": "ім'я / name@company.com",
"password": "Пароль",
"passwordPlaceholder": "Введіть ваш пароль",
"rememberMe": "Запам'ятати мене",
"loginButton": "Увійти",
"noAccount": "Ще не маєте облікового запису?",
"createVault": "Створити нове сховище",
"twoFactorTitle": "Будь ласка, введіть код автентифікації з вашого застосунку для автентифікації.",
"authCode": "Код автентифікації",
"authCodePlaceholder": "Введіть 6-значний код",
"verify": "Перевірка",
"cancel": "Скасувати",
"twoFactorNote": "Примітка: якщо у вас немає доступу до вашого пристрою автентифікатора, ви можете скинути налаштування 2FA за допомогою коду відновлення, увійшовши через вебсайт.",
"masterPassword": "Головний пароль",
"unlockVault": "Розблокувати Vault",
"unlockTitle": "Розблокувати своє сховище",
"unlockDescription": "Введіть свій головний пароль, щоб розблокувати сховище.",
"logout": "Вийти",
"logoutConfirm": "Ви впевнені, що хочете вийти?",
"sessionExpired": "Ваш сеанс закінчився. Будь ласка, увійдіть знову.",
"unlockSuccess": "Сховище успішно розблоковано!",
"unlockSuccessTitle": "Ваше сховище успішно розблоковано",
"unlockSuccessDescription": "Тепер ви можете використовувати автозаповнення форм входу у вашому браузері.",
"closePopup": "Закрити цю підказку",
"browseVault": "Переглянути вміст сховища",
"connectingTo": "Підключення до",
"switchAccounts": "Змінити обліковий запис?",
"loggedIn": "Вхід виконано",
"errors": {
"invalidCode": "Будь ласка, введіть дійсний 6-значний код автентифікації.",
"serverError": "Не вдалося зв’язатися зі сервером AliasVault. Будь ласка, спробуйте пізніше або зверніться до служби підтримки, якщо проблема не зникне.",
"noToken": "Не вдалося ввійти -- токен не знайдено",
"migrationError": "Під час перевірки незавершених перенесень сталася помилка.",
"wrongPassword": "Невірний пароль. Будь ласка, спробуйте ще раз.",
"accountLocked": "Обліковий запис тимчасово заблоковано через занадто багато невдалих спроб.",
"networkError": "Помилка мережі. Будь ласка, перевірте з’єднання та спробуйте ще раз.",
"loginDataMissing": "Термін дії сеансу закінчився. Будь ласка, спробуйте ще раз."
}
},
"menu": {
"credentials": "Облікові дані",
"emails": "Електронні адреси",
"settings": "Налаштування"
},
"common": {
"appName": "AliasVault",
"loading": "Завантаження даних...",
"error": "Помилка",
"success": "Успішно",
"cancel": "Скасувати",
"use": "Використовувати",
"delete": "Видалити",
"close": "Закрити",
"copied": "Скопійовано!",
"openInNewWindow": "Відкрити у новому вікні",
"language": "Мова",
"enabled": "Увімкнено",
"disabled": "Вимкнено",
"showPassword": "Показати пароль",
"hidePassword": "Приховати пароль",
"copyToClipboard": "Копіювати до буфера обміну",
"loadingEmails": "Завантаження електронних адрес...",
"loadingTotpCodes": "Завантаження кодів TOTP...",
"attachments": "Вкладення",
"loadingAttachments": "Завантаження вкладень...",
"settings": "Налаштування",
"recentEmails": "Останні електронні листи",
"loginCredentials": "Облікові дані для входу",
"twoFactorAuthentication": "Двофакторна автентифікація",
"alias": "Псевдонім",
"notes": "Нотатки",
"fullName": "Повне ім'я",
"firstName": "Ім’я",
"lastName": "Прізвище",
"birthDate": "Дата народження",
"nickname": "Нікнейм",
"email": "Електронна пошта",
"username": "Ім'я користувача",
"password": "Пароль",
"syncingVault": "Синхронізація сховища",
"savingChangesToVault": "Збереження змін у сховищі",
"uploadingVaultToServer": "Завантаження сховища на сервер",
"checkingVaultUpdates": "Перевірка оновлень сховища",
"syncingUpdatedVault": "Синхронізація оновленого сховища",
"executingOperation": "Виконання операції...",
"loadMore": "Завантажити ще",
"errors": {
"VaultOutdated": "Ваше сховище застаріло. Будь ласка, увійдіть на вебсайт AliasVault та виконайте наведені нижче дії.",
"serverNotAvailable": "Не вдалося зв’язатися зі сервером AliasVault. Будь ласка, спробуйте пізніше або зверніться до служби підтримки, якщо проблема не зникне.",
"clientVersionNotSupported": "Ця версія розширення браузера AliasVault більше не підтримується сервером. Будь ласка, оновіть розширення браузера до останньої версії.",
"serverVersionNotSupported": "Щоб використовувати це розширення браузера, потрібно оновити сервер AliasVault до новішої версії. Зверніться до служби підтримки, якщо вам потрібна допомога.",
"unknownError": "Сталася невідома помилка",
"failedToStoreVault": "Не вдалося зберегти сховище",
"vaultNotAvailable": "Сховище недоступне",
"failedToRetrieveData": "Не вдалося отримати дані",
"vaultIsLocked": "Сховище заблоковано",
"failedToUploadVault": "Не вдалося завантажити сховище",
"passwordChanged": "Ваш пароль змінився з моменту останнього входу. З міркувань безпеки, будь ласка, увійдіть ще раз."
},
"apiErrors": {
"UNKNOWN_ERROR": "Сталася невідома помилка. Будь ласка, спробуйте ще раз.",
"ACCOUNT_LOCKED": "Обліковий запис тимчасово заблоковано через занадто багато невдалих спроб. Будь ласка, спробуйте пізніше.",
"ACCOUNT_BLOCKED": "Ваш обліковий запис вимкнено. Якщо ви вважаєте, що це помилка, зверніться до служби підтримки.",
"USER_NOT_FOUND": "Недійсне ім'я користувача або пароль. Спробуйте ще раз.",
"INVALID_AUTHENTICATOR_CODE": "Недійсний код автентифікатора. Спробуйте ще раз.",
"INVALID_RECOVERY_CODE": "Недійсний код відновлення. Будь ласка, спробуйте ще раз.",
"REFRESH_TOKEN_REQUIRED": "Необхідне оновлення токена.",
"INVALID_REFRESH_TOKEN": "Оновлення токена невдале.",
"REFRESH_TOKEN_REVOKED_SUCCESSFULLY": "Оновлення токена відкликано успішно.",
"PUBLIC_REGISTRATION_DISABLED": "Реєстрація нових облікових записів на цьому сервері наразі вимкнена. Зверніться до адміністратора.",
"USERNAME_REQUIRED": "Ім'я користувача обов'язкове.",
"USERNAME_ALREADY_IN_USE": "Ім'я користувача вже використовується.",
"USERNAME_AVAILABLE": "Ім'я користувача доступне.",
"USERNAME_MISMATCH": "Ім'я користувача не відповідає поточному користувачеві.",
"PASSWORD_MISMATCH": "Введений пароль не відповідає вашому поточному паролю.",
"ACCOUNT_SUCCESSFULLY_DELETED": "Обліковий запис успішно видалено.",
"USERNAME_EMPTY_OR_WHITESPACE": "Ім’я користувача не може бути порожнім або містити пробіли.",
"USERNAME_TOO_SHORT": "Ім’я користувача закоротке: має містити щонайменше 3 символи.",
"USERNAME_TOO_LONG": "Ім'я користувача занадто довге: не може бути довшим за 40 символів.",
"USERNAME_INVALID_EMAIL": "Недійсна адреса електронної пошти.",
"USERNAME_INVALID_CHARACTERS": "Ім'я користувача недійсне, може містити лише літери або цифри.",
"VAULT_NOT_UP_TO_DATE": "Ваше сховище застаріло. Синхронізуйте його та спробуйте ще раз.",
"INTERNAL_SERVER_ERROR": "Внутрішня помилка сервера.",
"VAULT_ERROR": "Локальне сховище не оновлене. Синхронізуйте своє сховище, оновивши сторінку, та повторіть спробу."
}
},
"content": {
"or": "або",
"new": "Новий",
"cancel": "Скасувати",
"search": "Пошук",
"vaultLocked": "AliasVault заблоковано.",
"creatingNewAlias": "Створення нового псевдоніму...",
"noMatchesFound": "Збігів не знайдено",
"searchVault": "Пошук сховища...",
"serviceName": "Назва служби",
"email": "Електронна пошта",
"username": "Ім'я користувача",
"password": "Пароль",
"enterServiceName": "Введіть назву служби",
"enterEmailAddress": "Введіть електронну адресу",
"enterUsername": "Введіть ім'я користувача",
"hideFor1Hour": "Сховати протягом 1 години (поточний сайт)",
"hidePermanently": "Приховати назавжди (поточний сайт)",
"createRandomAlias": "Створити випадковий псевдонім",
"createUsernamePassword": "Створити ім'я користувача/пароль",
"randomAlias": "Випадковий псевдонім",
"usernamePassword": "Ім'я користувача/Пароль",
"createAndSaveAlias": "Створити та зберегти псевдонім",
"createAndSaveCredential": "Створити та зберегти облікові дані",
"randomIdentityDescription": "Згенеруйте випадкову особу з випадковою адресою електронної пошти, доступною в AliasVault.",
"randomIdentityDescriptionDropdown": "Випадкова ідентифікація з випадковою електронною поштою",
"manualCredentialDescription": "Вкажіть власну адресу електронної пошти та ім'я користувача.",
"manualCredentialDescriptionDropdown": "Ім'я користувача та пароль вручну",
"failedToCreateIdentity": "Не вдалося створити ідентифікатор. Спробуйте ще раз.",
"enterEmailAndOrUsername": "Введіть електронну пошту та/або ім'я користувача",
"autofillWithAliasVault": "Автозаповнення за допомогою AliasVault",
"generateRandomPassword": "Згенерувати випадковий пароль (скопіювати в буфер обміну)",
"generateNewPassword": "Згенерувати новий пароль",
"togglePasswordVisibility": "Перемикання видимості пароля",
"passwordCopiedToClipboard": "Пароль скопійовано в буфер обміну",
"enterEmailAndOrUsernameError": "Введіть електронну пошту та/або ім'я користувача",
"openAliasVaultToUpgrade": "Відкрити AliasVault для покращення",
"vaultUpgradeRequired": "Потрібне оновлення сховища.",
"dismissPopup": "Закрити спливаюче вікно"
},
"credentials": {
"title": "Облікові дані",
"addCredential": "Додати облікові дані",
"editCredential": "Редагувати облікові дані",
"deleteCredential": "Видалити облікові дані",
"credentialDetails": "Відомості про облікові дані",
"serviceName": "Назва сервісу",
"serviceNamePlaceholder": "наприклад, Gmail, Facebook, Bank",
"website": "Вебсайт",
"websitePlaceholder": "https://example.com",
"username": "Ім'я користувача",
"usernamePlaceholder": "Введіть ім'я користувача",
"password": "Пароль",
"passwordPlaceholder": "Введіть пароль",
"generatePassword": "Згенерувати пароль",
"copyPassword": "Копіювати пароль",
"showPassword": "Показати пароль",
"hidePassword": "Приховати пароль",
"notes": "Нотатки",
"notesPlaceholder": "Додаткові нотатки...",
"totp": "Двофакторна аутентифікація",
"totpCode": "Код TOTP",
"copyTotp": "Копіювати TOTP",
"totpSecret": "Секрет TOTP",
"totpSecretPlaceholder": "Введіть секретний ключ TOTP",
"noCredentials": "Облікових даних не знайдено",
"noCredentialsDescription": "Додайте свої перші облікові дані, щоб розпочати",
"searchPlaceholder": "Пошук облікових даних...",
"welcomeTitle": "Ласкаво просимо до AliasVult!",
"welcomeDescription": "Щоб скористатися розширенням браузера AliasVault: перейдіть на вебсайт і скористайтеся спливаючим вікном автозаповнення AliasVault, щоб створити нові облікові дані.",
"createdAt": "Створено",
"updatedAt": "Останнє оновлення",
"autofill": "Автозаповнення",
"fillForm": "Заповнити форму",
"deleteConfirm": "Ви впевнені, що хочете видалити ці облікові дані?",
"saveSuccess": "Облікові дані успішно збережено",
"tags": "Теги",
"addTag": "Додати тег",
"removeTag": "Видалити тег",
"folder": "Тека",
"selectFolder": "Вибрати теку",
"createFolder": "Створити теку",
"saveCredential": "Зберегти облікові дані",
"deleteCredentialTitle": "Видалити облікові дані",
"deleteCredentialConfirm": "Ви впевнені, що хочете видалити ці облікові дані? Цю дію неможливо скасувати.",
"randomAlias": "Випадковий псевдонім",
"manual": "Посібник",
"service": "Служба",
"serviceUrl": "URL-адреса сервісу",
"loginCredentials": "Дані для входу",
"generateRandomUsername": "Згенерувати випадкове ім'я користувача",
"generateRandomPassword": "Згенерувати випадковий пароль",
"changePasswordComplexity": "Зміна складності пароля",
"passwordLength": "Довжина пароля",
"includeLowercase": "Включити малі літери",
"includeUppercase": "Включити великі літери",
"includeNumbers": "Включити числа",
"includeSpecialChars": "Включити спеціальні символи",
"avoidAmbiguousChars": "Уникайте неоднозначних символів (o, 0 тощо)",
"generateNewPreview": "Згенерувати новий попередній перегляд",
"generateRandomAlias": "Генерувати випадковий псевдонім",
"alias": "Псевдонім",
"firstName": "Ім’я",
"lastName": "Прізвище",
"nickName": "Нікнейм",
"gender": "Стать",
"birthDate": "Дата народження",
"birthDatePlaceholder": "РРРР-ММ-ДД",
"metadata": "Метадані",
"validation": {
"required": "Це поле обов'язкове",
"serviceNameRequired": "Назва служби обов'язкова",
"invalidEmail": "Недійсний формат електронної пошти",
"invalidDateFormat": "Дата має бути у форматі РРРР-ММ-ДД"
},
"privateEmailTitle": "Приватна електронна адреса",
"privateEmailAliasVaultServer": "Сервер AliasVault",
"privateEmailDescription": "Наскрізне шифрування, повністю конфіденційно.",
"publicEmailTitle": "Публічні тимчасові постачальники електронної пошти",
"publicEmailDescription": "Анонімність, але обмежена конфіденційність. Зміст електронного листа може прочитати будь-хто, хто знає адресу.",
"useDomainChooser": "Використовувати засіб вибору домену",
"enterCustomDomain": "Введіть користувацький домен",
"enterFullEmail": "Введіть повну електронну адресу",
"enterEmailPrefix": "Введіть префікс електронної адреси"
},
"emails": {
"title": "Електронні листи",
"deleteEmailTitle": "Видалити електронного листа",
"deleteEmailConfirm": "Ви впевнені, що хочете остаточно видалити цей електронний лист?",
"from": "Від",
"to": "До",
"date": "Дата",
"emailContent": "Вміст електронного листа",
"attachments": "Вкладення",
"emailNotFound": "Електронний лист не знайдено",
"noEmails": "Електронних листів не знайдено",
"noEmailsDescription": "Ви ще не отримували жодних листів на свої приватні адреси електронної пошти. Коли ви отримаєте новий лист, він з’явиться тут.",
"dateFormat": {
"justNow": "щойно",
"minutesAgo_single": "{{count}} хвилина тому",
"minutesAgo_plural": "{{count}} хвилин тому",
"hoursAgo_single": "{{count}} година тому",
"hoursAgo_plural": "{{count}} годин тому",
"yesterday": "учора"
},
"errors": {
"emailLoadError": "Під час завантаження електронних листів сталася помилка. Спробуйте ще раз пізніше.",
"emailUnexpectedError": "Під час завантаження електронних листів сталася неочікувана помилка. Спробуйте ще раз пізніше."
},
"apiErrors": {
"CLAIM_DOES_NOT_MATCH_USER": "Поточна вибрана електронна адреса вже використовується. Змініть електронну адресу, відредагувавши ці облікові дані.",
"CLAIM_DOES_NOT_EXIST": "Під час спроби завантажити електронні листи сталася помилка. Спробуйте відредагувати та зберегти запис облікових даних, щоб синхронізувати базу даних, а потім повторіть спробу."
}
},
"settings": {
"title": "Налаштування",
"serverUrl": "URL-адреса сервера",
"language": "Мова",
"autofillEnabled": "Увімкнути автозаповнення",
"version": "Версія",
"openInNewWindow": "Відкрити у новому вікні",
"openWebApp": "Відкрити веб додаток",
"loggedIn": "Вхід виконано",
"logout": "Вийти",
"globalSettings": "Глобальні налаштування",
"autofillPopup": "Спливаюче вікно автозаповнення",
"activeOnAllSites": "Активно на всіх сайтах (якщо не вимкнено нижче)",
"disabledOnAllSites": "Вимкнено на всіх сайтах",
"enabled": "Увімкнено",
"disabled": "Вимкнено",
"rightClickContextMenu": "Контекстне меню правою кнопкою миші",
"autofillMatching": "Autofill Matching",
"autofillMatchingMode": "Autofill matching mode",
"autofillMatchingModeDescription": "Determines which credentials are considered a match and shown as suggestions in the autofill popup for a given website.",
"autofillMatchingDefault": "URL + subdomain + name wildcard",
"autofillMatchingUrlSubdomain": "URL + subdomain",
"autofillMatchingUrlExact": "Exact URL domain only",
"siteSpecificSettings": "Налаштування, специфічні для сайту",
"autofillPopupOn": "Спливаюче вікно автозаповнення на: ",
"enabledForThisSite": "Увімкнено для цього сайту",
"disabledForThisSite": "Вимкнено для цього сайту",
"temporarilyDisabledUntil": "Тимчасово вимкнено до ",
"resetAllSiteSettings": "Скинути всі налаштування, характерні для сайту",
"appearance": "Зовнішній вигляд",
"theme": "Тема",
"useDefault": "Використовувати за замовчуванням",
"light": "Світла",
"dark": "Темна",
"keyboardShortcuts": "Комбінації клавіш",
"configureKeyboardShortcuts": "Налаштування комбінацій клавіш",
"configure": "Налаштування",
"security": "Безпека",
"clipboardClearTimeout": "Очистити буфер обміну після копіювання",
"clipboardClearTimeoutDescription": "Автоматично очищати буфер обміну після копіювання конфіденційних даних",
"clipboardClearDisabled": "Ніколи не очищати",
"clipboardClear5Seconds": "Очистити після 5 секунд",
"clipboardClear10Seconds": "Очистити після 10 секунд",
"clipboardClear15Seconds": "Очистити після 15 секунд",
"autoLockTimeout": "Auto-lock Timeout",
"autoLockTimeoutDescription": "Автоматично блокувати сховище після періоду бездіяльності",
"autoLockTimeoutHelp": "Сховище буде заблоковано лише після зазначеного періоду бездіяльності (не використовується автозаповнення або не відкривається спливне вікно розширення). Сховище завжди блокуватиметься, коли браузер закривається, незалежно від цього налаштування.",
"autoLockNever": "Ніколи",
"autoLock15Seconds": "15 секунд",
"autoLock1Minute": "1 хвилина",
"autoLock5Minutes": "5 хвилин",
"autoLock15Minutes": "15 хвилин",
"autoLock30Minutes": "30 хвилин",
"autoLock1Hour": "1 година",
"autoLock4Hours": "4 години",
"autoLock8Hours": "8 годин",
"autoLock24Hours": "24 години",
"versionPrefix": "Версія ",
"preferences": "Preferences",
"autofillSettings": "Autofill Settings",
"clipboardSettings": "Clipboard Settings",
"contextMenuSettings": "Context Menu Settings",
"contextMenu": "Context Menu",
"contextMenuEnabled": "Context menu is enabled",
"contextMenuDisabled": "Context menu is disabled",
"contextMenuDescription": "Right-click on input fields to access AliasVault options",
"selectLanguage": "Select Language",
"validation": {
"apiUrlRequired": "URL-адреса API обов'язкова",
"apiUrlInvalid": "Будь ласка, введіть дійсну URL-адресу API",
"clientUrlRequired": "URL-адреса клієнта обов'язкова",
"clientUrlInvalid": "Будь ласка, введіть дійсну URL-адресу клієнта"
}
},
"upgrade": {
"title": "Оновлення сховища",
"subtitle": "AliasVault оновлено, і ваше сховище потрібно оновити. Це займе лише кілька секунд.",
"versionInformation": "Інформація про версію",
"yourVault": "Ваше сховище:",
"newVersion": "Нова версія:",
"upgrade": "Оновлення сховища",
"upgrading": "Оновлення...",
"logout": "Вихід",
"whatsNew": "Що нового",
"whatsNewDescription": "Для підтримки таких змін потрібне оновлення:",
"noDescriptionAvailable": "Для цієї версії немає опису.",
"okay": "Ок",
"status": {
"preparingUpgrade": "Підготовка оновлення...",
"vaultAlreadyUpToDate": "Сховище вже оновлено",
"startingDatabaseTransaction": "Початок транзакції бази даних...",
"applyingDatabaseMigrations": "Застосування міграцій бази даних...",
"applyingMigration": "Застосування міграції {{current}} з {{total}}...",
"committingChanges": "Внесення змін..."
},
"alerts": {
"error": "Помилка",
"unableToGetVersionInfo": "Не вдалося отримати інформацію про версію. Спробуйте ще раз.",
"selfHostedServer": "Сервер із самостійним розміщенням",
"selfHostedWarning": "Якщо ви використовуєте власний сервер, обов’язково оновіть і свій власний екземпляр, інакше вхід до вебклієнта перестане працювати.",
"cancel": "Скасувати",
"continueUpgrade": "Продовжити оновлення",
"upgradeFailed": "Помилка оновлення",
"failedToApplyMigration": "Не вдалося застосувати міграцію ({{current}} з {{total}})",
"unknownErrorDuringUpgrade": "Під час оновлення сталася невідома помилка. Спробуйте ще раз."
}
}
}

View File

@@ -0,0 +1,392 @@
{
"auth": {
"loginTitle": "登录AliasVault",
"username": "用户名或电子邮箱",
"usernamePlaceholder": "name / name@company.com",
"password": "密码",
"passwordPlaceholder": "请输入密码",
"rememberMe": "记住我",
"loginButton": "登录",
"noAccount": "还没有账户?",
"createVault": "创建新保险库",
"twoFactorTitle": "请输入认证器的动态验证码。",
"authCode": "动态验证码",
"authCodePlaceholder": "输入6位动态验证码",
"verify": "验证",
"cancel": "取消",
"twoFactorNote": "注意如果无法访问你的认证设备你可以通过网站登录使用恢复码重置双因素认证2FA。",
"masterPassword": "主密码",
"unlockVault": "解锁保险库",
"unlockTitle": "解锁你的保险库",
"unlockDescription": "输入你的主密码以解锁保险库。",
"logout": "退出登录",
"logoutConfirm": "确定要退出登录吗?",
"sessionExpired": "你的会话已过期。请重新登录。",
"unlockSuccess": "保险库解锁成功!",
"unlockSuccessTitle": "你的保险库已成功解锁",
"unlockSuccessDescription": "现在你可以在浏览器的登录表单中使用自动填充功能了。",
"closePopup": "关闭此弹窗",
"browseVault": "浏览保险库内容",
"connectingTo": "正在连接到",
"switchAccounts": "切换账户?",
"loggedIn": "已登录",
"errors": {
"invalidCode": "请输入有效的6位动态验证码。",
"serverError": "无法连接到AliasVault服务器。请稍后重试若问题依旧请联系支持人员。",
"noToken": "登录失败——未返回令牌",
"migrationError": "检查待处理迁移时发生错误。",
"wrongPassword": "密码不正确。请重试。",
"accountLocked": "由于多次尝试失败,账户已暂时锁定。",
"networkError": "网络错误。请检查你的连接后重试。",
"loginDataMissing": "登录会话已过期。请重试。"
}
},
"menu": {
"credentials": "凭证",
"emails": "邮件",
"settings": "设置"
},
"common": {
"appName": "AliasVault",
"loading": "加载中……",
"error": "错误",
"success": "成功",
"cancel": "取消",
"use": "使用",
"delete": "删除",
"close": "关闭",
"copied": "已复制!",
"openInNewWindow": "在新窗口中打开",
"language": "语言",
"enabled": "已启用",
"disabled": "已禁用",
"showPassword": "显示密码",
"hidePassword": "隐藏密码",
"copyToClipboard": "复制到剪贴板",
"loadingEmails": "加载邮件中……",
"loadingTotpCodes": "加载TOTP验证码中……",
"attachments": "附件",
"loadingAttachments": "加载附件中……",
"settings": "设置",
"recentEmails": "最近邮件",
"loginCredentials": "登录凭证",
"twoFactorAuthentication": "双因素认证2FA",
"alias": "别名",
"notes": "备注",
"fullName": "全名",
"firstName": "名",
"lastName": "姓",
"birthDate": "出生日期",
"nickname": "昵称",
"email": "电子邮箱",
"username": "用户名",
"password": "密码",
"syncingVault": "同步保险库中",
"savingChangesToVault": "正在保存对保险库的更改",
"uploadingVaultToServer": "正在将保险库上传到服务器",
"checkingVaultUpdates": "检查保险库更新中",
"syncingUpdatedVault": "同步更新后的保险库",
"executingOperation": "执行操作中……",
"loadMore": "加载更多",
"errors": {
"VaultOutdated": "你的保险库版本多低。请登录AliasVault网站并按照步骤操作。",
"serverNotAvailable": "AliasVault服务器不可用。请稍后重试若问题持续请联系支持人员。",
"clientVersionNotSupported": "此版本的AliasVault浏览器扩展已不被服务器支持。请将浏览器扩展更新到最新版本。",
"serverVersionNotSupported": "AliasVault服务器需要更新到新版本才能使用此浏览器扩展。如需帮助请联系支持人员。",
"unknownError": "发生未知错误",
"failedToStoreVault": "存储保险库失败",
"vaultNotAvailable": "保险库不可用",
"failedToRetrieveData": "无法检索数据",
"vaultIsLocked": "保险库已锁定",
"failedToUploadVault": "上传保险库失败",
"passwordChanged": "登录密码已更新,请重新登录以确保账户安全。"
},
"apiErrors": {
"UNKNOWN_ERROR": "发生未知错误。请重试。",
"ACCOUNT_LOCKED": "由于多次尝试失败,账户已暂时锁定。请稍后重试。",
"ACCOUNT_BLOCKED": "你的账户已被禁用。如果你认为这是误操作,请联系支持人员。",
"USER_NOT_FOUND": "用户名或密码无效。请重试。",
"INVALID_AUTHENTICATOR_CODE": "认证器验证码无效。请重试。",
"INVALID_RECOVERY_CODE": "恢复码无效。请重试。",
"REFRESH_TOKEN_REQUIRED": "需要刷新令牌。",
"INVALID_REFRESH_TOKEN": "无效的刷新令牌。",
"REFRESH_TOKEN_REVOKED_SUCCESSFULLY": "刷新令牌已成功注销。",
"PUBLIC_REGISTRATION_DISABLED": "当前服务器已禁用新账户注册。请联系管理员。",
"USERNAME_REQUIRED": "用户名是必填项。",
"USERNAME_ALREADY_IN_USE": "用户名已被使用。",
"USERNAME_AVAILABLE": "用户名可用。",
"USERNAME_MISMATCH": "用户名与当前用户不匹配。",
"PASSWORD_MISMATCH": "提供的密码与你的当前密码不匹配。",
"ACCOUNT_SUCCESSFULLY_DELETED": "账户已成功删除。",
"USERNAME_EMPTY_OR_WHITESPACE": "用户名不能为空或仅含空格。",
"USERNAME_TOO_SHORT": "用户名过短至少需要3个字符。",
"USERNAME_TOO_LONG": "用户名过长不能超过40个字符。",
"USERNAME_INVALID_EMAIL": "无效的邮箱地址。",
"USERNAME_INVALID_CHARACTERS": "用户名无效,只能包含字母或数字。",
"VAULT_NOT_UP_TO_DATE": "你的保险库不是最新的。请同步你的保险库后重试。",
"INTERNAL_SERVER_ERROR": "服务器内部错误。",
"VAULT_ERROR": "本地保险库不是最新的。请刷新页面同步保险库后重试。"
}
},
"content": {
"or": "或者",
"new": "新建",
"cancel": "取消",
"search": "搜索",
"vaultLocked": "AliasVault已锁定。",
"creatingNewAlias": "正在创建新别名……",
"noMatchesFound": "未找到匹配项",
"searchVault": "搜索保险库……",
"serviceName": "服务名称",
"email": "电子邮箱",
"username": "用户名",
"password": "密码",
"enterServiceName": "输入服务名称",
"enterEmailAddress": "输入邮箱地址",
"enterUsername": "输入用户名",
"hideFor1Hour": "隐藏1小时当前网站",
"hidePermanently": "永久隐藏(当前网站)",
"createRandomAlias": "创建随机别名",
"createUsernamePassword": "创建用户名/密码",
"randomAlias": "随机别名",
"usernamePassword": "用户名/密码",
"createAndSaveAlias": "创建并保存别名",
"createAndSaveCredential": "创建并保存凭证",
"randomIdentityDescription": "生成一个包含随机邮箱地址的随机身份可在AliasVault中查看。",
"randomIdentityDescriptionDropdown": "带随机邮箱的随机身份",
"manualCredentialDescription": "指定你自己的邮箱地址和用户名。",
"manualCredentialDescriptionDropdown": "手动输入用户名和密码",
"failedToCreateIdentity": "创建身份失败。请重试。",
"enterEmailAndOrUsername": "输入邮箱和/或用户名",
"autofillWithAliasVault": "使用AliasVault自动填充",
"generateRandomPassword": "生成随机密码(复制到剪贴板)",
"generateNewPassword": "生成新密码",
"togglePasswordVisibility": "切换密码可见性",
"passwordCopiedToClipboard": "密码已复制到剪贴板",
"enterEmailAndOrUsernameError": "请输入邮箱和/或用户名",
"openAliasVaultToUpgrade": "打开AliasVault进行升级",
"vaultUpgradeRequired": "需要升级保险库。",
"dismissPopup": "关闭弹窗"
},
"credentials": {
"title": "凭证",
"addCredential": "添加凭证",
"editCredential": "编辑凭证",
"deleteCredential": "删除凭证",
"credentialDetails": "凭证详情",
"serviceName": "服务名称",
"serviceNamePlaceholder": "例如Gmail、Facebook、银行",
"website": "网站",
"websitePlaceholder": "https://example.com",
"username": "用户名",
"usernamePlaceholder": "输入用户名",
"password": "密码",
"passwordPlaceholder": "输入密码",
"generatePassword": "生成密码",
"copyPassword": "复制密码",
"showPassword": "显示密码",
"hidePassword": "隐藏密码",
"notes": "备注",
"notesPlaceholder": "添加备注……",
"totp": "双因素认证2FA",
"totpCode": "TOTP验证码",
"copyTotp": "复制 TOTP",
"totpSecret": "TOTP密钥",
"totpSecretPlaceholder": "输入TOTP密钥",
"noCredentials": "未找到凭证",
"noCredentialsDescription": "添加你的第一个凭证开始使用",
"searchPlaceholder": "搜索凭证……",
"welcomeTitle": "欢迎使用AliasVault",
"welcomeDescription": "要使用AliasVault浏览器扩展导航到某个网站使用AliasVault自动填充弹窗创建新凭证。",
"createdAt": "创建时间",
"updatedAt": "最后更新",
"autofill": "自动填充",
"fillForm": "填充表单",
"deleteConfirm": "确定要删除此凭证吗?",
"saveSuccess": "凭证保存成功",
"tags": "标签",
"addTag": "添加标签",
"removeTag": "移除标签",
"folder": "文件夹",
"selectFolder": "选择文件夹",
"createFolder": "创建文件夹",
"saveCredential": "保存凭证",
"deleteCredentialTitle": "删除凭证",
"deleteCredentialConfirm": "确定要删除此凭证吗?此操作无法撤销。",
"randomAlias": "随机别名",
"manual": "手动",
"service": "服务",
"serviceUrl": "服务 URL",
"loginCredentials": "登录凭证",
"generateRandomUsername": "生成随机用户名",
"generateRandomPassword": "生成随机密码",
"changePasswordComplexity": "修改密码复杂度",
"passwordLength": "密码长度",
"includeLowercase": "包含小写字母",
"includeUppercase": "包含大写字母",
"includeNumbers": "包含数字",
"includeSpecialChars": "包含特殊字符",
"avoidAmbiguousChars": "避免易混淆字符o、0 等)",
"generateNewPreview": "生成新预览",
"generateRandomAlias": "生成随机别名",
"alias": "别名",
"firstName": "名",
"lastName": "姓",
"nickName": "昵称",
"gender": "性别",
"birthDate": "出生日期",
"birthDatePlaceholder": "YYYY-MM-DD",
"metadata": "备注",
"validation": {
"required": "此字段为必填项",
"serviceNameRequired": "服务名称为必填项",
"invalidEmail": "无效的邮箱格式",
"invalidDateFormat": "日期必须为 YYYY-MM-DD 格式"
},
"privateEmailTitle": "私人邮箱",
"privateEmailAliasVaultServer": "AliasVault服务器",
"privateEmailDescription": "端对端加密,完全私密。",
"publicEmailTitle": "公开临时邮箱供应商",
"publicEmailDescription": "匿名但隐私有限。任何知晓该邮箱地址的人均可查看邮件内容。",
"useDomainChooser": "使用域名选择器",
"enterCustomDomain": "输入自定义域名",
"enterFullEmail": "输入验证邮箱地址",
"enterEmailPrefix": "输入邮箱前缀"
},
"emails": {
"title": "邮件",
"deleteEmailTitle": "删除邮件",
"deleteEmailConfirm": "确定要永久删除此邮件吗?",
"from": "发件人",
"to": "收件人",
"date": "日期",
"emailContent": "邮件内容",
"attachments": "附件",
"emailNotFound": "未找到邮件",
"noEmails": "未找到邮件",
"noEmailsDescription": "你的私人邮箱地址尚未收到任何邮件。当你收到新邮件时,会显示在这里。",
"dateFormat": {
"justNow": "刚刚",
"minutesAgo_single": "{{count}}分钟前",
"minutesAgo_plural": "{{count}}分钟前",
"hoursAgo_single": "{{count}}小时前",
"hoursAgo_plural": "{{count}}小时前",
"yesterday": "昨天"
},
"errors": {
"emailLoadError": "加载邮件时发生错误。请稍后重试。",
"emailUnexpectedError": "加载邮件时发生意外错误。请稍后重试。"
},
"apiErrors": {
"CLAIM_DOES_NOT_MATCH_USER": "当前选择的邮箱地址已被使用。请通过编辑此凭证修改邮箱地址。",
"CLAIM_DOES_NOT_EXIST": "加载邮件时发生错误。请尝试编辑并保存凭证条目以同步数据库,然后重试。"
}
},
"settings": {
"title": "设置",
"serverUrl": "服务器 URL",
"language": "语言",
"autofillEnabled": "启用自动填充",
"version": "版本",
"openInNewWindow": "在新窗口中打开",
"openWebApp": "打开网页应用",
"loggedIn": "已登录",
"logout": "退出登录",
"globalSettings": "全局设置",
"autofillPopup": "自动填充弹窗",
"activeOnAllSites": "在所有网站上激活(除非在下方禁用)",
"disabledOnAllSites": "在所有网站上禁用",
"enabled": "启用",
"disabled": "禁用",
"rightClickContextMenu": "右键上下文菜单",
"autofillMatching": "自动填充匹配",
"autofillMatchingMode": "自动填充匹配模式",
"autofillMatchingModeDescription": "用于判定哪些凭证会被视为匹配项,并在指定网站的自动填充弹窗中显示为建议选项。",
"autofillMatchingDefault": "URL + 子域名 + 名称通配符",
"autofillMatchingUrlSubdomain": "URL + 子域名",
"autofillMatchingUrlExact": "精确匹配URL域名",
"siteSpecificSettings": "网站特定设置",
"autofillPopupOn": "自动填充弹窗在以下位置: ",
"enabledForThisSite": "对此网站启用",
"disabledForThisSite": "对此网站禁用",
"temporarilyDisabledUntil": "暂时禁用至 ",
"resetAllSiteSettings": "重置所有网站特定设置",
"appearance": "外观",
"theme": "主题",
"useDefault": "使用默认",
"light": "浅色",
"dark": "深色",
"keyboardShortcuts": "键盘快捷键",
"configureKeyboardShortcuts": "配置键盘快捷键",
"configure": "配置",
"security": "安全",
"clipboardClearTimeout": "复制后清楚剪切板",
"clipboardClearTimeoutDescription": "复制敏感数据后自动清除剪贴板",
"clipboardClearDisabled": "从不清除",
"clipboardClear5Seconds": "5秒后清除",
"clipboardClear10Seconds": "10 秒后清除",
"clipboardClear15Seconds": "15分钟后清除",
"autoLockTimeout": "自动锁定超时时间",
"autoLockTimeoutDescription": "无操作一段时间后自动锁定保险库",
"autoLockTimeoutHelp": "保险库仅在达到指定的无操作时长后才会锁定(未使用自动填充功能或未打开扩展程序弹窗)。无论此设置如何,关闭浏览器时保险库始终会锁定。",
"autoLockNever": "从不",
"autoLock15Seconds": "15秒",
"autoLock1Minute": "1分钟",
"autoLock5Minutes": "5分钟",
"autoLock15Minutes": "15分钟",
"autoLock30Minutes": "30分钟",
"autoLock1Hour": "1小时",
"autoLock4Hours": "4小时",
"autoLock8Hours": "8小时",
"autoLock24Hours": "24小时",
"versionPrefix": "版本 ",
"preferences": "首选项",
"autofillSettings": "自动填充设置",
"clipboardSettings": "剪切板设置",
"contextMenuSettings": "上下文菜单设置",
"contextMenu": "上下文菜单",
"contextMenuEnabled": "上下文菜单已启用",
"contextMenuDisabled": "上下文菜单已停用",
"contextMenuDescription": "右键点击输入字段即可访问 AliasVault 选项",
"selectLanguage": "选择语言",
"validation": {
"apiUrlRequired": "API URL 为必填项",
"apiUrlInvalid": "请输入有效的 API URL",
"clientUrlRequired": "客户端 URL 为必填项",
"clientUrlInvalid": "请输入有效的客户端 URL"
}
},
"upgrade": {
"title": "升级保险库",
"subtitle": "AliasVault已更新你的保险库需要升级仅需数秒。",
"versionInformation": "版本信息",
"yourVault": "你的保险库:",
"newVersion": "新版本:",
"upgrade": "升级保险库",
"upgrading": "升级中……",
"logout": "退出登录",
"whatsNew": "新功能",
"whatsNewDescription": "需要升级以支持以下变更:",
"noDescriptionAvailable": "此版本无可用说明。",
"okay": "确定",
"status": {
"preparingUpgrade": "准备升级中……",
"vaultAlreadyUpToDate": "当前保险库数据已是最新",
"startingDatabaseTransaction": "开始数据库事务……",
"applyingDatabaseMigrations": "应用数据库迁移……",
"applyingMigration": "应用迁移 {{current}} / {{total}}……",
"committingChanges": "提交更改中……"
},
"alerts": {
"error": "错误",
"unableToGetVersionInfo": "无法获取版本信息。请重试。",
"selfHostedServer": "自托管服务器",
"selfHostedWarning": "如果你使用的是自托管服务器,请确保同时更新你的自托管实例,否则将无法登录网页客户端。",
"cancel": "取消",
"continueUpgrade": "继续升级",
"upgradeFailed": "升级失败",
"failedToApplyMigration": "应用迁移失败({{current}} / {{total}}",
"unknownErrorDuringUpgrade": "升级过程中发生未知错误。请重试。"
}
}
}

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

View File

@@ -4,6 +4,9 @@ export const GLOBAL_AUTOFILL_POPUP_ENABLED_KEY = 'local:aliasvault_global_autofi
export const GLOBAL_CONTEXT_MENU_ENABLED_KEY = 'local:aliasvault_global_context_menu_enabled';
export const VAULT_LOCKED_DISMISS_UNTIL_KEY = 'local:aliasvault_vault_locked_dismiss_until';
export const TEMPORARY_DISABLED_SITES_KEY = 'local:aliasvault_temporary_disabled_sites';
export const CLIPBOARD_CLEAR_TIMEOUT_KEY = 'local:aliasvault_clipboard_clear_timeout';
export const AUTO_LOCK_TIMEOUT_KEY = 'local:aliasvault_auto_lock_timeout';
export const AUTOFILL_MATCHING_MODE_KEY = 'local:aliasvault_autofill_matching_mode';
// TODO: store these settings in the actual vault when updating the datamodel for roadmap v1.0.
export const LAST_CUSTOM_EMAIL_KEY = 'local:aliasvault_last_custom_email';

View File

@@ -1,6 +1,7 @@
import initSqlJs, { Database } from 'sql.js';
import type { Credential, EncryptionKey, PasswordSettings, TotpCode } from '@/utils/dist/shared/models/vault';
import type { Attachment } from '@/utils/dist/shared/models/vault';
import type { VaultVersion } from '@/utils/dist/shared/vault-sql';
import { VaultSqlGenerator } from '@/utils/dist/shared/vault-sql';
@@ -244,7 +245,7 @@ export class SqliteClient {
BirthDate: row.BirthDate,
Gender: row.Gender,
Email: row.Email
}
},
};
}
@@ -356,6 +357,7 @@ export class SqliteClient {
const isValidDomain = (domain: string): boolean => {
return Boolean(domain &&
domain !== 'DISABLED.TLD' &&
domain !== '' &&
(privateEmailDomains.includes(domain) || publicEmailDomains.includes(domain)));
};
@@ -424,9 +426,10 @@ export class SqliteClient {
/**
* Create a new credential with associated entities
* @param credential The credential object to insert
* @param attachments The attachments to insert
* @returns The ID of the created credential
*/
public async createCredential(credential: Credential): Promise<string> {
public async createCredential(credential: Credential, attachments: Attachment[]): Promise<string> {
if (!this.db) {
throw new Error('Database not initialized');
}
@@ -520,6 +523,26 @@ export class SqliteClient {
]);
}
// 5. Insert Attachment
if (attachments) {
for (const attachment of attachments) {
const attachmentQuery = `
INSERT INTO Attachments (Id, Filename, Blob, CredentialId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`;
const attachmentId = crypto.randomUUID().toUpperCase();
this.executeUpdate(attachmentQuery, [
attachmentId,
attachment.Filename,
attachment.Blob as Uint8Array,
credentialId,
currentDateTime,
currentDateTime,
0
]);
}
}
await this.commitTransaction();
return credentialId;
@@ -641,6 +664,39 @@ export class SqliteClient {
}
}
/**
* Get attachments for a specific credential
* @param credentialId - The ID of the credential
* @returns Array of attachments for the credential
*/
public getAttachmentsForCredential(credentialId: string): Attachment[] {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
if (!this.tableExists('Attachments')) {
return [];
}
const query = `
SELECT
Id,
Filename,
Blob,
CredentialId,
CreatedAt,
UpdatedAt,
IsDeleted
FROM Attachments
WHERE CredentialId = ? AND IsDeleted = 0`;
return this.executeQuery<Attachment>(query, [credentialId]);
} catch (error) {
console.error('Error getting attachments:', error);
return [];
}
}
/**
* Delete a credential by ID
* @param credentialId - The ID of the credential to delete
@@ -702,9 +758,11 @@ export class SqliteClient {
/**
* Update an existing credential with associated entities
* @param credential The credential object to update
* @param originalAttachmentIds The IDs of the original attachments
* @param attachments The attachments to update
* @returns The number of rows modified
*/
public async updateCredentialById(credential: Credential): Promise<number> {
public async updateCredentialById(credential: Credential, originalAttachmentIds: string[], attachments: Attachment[]): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
@@ -850,6 +908,44 @@ export class SqliteClient {
}
}
// 5. Handle Attachments
if (attachments) {
// Get current attachment IDs to track what needs to be deleted
const currentAttachmentIds = attachments.map(a => a.Id);
// Delete attachments that were removed (in originalAttachmentIds but not in current attachments)
const attachmentsToDelete = originalAttachmentIds.filter(id => !currentAttachmentIds.includes(id));
for (const attachmentId of attachmentsToDelete) {
const deleteQuery = `
UPDATE Attachments
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = ?`;
this.executeUpdate(deleteQuery, [currentDateTime, attachmentId]);
}
// Process each attachment
for (const attachment of attachments) {
const isExistingAttachment = originalAttachmentIds.includes(attachment.Id);
if (!isExistingAttachment) {
// Insert new attachment
const insertQuery = `
INSERT INTO Attachments (Id, Filename, Blob, CredentialId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`;
this.executeUpdate(insertQuery, [
attachment.Id,
attachment.Filename,
attachment.Blob as Uint8Array,
credential.Id,
currentDateTime,
currentDateTime,
0
]);
}
}
}
await this.commitTransaction();
return 1;

View File

@@ -2,6 +2,8 @@ import type { StatusResponse, VaultResponse } from '@/utils/dist/shared/models/w
import { AppInfo } from "./AppInfo";
import type { TFunction } from 'i18next';
import { storage } from '#imports';
type RequestInit = globalThis.RequestInit;
@@ -260,25 +262,26 @@ export class WebApiService {
return {
clientVersionSupported: true,
serverVersion: '0.0.0',
vaultRevision: 0
vaultRevision: 0,
srpSalt: ''
};
}
}
/**
* Validates the status response and returns an error message if validation fails.
* Validates the status response and returns an error message (as translation key) if validation fails.
*/
public validateStatusResponse(statusResponse: StatusResponse): string | null {
if (statusResponse.serverVersion === '0.0.0') {
return 'The AliasVault server is not available. Please try again later or contact support if the problem persists.';
return 'serverNotAvailable';
}
if (!statusResponse.clientVersionSupported) {
return 'This version of the AliasVault browser extension is not supported by the server anymore. Please update your browser extension to the latest version.';
return 'clientVersionNotSupported';
}
if (!AppInfo.isServerVersionSupported(statusResponse.serverVersion)) {
return 'The AliasVault server needs to be updated to a newer version in order to use this browser extension. Please contact support if you need help.';
return 'serverVersionNotSupported';
}
return null;
@@ -287,22 +290,18 @@ export class WebApiService {
/**
* Validates the vault response and returns an error message if validation fails
*/
public validateVaultResponse(vaultResponseJson: VaultResponse): string | null {
public validateVaultResponse(vaultResponseJson: VaultResponse, t: TFunction): string | null {
/**
* Status 0 = OK, vault is ready.
* Status 1 = Merge required, which only the web client supports.
*/
if (vaultResponseJson.status === 1) {
// Note: vault merge is no longer allowed by the API as of 0.20.0, updates with the same revision number are rejected. So this check can be removed later.
return 'Your vault needs to be updated. Please login on the AliasVault website and follow the steps.';
return t('errors.VaultOutdated');
}
if (vaultResponseJson.status === 2) {
return 'Your vault is outdated. Please login on the AliasVault website and follow the steps.';
}
if (!vaultResponseJson.vault?.blob) {
return 'Your account does not have a vault yet. Please complete the tutorial in the AliasVault web client before using the browser extension.';
return t('errors.VaultOutdated');
}
return null;

View File

@@ -137,7 +137,7 @@ declare class UsernameEmailGenerator {
* @param language - The language to use for generating the identity (e.g. "en", "nl").
* @returns A new identity generator instance.
*/
declare const CreateIdentityGenerator: (language: string) => IIdentityGenerator;
declare const CreateIdentityGenerator: (language: string) => IdentityGenerator;
/**
* Creates a new username email generator. This is used by the .NET Blazor WASM JSinterop

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -77,4 +77,17 @@ type Alias = {
Email?: string;
};
export type { Alias, Credential, EncryptionKey, PasswordSettings, TotpCode };
/**
* Attachment SQLite database type.
*/
type Attachment = {
Id: string;
Filename: string;
Blob: Uint8Array | number[];
CredentialId: string;
CreatedAt: string;
UpdatedAt: string;
IsDeleted?: boolean;
};
export type { Alias, Attachment, Credential, EncryptionKey, PasswordSettings, TotpCode };

View File

@@ -65,6 +65,7 @@ type StatusResponse = {
clientVersionSupported: boolean;
serverVersion: string;
vaultRevision: number;
srpSalt: string;
};
/**

View File

@@ -39,7 +39,7 @@ var PasswordGenerator = class {
this.uppercaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
this.numberChars = "0123456789";
this.specialChars = "!@#$%^&*()_+-=[]{}|;:,.<>?";
this.ambiguousChars = "Il1O0";
this.ambiguousChars = "Il1O0o";
this.length = 18;
this.useLowercase = true;
this.useUppercase = true;

View File

@@ -13,7 +13,7 @@ var PasswordGenerator = class {
this.uppercaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
this.numberChars = "0123456789";
this.specialChars = "!@#$%^&*()_+-=[]{}|;:,.<>?";
this.ambiguousChars = "Il1O0";
this.ambiguousChars = "Il1O0o";
this.length = 18;
this.useLowercase = true;
this.useUppercase = true;

View File

@@ -169,6 +169,37 @@ export class FormDetector {
return this.clickedElement?.closest('form, [role="dialog"]') as HTMLElement | null;
}
/**
* Get the actual input element from a potentially custom element.
* This handles any element with shadow DOM containing input elements.
* @param element The element to check (could be a custom element or regular input)
* @returns The actual input element, or the original element if no nested input is found
*/
private getActualInputElement(element: HTMLElement): HTMLElement {
// If it's already an input, return it
if (element.tagName.toLowerCase() === 'input') {
return element;
}
// Check for shadow DOM input (generic approach)
const elementWithShadow = element as HTMLElement & { shadowRoot?: ShadowRoot };
if (elementWithShadow.shadowRoot) {
const shadowInput = elementWithShadow.shadowRoot.querySelector('input, textarea') as HTMLElement;
if (shadowInput) {
return shadowInput;
}
}
// Check for regular child input (non-shadow DOM)
const childInput = element.querySelector('input, textarea') as HTMLElement;
if (childInput) {
return childInput;
}
// Return the original element if no nested input found
return element;
}
/**
* Check if an element and all its parents are visible.
* This checks for display:none, visibility:hidden, and opacity:0
@@ -257,10 +288,32 @@ export class FormDetector {
types: string[],
excludeElements: HTMLInputElement[] = []
): HTMLInputElement[] {
// Query for both standard input elements and any element with a type attribute
const candidates = form
? form.querySelectorAll<HTMLElement>('input, select, [type]')
: this.document.querySelectorAll<HTMLElement>('input, select, [type]');
// Query for standard input elements, select elements, and elements with type attributes
const standardCandidates = form
? Array.from(form.querySelectorAll<HTMLElement>('input, select, [type]'))
: Array.from(this.document.querySelectorAll<HTMLElement>('input, select, [type]'));
/**
* Also find any custom elements that might contain shadow DOM inputs
* Look for elements with shadow roots that contain input elements
*/
const allElements = form
? Array.from(form.querySelectorAll<HTMLElement>('*'))
: Array.from(this.document.querySelectorAll<HTMLElement>('*'));
const shadowDOMCandidates = allElements.filter(el => {
// Check if element has shadow DOM with input elements
const elementWithShadow = el as HTMLElement & { shadowRoot?: ShadowRoot };
if (elementWithShadow.shadowRoot) {
const shadowInput = elementWithShadow.shadowRoot.querySelector('input, textarea');
return shadowInput !== null;
}
return false;
});
// Combine and deduplicate candidates
const allCandidates = [...standardCandidates, ...shadowDOMCandidates];
const candidates = allCandidates.filter((el, index, arr) => arr.indexOf(el) === index);
const matches: { input: HTMLInputElement; score: number }[] = [];
@@ -274,12 +327,32 @@ export class FormDetector {
}
// Get type from either the element's type property or its type attribute
const type = input.tagName.toLowerCase() === 'select'
const tagName = input.tagName.toLowerCase();
let type = tagName === 'select'
? 'select'
: (input as HTMLInputElement).type?.toLowerCase() || input.getAttribute('type')?.toLowerCase() || '';
// Check if element has shadow DOM with input elements (generic detection)
const elementWithShadow = input as HTMLElement & { shadowRoot?: ShadowRoot };
const hasShadowDOMInput = elementWithShadow.shadowRoot &&
elementWithShadow.shadowRoot.querySelector('input, textarea');
// For elements with shadow DOM, get the type from the actual input inside
if (hasShadowDOMInput && !type) {
const shadowInput = elementWithShadow.shadowRoot!.querySelector('input, textarea') as HTMLInputElement;
if (shadowInput) {
type = shadowInput.type?.toLowerCase() || 'text';
}
}
// Check if this element should be considered based on type matching
if (!types.includes(type)) {
continue;
// For shadow DOM elements, allow if we're looking for text and it contains an input
if (hasShadowDOMInput && types.includes('text') && !type) {
// This is a shadow DOM element without explicit type, treat as text input
} else {
continue;
}
}
if (types.includes('email') && type === 'email') {
@@ -303,6 +376,34 @@ export class FormDetector {
}
}
/**
* Check for slot-based labels (e.g., <span slot="label">Email or username</span>)
* Look for slot elements within the input's parent hierarchy
*/
let slotParent: HTMLElement | null = input;
for (let depth = 0; depth < 3 && slotParent; depth++) {
const slotElements = slotParent.querySelectorAll('[slot="label"], [slot="helper-text"]');
for (const slotEl of Array.from(slotElements)) {
const slotText = slotEl.textContent?.toLowerCase() ?? '';
if (slotText) {
attributesToCheck.push(slotText);
}
}
/** Also check if the parent itself is a custom element with slots */
if (slotParent.shadowRoot) {
const shadowSlots = slotParent.shadowRoot.querySelectorAll('slot[name="label"], slot[name="helper-text"]');
for (const slot of Array.from(shadowSlots)) {
const assignedNodes = (slot as HTMLSlotElement).assignedNodes();
for (const node of assignedNodes) {
if (node.textContent) {
attributesToCheck.push(node.textContent.toLowerCase());
}
}
}
}
slotParent = slotParent.parentElement;
}
// Check for sibling elements with class containing "label"
const parent = input.parentElement;
if (parent) {
@@ -631,21 +732,34 @@ export class FormDetector {
// Check if it's a username, email or password field by reusing the existing detection logic
const formWrapper = this.getFormWrapper();
// Check if the clicked element is a username field.
if (!this.clickedElement) {
return false;
}
// Get the actual input element (handles shadow DOM)
const actualElement = this.getActualInputElement(this.clickedElement);
// Check both the clicked element and the actual input element
const elementsToCheck = [this.clickedElement, actualElement].filter((el, index, arr) =>
el && arr.indexOf(el) === index // Remove duplicates
);
// Check if any of the elements is a username field
const usernameFields = this.findAllInputFields(formWrapper as HTMLFormElement | null, CombinedFieldPatterns.username, ['text']);
if (usernameFields.some(input => input === this.clickedElement)) {
if (usernameFields.some(input => elementsToCheck.includes(input))) {
return true;
}
// Check if the clicked element is a password field.
// Check if any of the elements is a password field
const passwordField = this.findPasswordField(formWrapper as HTMLFormElement | null);
if (passwordField.primary === this.clickedElement || passwordField.confirm === this.clickedElement) {
if ((passwordField.primary && elementsToCheck.includes(passwordField.primary)) ||
(passwordField.confirm && elementsToCheck.includes(passwordField.confirm))) {
return true;
}
// Check if the clicked element is an email field.
// Check if any of the elements is an email field
const emailFields = this.findAllInputFields(formWrapper as HTMLFormElement | null, CombinedFieldPatterns.email, ['text', 'email']);
if (emailFields.some(input => input === this.clickedElement)) {
if (emailFields.some(input => elementsToCheck.includes(input))) {
return true;
}

View File

@@ -2,10 +2,13 @@ import { Gender, IdentityHelperUtils } from "@/utils/dist/shared/identity-genera
import type { Credential } from "@/utils/dist/shared/models/vault";
import { CombinedDateOptionPatterns, CombinedGenderOptionPatterns } from "@/utils/formDetector/FieldPatterns";
import { FormFields } from "@/utils/formDetector/types/FormFields";
import { ClickValidator } from "@/utils/security/ClickValidator";
/**
* Class to fill the fields of a form with the given credential.
*/
export class FormFiller {
private readonly clickValidator = ClickValidator.getInstance();
/**
* Constructor.
*/
@@ -23,19 +26,257 @@ export class FormFiller {
* Fill the fields of the form with the given credential.
* @param credential The credential to fill the form with.
*/
public fillFields(credential: Credential): void {
public async fillFields(credential: Credential): Promise<void> {
// Perform security validation before filling any fields
if (!await this.validateFormSecurity()) {
console.warn('[AliasVault Security] Autofill blocked due to security validation failure');
return;
}
this.fillBasicFields(credential);
this.fillBirthdateFields(credential);
this.fillGenderFields(credential);
}
/**
* Validate form security to prevent autofill in potential clickjacking scenarios.
* This method checks for various attack vectors including:
* - Page-wide opacity manipulation
* - Form field obstruction via overlays
* - Suspicious element positioning
* - Multiple forms with identical fields (potential decoy attacks)
*/
private async validateFormSecurity(): Promise<boolean> {
try {
// Skip security validation in test environments where browser APIs may not be available
if (typeof window === 'undefined' || typeof MouseEvent === 'undefined') {
return true;
}
// 1. Check page-wide security using ClickValidator (detects body/HTML opacity tricks)
const dummyEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
clientX: window.innerWidth / 2,
clientY: window.innerHeight / 2
});
// Note: isTrusted is read-only and set by the browser
if (!await this.clickValidator.validateClick(dummyEvent)) {
console.warn('[AliasVault Security] Form autofill blocked: Page-wide attack detected');
return false;
}
// 2. Check form field obstruction and positioning
const formFields = this.getAllFormFields();
for (const field of formFields) {
if (!this.validateFieldSecurity(field)) {
console.warn('[AliasVault Security] Form autofill blocked: Field obstruction detected', field);
return false;
}
}
// 3. Check for suspicious form duplication (decoy attack)
if (this.detectDecoyForms()) {
console.warn('[AliasVault Security] Form autofill blocked: Multiple suspicious forms detected');
return false;
}
return true;
} catch (error) {
console.error('[AliasVault Security] Form security validation error:', error);
return false; // Fail safely - block autofill if validation fails
}
}
/**
* Get all form fields that will be filled.
*/
private getAllFormFields(): HTMLElement[] {
const fields: HTMLElement[] = [];
if (this.form.usernameField) {
fields.push(this.form.usernameField);
}
if (this.form.passwordField) {
fields.push(this.form.passwordField);
}
if (this.form.passwordConfirmField) {
fields.push(this.form.passwordConfirmField);
}
if (this.form.emailField) {
fields.push(this.form.emailField);
}
if (this.form.emailConfirmField) {
fields.push(this.form.emailConfirmField);
}
return fields;
}
/**
* Validate individual field security to detect obstruction attacks.
*/
private validateFieldSecurity(field: HTMLElement): boolean {
if (!field) {
return true;
}
// Skip field validation in test environments where browser APIs may not be available
if (typeof window === 'undefined' || typeof document === 'undefined' || !document.elementsFromPoint) {
return true;
}
const rect = field.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// Check if field is within viewport
if (rect.width === 0 || rect.height === 0 ||
centerX < 0 || centerY < 0 ||
centerX > window.innerWidth || centerY > window.innerHeight) {
console.warn('[AliasVault Security] Field outside viewport or zero-sized:', rect);
return false;
}
// Use elementsFromPoint to check what's actually at the field center
try {
const elementsAtPoint = document.elementsFromPoint(centerX, centerY);
if (elementsAtPoint.length === 0) {
console.warn('[AliasVault Security] No elements found at field center');
return false;
}
// Check if our field is in the element stack (or its parents/children)
const fieldFound = elementsAtPoint.some(element =>
element === field ||
field.contains(element) ||
element.contains(field)
);
if (!fieldFound) {
console.warn('[AliasVault Security] Field is obstructed by other elements');
return false;
}
// Check for suspicious covering elements
const suspiciousCovering = elementsAtPoint.slice(0, 3).some(element => {
if (element === field || field.contains(element) || element.contains(field)) {
return false; // This is our field or related element
}
const style = getComputedStyle(element);
// Check for nearly transparent overlays
const opacity = parseFloat(style.opacity);
if (opacity > 0 && opacity < 0.1) {
console.warn('[AliasVault Security] Nearly transparent overlay detected:', element);
return true;
}
// Check for high z-index elements (potential overlays)
const zIndex = parseInt(style.zIndex) || 0;
if (zIndex > 1000000) {
console.warn('[AliasVault Security] Suspicious high z-index element:', element, zIndex);
return true;
}
// Check for elements covering large areas (potential clickjacking overlays)
const elementRect = element.getBoundingClientRect();
if (elementRect.width >= window.innerWidth * 0.8 &&
elementRect.height >= window.innerHeight * 0.8) {
console.warn('[AliasVault Security] Large covering element detected:', element);
return true;
}
return false;
});
return !suspiciousCovering;
} catch (error) {
console.warn('[AliasVault Security] Field validation error:', error);
return false; // Fail safely
}
}
/**
* Detect potential decoy forms (multiple forms with similar fields).
*/
private detectDecoyForms(): boolean {
try {
// Find all forms on the page
const allForms = Array.from(document.querySelectorAll('form'));
if (allForms.length <= 1) {
return false; // Only one form, no decoy risk
}
let suspiciousFormCount = 0;
for (const form of allForms) {
const hasPasswordField = form.querySelector('input[type="password"]');
const hasEmailField = form.querySelector('input[type="email"], input[name*="email" i], input[placeholder*="email" i]');
const hasUsernameField = form.querySelector('input[type="text"], input[name*="user" i], input[placeholder*="user" i]');
// Count forms with login-like patterns
if (hasPasswordField && (hasEmailField || hasUsernameField)) {
const formRect = form.getBoundingClientRect();
const isVisible = formRect.width > 0 && formRect.height > 0;
if (isVisible) {
suspiciousFormCount++;
}
}
}
// If more than 2 visible login forms, it's suspicious
if (suspiciousFormCount > 2) {
console.warn('[AliasVault Security] Multiple login forms detected:', suspiciousFormCount);
return true;
}
return false;
} catch (error) {
console.warn('[AliasVault Security] Decoy form detection error:', error);
return false; // Don't block on detection errors
}
}
/**
* Set value on an input element, handling both regular inputs and custom elements with shadow DOM.
* @param element The element to set the value on
* @param value The value to set
*/
private setElementValue(element: HTMLInputElement | HTMLSelectElement, value: string): void {
// Try to set value directly on the element
element.value = value;
// If it's a custom element with shadow DOM, try to find and fill the actual input
if (element.shadowRoot) {
const shadowInput = element.shadowRoot.querySelector('input, textarea') as HTMLInputElement;
if (shadowInput) {
shadowInput.value = value;
// Trigger events on the shadow input as well
this.triggerInputEvents(shadowInput, false);
}
}
// Also check if the element contains a regular child input (non-shadow DOM)
const childInput = element.querySelector('input, textarea') as HTMLInputElement;
if (childInput && childInput !== element) {
childInput.value = value;
this.triggerInputEvents(childInput, false);
}
}
/**
* Fill the basic fields of the form.
* @param credential The credential to fill the form with.
*/
private fillBasicFields(credential: Credential): void {
if (this.form.usernameField && credential.Username) {
this.form.usernameField.value = credential.Username;
this.setElementValue(this.form.usernameField, credential.Username);
this.triggerInputEvents(this.form.usernameField);
}
@@ -49,7 +290,7 @@ export class FormFiller {
if (this.form.emailField && (credential.Alias?.Email !== undefined || credential.Username !== undefined)) {
if (credential.Alias?.Email) {
this.form.emailField.value = credential.Alias.Email;
this.setElementValue(this.form.emailField, credential.Alias.Email);
this.triggerInputEvents(this.form.emailField);
} else if (credential.Username && !this.form.usernameField) {
/*
@@ -62,28 +303,28 @@ export class FormFiller {
* 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.setElementValue(this.form.emailField, credential.Username);
this.triggerInputEvents(this.form.emailField);
}
}
if (this.form.emailConfirmField && credential.Alias?.Email) {
this.form.emailConfirmField.value = credential.Alias.Email;
this.setElementValue(this.form.emailConfirmField, credential.Alias.Email);
this.triggerInputEvents(this.form.emailConfirmField);
}
if (this.form.fullNameField && credential.Alias?.FirstName && credential.Alias?.LastName) {
this.form.fullNameField.value = `${credential.Alias.FirstName} ${credential.Alias.LastName}`;
this.setElementValue(this.form.fullNameField, `${credential.Alias.FirstName} ${credential.Alias.LastName}`);
this.triggerInputEvents(this.form.fullNameField);
}
if (this.form.firstNameField && credential.Alias?.FirstName) {
this.form.firstNameField.value = credential.Alias.FirstName;
this.setElementValue(this.form.firstNameField, credential.Alias.FirstName);
this.triggerInputEvents(this.form.firstNameField);
}
if (this.form.lastNameField && credential.Alias?.LastName) {
this.form.lastNameField.value = credential.Alias.LastName;
this.setElementValue(this.form.lastNameField, credential.Alias.LastName);
this.triggerInputEvents(this.form.lastNameField);
}
}
@@ -91,25 +332,56 @@ export class FormFiller {
/**
* Fill the password field with the given password. This uses a small delay between each character to simulate human typing.
* Simulates actual keystroke behavior by appending characters one by one.
* Supports both regular inputs and custom elements with shadow DOM.
*
* @param field The password field to fill.
* @param password The password to fill the field with.
*/
private async fillPasswordField(field: HTMLInputElement, password: string): Promise<void> {
// Find the actual input element (could be in shadow DOM)
let actualInput = field;
let isCustomElement = false;
// Check for shadow DOM input
if (field.shadowRoot) {
const shadowInput = field.shadowRoot.querySelector('input[type="password"], input') as HTMLInputElement;
if (shadowInput) {
actualInput = shadowInput;
isCustomElement = true;
}
} else if (field.tagName.toLowerCase() !== 'input') {
// Check for child input (non-shadow DOM) only if field is not already an input
const childInput = field.querySelector('input[type="password"], input') as HTMLInputElement;
if (childInput) {
actualInput = childInput;
isCustomElement = true;
}
}
// Clear the field first
field.value = '';
this.triggerInputEvents(field, true);
actualInput.value = '';
if (isCustomElement) {
field.value = '';
}
this.triggerInputEvents(actualInput, 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;
// Append the character to the actual input
actualInput.value += char;
if (isCustomElement) {
// Also update the custom element's value property for compatibility
field.value += char;
}
// Small random delay between 5-15ms to simulate human typing
this.triggerInputEvents(field, false);
this.triggerInputEvents(actualInput, false);
await new Promise(resolve => setTimeout(resolve, Math.random() * 10 + 5));
}
this.triggerInputEvents(field, false);
this.triggerInputEvents(actualInput, false);
if (isCustomElement) {
this.triggerInputEvents(field, false);
}
}
/**
@@ -266,13 +538,13 @@ export class FormFiller {
private fillGenderFields(credential: Credential): void {
switch (this.form.genderField.type) {
case 'select':
this.fillGenderSelect(credential.Alias.Gender);
this.fillGenderSelect(credential.Alias.Gender as Gender | undefined);
break;
case 'radio':
this.fillGenderRadio(credential.Alias.Gender);
this.fillGenderRadio(credential.Alias.Gender as Gender | undefined);
break;
case 'text':
this.fillGenderText(credential.Alias.Gender);
this.fillGenderText(credential.Alias.Gender as Gender | undefined);
break;
}
}

View File

@@ -0,0 +1,273 @@
import { JSDOM } from 'jsdom';
import { describe, expect, it } from 'vitest';
import { FormDetector } from '../FormDetector';
describe('FormDetector Shadow DOM tests', () => {
it('should detect faceplate-text-input with actual shadow DOM as autofill triggerable field', () => {
const html = `
<form>
<fieldset>
<faceplate-text-input id="login-username" name="username" autocomplete="username" required="">
<span slot="label">Email or username</span>
</faceplate-text-input>
</fieldset>
</form>
`;
const dom = new JSDOM(html, {
url: 'http://localhost',
runScripts: 'dangerously',
resources: 'usable'
});
const document = dom.window.document;
// Get the custom element
const faceplateElement = document.getElementById('login-username');
expect(faceplateElement).toBeTruthy();
expect(faceplateElement?.tagName.toLowerCase()).toBe('faceplate-text-input');
// Create shadow DOM like Reddit's actual implementation
const shadowRoot = faceplateElement!.attachShadow({ mode: 'open' });
// Create the internal input element that would exist in the shadow DOM
const shadowHTML = `
<div class="faceplate-input-wrapper">
<input type="text" name="username" autocomplete="username" class="faceplate-input" />
<slot name="label"></slot>
</div>
`;
shadowRoot.innerHTML = shadowHTML;
// Verify shadow DOM was created correctly
const shadowInput = shadowRoot.querySelector('input');
expect(shadowInput).toBeTruthy();
expect(shadowInput?.type).toBe('text');
// Create a FormDetector with the custom element
const detector = new FormDetector(document, faceplateElement as HTMLElement);
// Should detect it as an autofill triggerable field
expect(detector.isAutofillTriggerableField()).toBe(true);
});
it('should detect faceplate-text-input with type attribute fallback as autofill triggerable field', () => {
const html = `
<form>
<fieldset>
<faceplate-text-input id="login-username" type="text" name="username" autocomplete="username" required="">
<span slot="label">Email or username</span>
</faceplate-text-input>
</fieldset>
</form>
`;
const dom = new JSDOM(html, {
url: 'http://localhost',
runScripts: 'dangerously',
resources: 'usable'
});
const document = dom.window.document;
// Get the custom element
const faceplateElement = document.getElementById('login-username');
expect(faceplateElement).toBeTruthy();
expect(faceplateElement?.tagName.toLowerCase()).toBe('faceplate-text-input');
// Create a FormDetector with the custom element (without shadow DOM for this test)
const detector = new FormDetector(document, faceplateElement as HTMLElement);
// Should detect it as an autofill triggerable field using type attribute
expect(detector.isAutofillTriggerableField()).toBe(true);
});
it('should detect faceplate-text-input with password shadow DOM as triggerable field', () => {
const html = `
<form>
<fieldset>
<faceplate-text-input id="login-password" name="password" autocomplete="current-password">
<span slot="label">Password</span>
</faceplate-text-input>
</fieldset>
</form>
`;
const dom = new JSDOM(html, {
url: 'http://localhost',
runScripts: 'dangerously',
resources: 'usable'
});
const document = dom.window.document;
const faceplateElement = document.getElementById('login-password');
// Create shadow DOM with password input (like Reddit's actual implementation)
const shadowRoot = faceplateElement!.attachShadow({ mode: 'open' });
const shadowHTML = `
<div class="faceplate-input-wrapper">
<input type="password" name="password" autocomplete="current-password" class="faceplate-input" />
<slot name="label"></slot>
</div>
`;
shadowRoot.innerHTML = shadowHTML;
// Verify shadow DOM password input
const shadowInput = shadowRoot.querySelector('input');
expect(shadowInput?.type).toBe('password');
const detector = new FormDetector(document, faceplateElement as HTMLElement);
expect(detector.isAutofillTriggerableField()).toBe(true);
});
it('should detect custom element with email type as triggerable field', () => {
const html = `
<form>
<custom-input id="email-field" type="email" name="email">
<span slot="label">Email Address</span>
</custom-input>
</form>
`;
const dom = new JSDOM(html, {
url: 'http://localhost',
runScripts: 'dangerously',
resources: 'usable'
});
const document = dom.window.document;
const customElement = document.getElementById('email-field');
const detector = new FormDetector(document, customElement as HTMLElement);
expect(detector.isAutofillTriggerableField()).toBe(true);
});
it('should not detect non-input custom elements as triggerable fields', () => {
const html = `
<form>
<custom-button id="submit-btn" type="button">
<span>Submit</span>
</custom-button>
</form>
`;
const dom = new JSDOM(html, {
url: 'http://localhost',
runScripts: 'dangerously',
resources: 'usable'
});
const document = dom.window.document;
const customButton = document.getElementById('submit-btn');
const detector = new FormDetector(document, customButton as HTMLElement);
expect(detector.isAutofillTriggerableField()).toBe(false);
});
it('should handle real Reddit-like login form with shadow DOM', () => {
const html = `
<form>
<fieldset class="relative mt-0 mb-0 ml-0 mr-0 p-0 border-0 flex flex-col flex-grow bg-transparent">
<faceplate-text-input id="login-username" name="username" autocomplete="username" required=""
helper-text-placeholder="&nbsp;" aria-disabled="false" helper-text-aria-live="polite"
appearance="secondary" faceplate-validity="invalid">
<span slot="label">Email or username</span>
</faceplate-text-input>
</fieldset>
<fieldset class="relative mt-0 mb-0 ml-0 mr-0 p-0 border-0 flex flex-col flex-grow bg-transparent">
<faceplate-text-input id="login-password" name="password" autocomplete="current-password" required=""
helper-text-placeholder="&nbsp;" aria-disabled="false" helper-text-aria-live="polite"
appearance="secondary">
<span slot="label">Password</span>
</faceplate-text-input>
</fieldset>
</form>
`;
const dom = new JSDOM(html, {
url: 'http://localhost',
runScripts: 'dangerously',
resources: 'usable'
});
const document = dom.window.document;
// Set up shadow DOM for username field (no type attribute, just like Reddit)
const usernameElement = document.getElementById('login-username');
const usernameShadowRoot = usernameElement!.attachShadow({ mode: 'open' });
usernameShadowRoot.innerHTML = `
<div class="faceplate-input-wrapper">
<input type="text" name="username" autocomplete="username" class="faceplate-input" />
<slot name="label"></slot>
<div class="faceplate-input-decorations"></div>
</div>
`;
// Set up shadow DOM for password field (no type attribute, just like Reddit)
const passwordElement = document.getElementById('login-password');
const passwordShadowRoot = passwordElement!.attachShadow({ mode: 'open' });
passwordShadowRoot.innerHTML = `
<div class="faceplate-input-wrapper">
<input type="password" name="password" autocomplete="current-password" class="faceplate-input" />
<slot name="label"></slot>
<div class="faceplate-input-decorations"></div>
</div>
`;
// Test username field detection
const usernameDetector = new FormDetector(document, usernameElement as HTMLElement);
expect(usernameDetector.containsLoginForm()).toBe(true);
expect(usernameDetector.isAutofillTriggerableField()).toBe(true);
// Test password field detection
const passwordDetector = new FormDetector(document, passwordElement as HTMLElement);
expect(passwordDetector.containsLoginForm()).toBe(true);
expect(passwordDetector.isAutofillTriggerableField()).toBe(true);
// Test form extraction
const form = usernameDetector.getForm();
expect(form).toBeTruthy();
/**
* The form should be able to detect both username and password fields
* Note: Since the custom elements are now recognized, they should be included in the form detection
*/
expect(form?.form).toBeTruthy();
});
it('should handle custom elements in form detection with type attributes', () => {
const html = `
<form>
<fieldset>
<faceplate-text-input id="username-field" type="text" name="username">
<span slot="label">Username</span>
</faceplate-text-input>
</fieldset>
<fieldset>
<faceplate-text-input id="password-field" type="password" name="password">
<span slot="label">Password</span>
</faceplate-text-input>
</fieldset>
</form>
`;
const dom = new JSDOM(html, {
url: 'http://localhost',
runScripts: 'dangerously',
resources: 'usable'
});
const document = dom.window.document;
const usernameElement = document.getElementById('username-field');
const detector = new FormDetector(document, usernameElement as HTMLElement);
// Should contain login form
expect(detector.containsLoginForm()).toBe(true);
// Should detect the field as triggerable
expect(detector.isAutofillTriggerableField()).toBe(true);
// Should be able to get form
const form = detector.getForm();
expect(form).toBeTruthy();
});
});

View File

@@ -0,0 +1,160 @@
import { JSDOM } from 'jsdom';
import { describe, expect, it } from 'vitest';
import { FormDetector } from '../FormDetector';
import { FormField, testField } from './TestUtils';
describe('FormDetector Slot-based Form tests', () => {
it('contains tests for slot-based form field detection', () => {
/**
* This test suite verifies that FormDetector can properly detect
* form fields that use slot-based labels (Web Components pattern).
* This is common in modern web applications using custom elements.
*/
expect(true).toBe(true);
});
describe('Slot-based login form detection', () => {
const htmlFile = 'slot-based-form.html';
testField(FormField.Username, 'login-username', htmlFile);
testField(FormField.Password, 'login-password', htmlFile);
testField(FormField.Email, 'email-field', htmlFile);
testField(FormField.FirstName, 'firstname-field', htmlFile);
testField(FormField.LastName, 'lastname-field', htmlFile);
});
describe('Direct slot label detection', () => {
it('should detect input field with slot label for username', () => {
const html = `
<form>
<div>
<input id="test-username" name="user" type="text" />
<span slot="label">username</span>
</div>
</form>
`;
const dom = new JSDOM(html, {
url: 'http://localhost',
runScripts: 'dangerously',
resources: 'usable'
});
const document = dom.window.document;
const inputElement = document.getElementById('test-username');
const detector = new FormDetector(document, inputElement as HTMLElement);
const form = detector.getForm();
// Since the slot contains "username", it should be detected as a username field
expect(form?.usernameField).toBe(inputElement);
});
it('should detect email field with slot helper-text', () => {
const html = `
<form>
<web-component id="test-email" type="email">
<span slot="helper-text">Enter your email address</span>
</web-component>
</form>
`;
const dom = new JSDOM(html, {
url: 'http://localhost',
runScripts: 'dangerously',
resources: 'usable'
});
const document = dom.window.document;
const inputElement = document.getElementById('test-email');
const detector = new FormDetector(document, inputElement as HTMLElement);
const form = detector.getForm();
expect(form?.emailField).toBe(inputElement);
});
it('should detect password field with nested slot label', () => {
const html = `
<form>
<fieldset>
<custom-password-input id="test-password" type="password">
<div slot="label">
<span>Your Password</span>
</div>
</custom-password-input>
</fieldset>
</form>
`;
const dom = new JSDOM(html, {
url: 'http://localhost',
runScripts: 'dangerously',
resources: 'usable'
});
const document = dom.window.document;
const inputElement = document.getElementById('test-password');
const detector = new FormDetector(document, inputElement as HTMLElement);
const form = detector.getForm();
expect(form?.passwordField).toBe(inputElement);
});
});
describe('Slot-based form with regular inputs', () => {
it('should correctly identify input fields with slot labels', () => {
const html = `
<form>
<div>
<input id="email-input" type="email" name="email">
<span slot="label">Email Address</span>
</div>
<div>
<input id="pass-input" type="password" name="password">
<span slot="label">Password</span>
</div>
<div>
<input id="fname-input" type="text" name="fname">
<span slot="label">First Name</span>
</div>
<div>
<input id="lname-input" type="text" name="lname">
<span slot="label">Last Name</span>
</div>
</form>
`;
const dom = new JSDOM(html, {
url: 'http://localhost',
runScripts: 'dangerously',
resources: 'usable'
});
const document = dom.window.document;
// Test email field detection
const emailElement = document.getElementById('email-input');
const emailDetector = new FormDetector(document, emailElement as HTMLElement);
const emailForm = emailDetector.getForm();
expect(emailForm?.emailField).toBe(emailElement);
// Test password field detection
const passElement = document.getElementById('pass-input');
const passDetector = new FormDetector(document, passElement as HTMLElement);
const passForm = passDetector.getForm();
expect(passForm?.passwordField).toBe(passElement);
// Test first name field detection
const fnameElement = document.getElementById('fname-input');
const fnameDetector = new FormDetector(document, fnameElement as HTMLElement);
const fnameForm = fnameDetector.getForm();
expect(fnameForm?.firstNameField).toBe(fnameElement);
// Test last name field detection
const lnameElement = document.getElementById('lname-input');
const lnameDetector = new FormDetector(document, lnameElement as HTMLElement);
const lnameForm = lnameDetector.getForm();
expect(lnameForm?.lastNameField).toBe(lnameElement);
});
});
});

View File

@@ -29,7 +29,7 @@ describe('FormFiller English', () => {
});
describe('fillBirthdateFields with English month names', () => {
it('should fill separate fields with English month names', () => {
it('should fill separate fields with English month names', async () => {
const { daySelect, monthSelect, yearSelect } = createDateSelects(document);
// Add month options with English month names
@@ -52,7 +52,7 @@ describe('FormFiller English', () => {
year: yearSelect as unknown as HTMLInputElement
};
formFiller.fillFields(mockCredential);
await formFiller.fillFields(mockCredential);
expect(daySelect.value).toBe('03');
expect(monthSelect.value).toBe('February');

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