Compare commits
325 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4030387ead | ||
|
|
0240f008ce | ||
|
|
bad4f46a82 | ||
|
|
8ec5fab5e0 | ||
|
|
85bbb0ab78 | ||
|
|
343b1baedb | ||
|
|
fb5d4dfeca | ||
|
|
661f0574c5 | ||
|
|
a4a1c0b097 | ||
|
|
02eae4c04f | ||
|
|
d7d9d2d99f | ||
|
|
40b368bc7e | ||
|
|
360ce0c9eb | ||
|
|
074b2e48fa | ||
|
|
ae4ea3cb80 | ||
|
|
a8a51f65c3 | ||
|
|
b5264eae69 | ||
|
|
d380ce7946 | ||
|
|
75797fe829 | ||
|
|
3fd279e032 | ||
|
|
df50a1ad47 | ||
|
|
5d96c44ea9 | ||
|
|
e7baadda9f | ||
|
|
376d38ef07 | ||
|
|
97d8d4d15d | ||
|
|
4010631d73 | ||
|
|
03d8e15eeb | ||
|
|
7f01e2a9a0 | ||
|
|
d0334e9033 | ||
|
|
0aa99572e3 | ||
|
|
51f666d238 | ||
|
|
fc60426e0f | ||
|
|
520a6ef4b2 | ||
|
|
deacb9ada9 | ||
|
|
25383dd615 | ||
|
|
6daed9b31b | ||
|
|
8c40c786f7 | ||
|
|
a5025d3262 | ||
|
|
c932a24f21 | ||
|
|
0ebc75dcea | ||
|
|
0d62b4af55 | ||
|
|
9de879a387 | ||
|
|
519fe9ba30 | ||
|
|
6aaca60049 | ||
|
|
17a248d0d7 | ||
|
|
c8b42aecc1 | ||
|
|
577c452c88 | ||
|
|
6a3e294aae | ||
|
|
81ad1ec5e7 | ||
|
|
8c3007b6f4 | ||
|
|
e4cd9fe6ed | ||
|
|
6dc5e4806b | ||
|
|
7a72416e83 | ||
|
|
727d7e6025 | ||
|
|
506bc37eac | ||
|
|
a69b1049a6 | ||
|
|
7f3508030e | ||
|
|
0b2fd61fd0 | ||
|
|
b76654c9d2 | ||
|
|
68c7453c08 | ||
|
|
dbbc6a96db | ||
|
|
f6ad5667ef | ||
|
|
ed8642de41 | ||
|
|
bcd3673a00 | ||
|
|
c180fdf505 | ||
|
|
3664f5bc20 | ||
|
|
c134c2642a | ||
|
|
003ef1f096 | ||
|
|
386da4b227 | ||
|
|
7ca816a60e | ||
|
|
932d79fd85 | ||
|
|
d8ef99207f | ||
|
|
c7182e7a21 | ||
|
|
fa451dc2cc | ||
|
|
85d89b2b2c | ||
|
|
7d22bc34a7 | ||
|
|
b1a06cb2da | ||
|
|
e5a15b2486 | ||
|
|
c1e8a9b44e | ||
|
|
d628e9cc4c | ||
|
|
3a50b6e85b | ||
|
|
9641514b3b | ||
|
|
975ae9bd74 | ||
|
|
3bead0bbfc | ||
|
|
a77417c990 | ||
|
|
dc48ac23dd | ||
|
|
4428f428dc | ||
|
|
5a6d317e31 | ||
|
|
6f24fd6453 | ||
|
|
af60b2e22d | ||
|
|
85642eab64 | ||
|
|
8aad6f845e | ||
|
|
4ba2c8e6ab | ||
|
|
9da88cc7e7 | ||
|
|
e67fce5e39 | ||
|
|
3c94eb873d | ||
|
|
16418e1513 | ||
|
|
7ddb035f1a | ||
|
|
f5c88639a6 | ||
|
|
d0baf8b6e0 | ||
|
|
6269b7ec7c | ||
|
|
5ee8d7a8f4 | ||
|
|
c1d41b3d8d | ||
|
|
5fddf753f8 | ||
|
|
712a9a0182 | ||
|
|
f43f3cc51f | ||
|
|
99dc808de4 | ||
|
|
f97efea681 | ||
|
|
9ec245c102 | ||
|
|
fc9c59b077 | ||
|
|
5fe2c3ab4c | ||
|
|
2c4af6c85b | ||
|
|
99a24c23e4 | ||
|
|
1427693c1d | ||
|
|
619f402ca0 | ||
|
|
71ddbbe3d2 | ||
|
|
ad086689dd | ||
|
|
dc114c6bfa | ||
|
|
9843142419 | ||
|
|
9ba698bb74 | ||
|
|
5185dfa41d | ||
|
|
ea4d72ceca | ||
|
|
b2206cae8f | ||
|
|
1f8fb2ea39 | ||
|
|
b2476ab5c5 | ||
|
|
866c8e7834 | ||
|
|
fb01b75f3d | ||
|
|
8b05d2aafa | ||
|
|
4d54649c3a | ||
|
|
a5c8ff91b5 | ||
|
|
5164c705c2 | ||
|
|
c00088d955 | ||
|
|
6698771fc4 | ||
|
|
665662982c | ||
|
|
c7d3a9ea1e | ||
|
|
c24598c151 | ||
|
|
b995ec728c | ||
|
|
234193e99b | ||
|
|
af06bbfd12 | ||
|
|
646416c069 | ||
|
|
219bc88e30 | ||
|
|
020f11d3a4 | ||
|
|
4cea8aae5e | ||
|
|
1db63bbc6b | ||
|
|
00c230a92e | ||
|
|
868bdc9aa2 | ||
|
|
4c9de1fc2f | ||
|
|
3adc796295 | ||
|
|
30d223aba6 | ||
|
|
6eb43c4f8b | ||
|
|
f0260622fd | ||
|
|
a0269f90f3 | ||
|
|
11ea12499b | ||
|
|
4cff77b927 | ||
|
|
fa517c38c0 | ||
|
|
5e1f899a5e | ||
|
|
e1318e2147 | ||
|
|
ee9f3ca0f9 | ||
|
|
026cfb91e9 | ||
|
|
0b78e5fa77 | ||
|
|
d5b11cc34c | ||
|
|
ddf34a2d30 | ||
|
|
37acd87c44 | ||
|
|
efaa7962cb | ||
|
|
d4f0579eea | ||
|
|
ac78bb1afc | ||
|
|
8d3034676b | ||
|
|
d9588acf00 | ||
|
|
f213b1ac57 | ||
|
|
5f49013235 | ||
|
|
bb0bee7870 | ||
|
|
7c64e656ff | ||
|
|
90e846674e | ||
|
|
3d684e59ea | ||
|
|
a4d728c9e5 | ||
|
|
74e8f1b840 | ||
|
|
774afaf522 | ||
|
|
92623493e8 | ||
|
|
53c4242342 | ||
|
|
ed5c436084 | ||
|
|
dd2b08a4a3 | ||
|
|
dad709fc20 | ||
|
|
8964b1080d | ||
|
|
5ec9e53449 | ||
|
|
18182cdda2 | ||
|
|
33ed79e951 | ||
|
|
c044a27a3f | ||
|
|
95753e3fa9 | ||
|
|
9a3df923b5 | ||
|
|
c41bf8a921 | ||
|
|
d93ec10cc9 | ||
|
|
385ee841dd | ||
|
|
7c533de8f3 | ||
|
|
92fe915d0f | ||
|
|
1905078bdc | ||
|
|
974315ed8c | ||
|
|
d8b8fc7922 | ||
|
|
795adab0dc | ||
|
|
020d1bcfa1 | ||
|
|
1efc06eaac | ||
|
|
19c7da5dc6 | ||
|
|
e85a3cab7f | ||
|
|
0ab5ca9377 | ||
|
|
48000b76eb | ||
|
|
c27300bcb3 | ||
|
|
48acb81492 | ||
|
|
09f61bd7a2 | ||
|
|
4bfe69750c | ||
|
|
afab20f59b | ||
|
|
3bc3c165f6 | ||
|
|
bc6f492208 | ||
|
|
fa4c80858c | ||
|
|
6c94ed5193 | ||
|
|
3658b606c2 | ||
|
|
01eee844de | ||
|
|
ac7ea057d4 | ||
|
|
00023ea944 | ||
|
|
bd78cfe778 | ||
|
|
c2b6e8af1e | ||
|
|
f0fdfcdf19 | ||
|
|
479e32ddac | ||
|
|
4661e36ef4 | ||
|
|
26eb965b1d | ||
|
|
ae4aeb6f45 | ||
|
|
5b62b035ee | ||
|
|
8416c7c15f | ||
|
|
1a9e1967ed | ||
|
|
9156923f92 | ||
|
|
b8a15930cd | ||
|
|
544fea83b0 | ||
|
|
032417aeec | ||
|
|
30e213919d | ||
|
|
98e52b8756 | ||
|
|
240a0854be | ||
|
|
57f6ec1be7 | ||
|
|
df9eacdf13 | ||
|
|
eebf7aff41 | ||
|
|
10c9478238 | ||
|
|
3b1199d2db | ||
|
|
405b44383f | ||
|
|
cf90721197 | ||
|
|
b62078f97e | ||
|
|
74f4bc0ee9 | ||
|
|
7a65678ba2 | ||
|
|
2a208b5cff | ||
|
|
6a0e8fc5ca | ||
|
|
dad476548e | ||
|
|
1cf49eed7e | ||
|
|
04dfd41281 | ||
|
|
b31c94c582 | ||
|
|
5569202b9a | ||
|
|
0ffb14ba0a | ||
|
|
db227894b6 | ||
|
|
46e217f523 | ||
|
|
d40d2d9c43 | ||
|
|
1a5ed775de | ||
|
|
a16d773686 | ||
|
|
4ebb02795a | ||
|
|
5a70e7e20e | ||
|
|
18ee97f6e5 | ||
|
|
4ffac949ee | ||
|
|
db15c9ab25 | ||
|
|
0ca4a7b8c7 | ||
|
|
364093e789 | ||
|
|
61c124364a | ||
|
|
0f62d15d74 | ||
|
|
536c020bfb | ||
|
|
3c91103c3a | ||
|
|
3b196afe26 | ||
|
|
68934ba48c | ||
|
|
03ecc472b7 | ||
|
|
b103aab646 | ||
|
|
2d43858457 | ||
|
|
6b63b6b45d | ||
|
|
1c9573eeb9 | ||
|
|
97141af1f1 | ||
|
|
82a20e1fc5 | ||
|
|
75eea4162d | ||
|
|
5eb28d3ddf | ||
|
|
257174c459 | ||
|
|
37c09c2c55 | ||
|
|
85348610a6 | ||
|
|
9941473937 | ||
|
|
afcef4f3bb | ||
|
|
a44e4102db | ||
|
|
63c5d61616 | ||
|
|
14cbce97d4 | ||
|
|
e5d924a094 | ||
|
|
46c364bbb4 | ||
|
|
7eef9b986f | ||
|
|
af384ff6d1 | ||
|
|
3a62554fe2 | ||
|
|
717894c21c | ||
|
|
2f8bc97a5a | ||
|
|
5215a0bdb8 | ||
|
|
624296da0d | ||
|
|
c6028c4f32 | ||
|
|
2e4caf8261 | ||
|
|
5aea4aa6a1 | ||
|
|
cad95e779d | ||
|
|
c88b0d1d8a | ||
|
|
60371796f3 | ||
|
|
ac3941f4aa | ||
|
|
dbae407df6 | ||
|
|
181a27e94e | ||
|
|
9a367acbdc | ||
|
|
938e8869f2 | ||
|
|
a9203600c1 | ||
|
|
ad2028e473 | ||
|
|
7cb7c02bb2 | ||
|
|
836e33f821 | ||
|
|
8d37e8ddbc | ||
|
|
b71f0b6a27 | ||
|
|
375b2e3c12 | ||
|
|
216875ef05 | ||
|
|
ceaea5f214 | ||
|
|
fe20fb0bdb | ||
|
|
6a35ad4f98 | ||
|
|
a6cd33733f | ||
|
|
4b988e78ff | ||
|
|
b96f01089f | ||
|
|
4875c50c90 | ||
|
|
8458a8cd19 | ||
|
|
becec9dc95 | ||
|
|
a4bdb22bf4 |
12
.github/actions/build-android-app/action.yml
vendored
@@ -44,6 +44,18 @@ runs:
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Configure Gradle JVM memory for CI
|
||||
run: |
|
||||
mkdir -p android
|
||||
cat >> android/gradle.properties <<EOF
|
||||
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.daemon.performance.disable-logging=true
|
||||
org.gradle.daemon=true
|
||||
org.gradle.caching=true
|
||||
EOF
|
||||
shell: bash
|
||||
working-directory: apps/mobile-app
|
||||
|
||||
- name: Build JS bundle (Expo)
|
||||
run: |
|
||||
mkdir -p build
|
||||
|
||||
50
.github/workflows/dotnet-e2e-tests.yml
vendored
@@ -86,3 +86,53 @@ jobs:
|
||||
timeout_minutes: 60
|
||||
max_attempts: 3
|
||||
command: cd apps/server && dotnet test Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "FullyQualifiedName~.E2ETests.Tests.Client.Shard${{ matrix.shard }}."
|
||||
|
||||
browser-extension-tests:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet workload install wasm-tools
|
||||
|
||||
- name: Build server
|
||||
working-directory: apps/server
|
||||
run: dotnet build
|
||||
|
||||
- name: Build browser extension
|
||||
working-directory: apps/browser-extension
|
||||
run: |
|
||||
npm install
|
||||
npm run build:chrome
|
||||
|
||||
- name: Start dev database
|
||||
run: ./install.sh configure-dev-db start
|
||||
|
||||
- name: Ensure browsers are installed
|
||||
working-directory: apps/server
|
||||
run: pwsh Tests/AliasVault.E2ETests/bin/Debug/net9.0/playwright.ps1 install --with-deps
|
||||
|
||||
- name: Run ExtensionTests with retry
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 60
|
||||
max_attempts: 3
|
||||
command: cd apps/server && dotnet test Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category=ExtensionTests"
|
||||
|
||||
- name: Upload Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: extension-test-results
|
||||
path: TestResults-Extension.xml
|
||||
|
||||
3
.gitignore
vendored
@@ -431,3 +431,6 @@ temp
|
||||
|
||||
# Android keystore file (for publishing to Google Play)
|
||||
*.keystore
|
||||
|
||||
# Safari extension build files
|
||||
apps/browser-extension/safari-xcode/AliasVault/build
|
||||
|
||||
4
.vscode/AliasVault.code-workspace
vendored
@@ -23,5 +23,7 @@
|
||||
"path": "../shared"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
"settings": {
|
||||
"java.configuration.updateBuildConfiguration": "disabled"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SECURITY.md
|
||||
This document describes the encryption algorithms used by AliasVault in order to keep its user data secure.
|
||||
# ARCHITECTURE.md
|
||||
This document provides a high-level overview of the AliasVault architecture, focusing on the encryption algorithms used to ensure the security of user data.
|
||||
|
||||
## Overview
|
||||
AliasVault features a [zero-knowledge architecture](https://en.wikipedia.org/wiki/Zero-knowledge_service) and uses a combination of encryption algorithms to protect the data of its users.
|
||||
@@ -10,14 +10,18 @@ All data is encrypted at rest and in transit. This ensures that even if the Alia
|
||||
the user's data remains secure.
|
||||
|
||||
## Encryption algorithms
|
||||
The following encryption algorithms are used by AliasVault:
|
||||
The following encryption algorithms and standards are used by AliasVault:
|
||||
|
||||
- [Argon2id](#argon2id)
|
||||
- [SRP](#srp)
|
||||
- [AES-GCM](#aes-gcm)
|
||||
- [RSA-OAEP](#rsa-oaep)
|
||||
### Core Vault Encryption
|
||||
- [Argon2id](#argon2id) - Key derivation from master password
|
||||
- [SRP](#srp) - Secure authentication protocol
|
||||
- [AES-GCM](#aes-gcm) - Vault data encryption
|
||||
|
||||
Below is a detailed explanation of each encryption algorithm.
|
||||
### Additional Features
|
||||
- [RSA-OAEP](#rsa-oaep) - Email encryption
|
||||
- [Passkeys (WebAuthn)](#passkeys-webauthn) - Passwordless authentication
|
||||
|
||||
Below is a detailed explanation of each encryption algorithm and standard.
|
||||
|
||||
For more information about how these algorithms are specifically used in AliasVault, see the [Architecture Documentation](https://docs.aliasvault.net/architecture) section on the documentation site.
|
||||
|
||||
@@ -93,3 +97,39 @@ This implementation ensures that:
|
||||
- Even if the server is compromised, email contents remain encrypted and unreadable
|
||||
|
||||
More information about RSA-OAEP can be found on the [RSA-OAEP](https://en.wikipedia.org/wiki/Optimal_asymmetric_encryption_padding) Wikipedia page.
|
||||
|
||||
### Passkeys (WebAuthn)
|
||||
AliasVault includes a virtual passkey authenticator that is fully compatible with the WebAuthn Level 2 specification. This enables users to securely store and use passkeys across their devices through the encrypted vault, providing a seamless and secure alternative to traditional password authentication.
|
||||
|
||||
#### Implementation Details
|
||||
AliasVault implements passkey functionality across all supported platforms:
|
||||
- **Browser Extension**: Virtual authenticator using the Web Crypto API
|
||||
- **iOS**: Native Swift implementation using CryptoKit
|
||||
- **Android**: Native Kotlin implementation using AndroidKeyStore
|
||||
|
||||
All implementations follow the WebAuthn Level 2 specification and use:
|
||||
- ES256 (ECDSA P-256) for key pair generation
|
||||
- CBOR/COSE encoding for attestation objects
|
||||
- Proper authenticator data with WebAuthn flags (UP, UV, BE, BS, AT)
|
||||
- AliasVault AAGUID (Authenticator Attestation GUID): `a11a5faa-9f32-4b8c-8c5d-2f7d13e8c942`
|
||||
- Self-attestation (packed format) or none attestation
|
||||
- Sign count always 0 for syncable passkeys
|
||||
- BE/BS flags indicating backup-eligible and backed-up status
|
||||
|
||||
#### Key Features
|
||||
1. **Zero-Knowledge Passkey Storage**: Passkey private keys are stored as encrypted entries in the user's vault alongside passwords and other credentials. The server never has access to the unencrypted private keys.
|
||||
|
||||
2. **Cross-Platform Sync**: Passkeys automatically sync across all devices where the user's vault is accessible, enabling seamless authentication on any platform (browser extension, iOS app, or Android app).
|
||||
|
||||
3. **PRF Extension Support**: Implements the hmac-secret (PRF) extension, allowing relying parties to derive additional secrets from passkeys for encryption keys or other cryptographic operations. Currently supported on browser extension and iOS; Android support is pending due to limited Android API support.
|
||||
|
||||
4. **Standards Compliance**: Full adherence to WebAuthn Level 2 specification ensures compatibility with all WebAuthn-compliant relying parties and services.
|
||||
|
||||
#### Security Benefits
|
||||
- Private keys remain encrypted in the vault at all times
|
||||
- All passkey operations (key generation, signing) occur on the client device
|
||||
- Passkeys benefit from the same zero-knowledge architecture as passwords
|
||||
- Cross-device sync provides convenience without compromising security
|
||||
- Eliminates phishing risks through cryptographic domain binding
|
||||
|
||||
More information about WebAuthn can be found on the [WebAuthn specification](https://www.w3.org/TR/webauthn-2/) page.
|
||||
|
||||
@@ -102,7 +102,7 @@ AliasVault takes security seriously and implements various measures to protect y
|
||||
- Zero-knowledge architecture ensures the server never has access to your unencrypted data
|
||||
|
||||
For detailed information about our encryption implementation and security architecture, see the following documents:
|
||||
- [SECURITY.md](SECURITY.md)
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
- [Security Architecture Diagram](https://docs.aliasvault.net/architecture)
|
||||
|
||||
## Features & Roadmap
|
||||
|
||||
1
apps/.version/major.txt
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
1
apps/.version/minor.txt
Normal file
@@ -0,0 +1 @@
|
||||
24
|
||||
1
apps/.version/patch.txt
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
1
apps/.version/suffix.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
apps/.version/version.txt
Normal file
@@ -0,0 +1 @@
|
||||
0.24.0
|
||||
2527
apps/browser-extension/package-lock.json
generated
@@ -2,7 +2,7 @@
|
||||
"name": "aliasvault-browser-extension",
|
||||
"description": "AliasVault Browser Extension",
|
||||
"private": true,
|
||||
"version": "0.23.2",
|
||||
"version": "0.24.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:chrome": "wxt -b chrome",
|
||||
@@ -67,6 +67,6 @@
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.6.3",
|
||||
"vite-plugin-static-copy": "^2.3.2",
|
||||
"wxt": "^0.20.6"
|
||||
"wxt": "^0.20.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
CE0CAFE02D81A9F8006174AB /* icon in Resources */ = {isa = PBXBuildFile; fileRef = CE0CAFD82D81A9F8006174AB /* icon */; };
|
||||
CE0CAFE12D81A9F8006174AB /* assets in Resources */ = {isa = PBXBuildFile; fileRef = CE0CAFD92D81A9F8006174AB /* assets */; };
|
||||
CE0CAFE22D81A9F8006174AB /* src in Resources */ = {isa = PBXBuildFile; fileRef = CE0CAFDA2D81A9F8006174AB /* src */; };
|
||||
CE0E5BB02E7D8BA600515C80 /* AliasVault.icon in Resources */ = {isa = PBXBuildFile; fileRef = CE0E5BAF2E7D8BA600515C80 /* AliasVault.icon */; };
|
||||
CEA194E42EB6221B00EAB23B /* webauthn.js in Resources */ = {isa = PBXBuildFile; fileRef = CEA194E32EB6221B00EAB23B /* webauthn.js */; };
|
||||
CEA194E72EB6248D00EAB23B /* offscreen.html in Resources */ = {isa = PBXBuildFile; fileRef = CEA194E52EB6248D00EAB23B /* offscreen.html */; };
|
||||
CEA194E82EB6248D00EAB23B /* offscreen.js in Resources */ = {isa = PBXBuildFile; fileRef = CEA194E62EB6248D00EAB23B /* offscreen.js */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -76,6 +80,10 @@
|
||||
CE0CAFD82D81A9F8006174AB /* icon */ = {isa = PBXFileReference; lastKnownFileType = folder; name = icon; path = "../../../dist/safari-mv2/icon"; sourceTree = "<group>"; };
|
||||
CE0CAFD92D81A9F8006174AB /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; name = assets; path = "../../../dist/safari-mv2/assets"; sourceTree = "<group>"; };
|
||||
CE0CAFDA2D81A9F8006174AB /* src */ = {isa = PBXFileReference; lastKnownFileType = folder; name = src; path = "../../../dist/safari-mv2/src"; sourceTree = "<group>"; };
|
||||
CE0E5BAF2E7D8BA600515C80 /* AliasVault.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AliasVault.icon; sourceTree = "<group>"; };
|
||||
CEA194E32EB6221B00EAB23B /* webauthn.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = webauthn.js; path = "/Users/user/Projects/AliasVault/apps/browser-extension/dist/safari-mv2/webauthn.js"; sourceTree = "<absolute>"; };
|
||||
CEA194E52EB6248D00EAB23B /* offscreen.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = offscreen.html; path = "/Users/user/Projects/AliasVault/apps/browser-extension/dist/safari-mv2/offscreen.html"; sourceTree = "<absolute>"; };
|
||||
CEA194E62EB6248D00EAB23B /* offscreen.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = offscreen.js; path = "/Users/user/Projects/AliasVault/apps/browser-extension/dist/safari-mv2/offscreen.js"; sourceTree = "<absolute>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -117,6 +125,7 @@
|
||||
CE0CAFA52D81A9F7006174AB /* AliasVault */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CE0E5BAF2E7D8BA600515C80 /* AliasVault.icon */,
|
||||
CE0CAFA62D81A9F7006174AB /* AppDelegate.swift */,
|
||||
CE0CAFB22D81A9F7006174AB /* ViewController.swift */,
|
||||
CE0CAFB42D81A9F7006174AB /* Main.storyboard */,
|
||||
@@ -154,6 +163,9 @@
|
||||
CE0CAFD22D81A9F8006174AB /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CEA194E52EB6248D00EAB23B /* offscreen.html */,
|
||||
CEA194E62EB6248D00EAB23B /* offscreen.js */,
|
||||
CEA194E32EB6221B00EAB23B /* webauthn.js */,
|
||||
CE0CAFD32D81A9F8006174AB /* background.js */,
|
||||
CE0CAFD42D81A9F8006174AB /* popup.html */,
|
||||
CE0CAFD52D81A9F8006174AB /* chunks */,
|
||||
@@ -248,6 +260,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CE0E5BB02E7D8BA600515C80 /* AliasVault.icon in Resources */,
|
||||
CE0CAFAD2D81A9F7006174AB /* Icon.png in Resources */,
|
||||
CE0CAFB12D81A9F7006174AB /* Script.js in Resources */,
|
||||
CE0CAFAB2D81A9F7006174AB /* Base in Resources */,
|
||||
@@ -262,8 +275,11 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CE0CAFDD2D81A9F8006174AB /* chunks in Resources */,
|
||||
CEA194E42EB6221B00EAB23B /* webauthn.js in Resources */,
|
||||
CE0CAFE02D81A9F8006174AB /* icon in Resources */,
|
||||
CE0CAFE12D81A9F8006174AB /* assets in Resources */,
|
||||
CEA194E72EB6248D00EAB23B /* offscreen.html in Resources */,
|
||||
CEA194E82EB6248D00EAB23B /* offscreen.js in Resources */,
|
||||
CE0CAFE22D81A9F8006174AB /* src in Resources */,
|
||||
CE0CAFDB2D81A9F8006174AB /* background.js in Resources */,
|
||||
CE0CAFDF2D81A9F8006174AB /* manifest.json in Resources */,
|
||||
@@ -447,7 +463,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 230200;
|
||||
CURRENT_PROJECT_VERSION = 2400902;
|
||||
DEVELOPMENT_TEAM = 8PHW4HN3F7;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -460,7 +476,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 0.23.2;
|
||||
MARKETING_VERSION = 0.24.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -479,7 +495,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = "AliasVault Extension/AliasVault_Extension.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 230200;
|
||||
CURRENT_PROJECT_VERSION = 2400902;
|
||||
DEVELOPMENT_TEAM = 8PHW4HN3F7;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -492,7 +508,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 0.23.2;
|
||||
MARKETING_VERSION = 0.24.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -509,13 +525,13 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AliasVault;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = AliasVault/AliasVault.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 230200;
|
||||
CURRENT_PROJECT_VERSION = 2400902;
|
||||
DEVELOPMENT_TEAM = 8PHW4HN3F7;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -530,7 +546,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 0.23.2;
|
||||
MARKETING_VERSION = 0.24.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -549,12 +565,12 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AliasVault;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = AliasVault/AliasVault.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 230200;
|
||||
CURRENT_PROJECT_VERSION = 2400902;
|
||||
DEVELOPMENT_TEAM = 8PHW4HN3F7;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -569,7 +585,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 0.23.2;
|
||||
MARKETING_VERSION = 0.24.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
|
||||
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"fill" : "automatic",
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"blend-mode" : "overlay",
|
||||
"fill" : {
|
||||
"automatic-gradient" : "display-p3:0.90471,0.76358,0.48553,1.00000"
|
||||
},
|
||||
"glass" : true,
|
||||
"hidden" : false,
|
||||
"image-name" : "icon-1024.png",
|
||||
"name" : "icon-1024"
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "16x16",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-16@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "16x16",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-16@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "32x32",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-32@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "32x32",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-32@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "128x128",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-128@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "128x128",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-128@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "256x256",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-256@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "256x256",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-256@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "512x512",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-512@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "512x512",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-512@2x.png",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 721 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 160 KiB |
179
apps/browser-extension/safari-xcode/AliasVault/build-and-submit.sh
Executable file
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
BUNDLE_ID="net.aliasvault.safari.extension"
|
||||
|
||||
# Build settings
|
||||
SCHEME="AliasVault"
|
||||
PROJECT="AliasVault.xcodeproj"
|
||||
CONFIG="Release"
|
||||
ARCHIVE_PATH="$PWD/build/${SCHEME}.xcarchive"
|
||||
EXPORT_DIR="$PWD/build/export"
|
||||
EXPORT_PLIST="$PWD/exportOptions.plist"
|
||||
|
||||
# Put the fastlane API key in the home directory
|
||||
API_KEY_PATH="$HOME/APPSTORE_CONNECT_FASTLANE.json"
|
||||
|
||||
# ------------------------------------------
|
||||
|
||||
if [ ! -f "$API_KEY_PATH" ]; then
|
||||
echo "❌ API key file '$API_KEY_PATH' does not exist. Please provide the App Store Connect API key at this path."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ------------------------------------------
|
||||
# Shared function to extract version info
|
||||
# ------------------------------------------
|
||||
extract_version_info() {
|
||||
local pkg_path="$1"
|
||||
|
||||
# For .pkg files, we need to expand and find the Info.plist
|
||||
local temp_dir=$(mktemp -d -t aliasvault-pkg-extract)
|
||||
trap "rm -rf '$temp_dir'" EXIT
|
||||
|
||||
# Expand the pkg to find the app bundle
|
||||
pkgutil --expand "$pkg_path" "$temp_dir/expanded" 2>/dev/null
|
||||
|
||||
# Find the payload and extract it
|
||||
local payload=$(find "$temp_dir/expanded" -name "Payload" | head -n 1)
|
||||
|
||||
if [ -n "$payload" ]; then
|
||||
mkdir -p "$temp_dir/contents"
|
||||
cd "$temp_dir/contents"
|
||||
cat "$payload" | gunzip -dc | cpio -i 2>/dev/null
|
||||
|
||||
# Find Info.plist in the extracted contents
|
||||
local info_plist=$(find "$temp_dir/contents" -name "Info.plist" -path "*/Contents/Info.plist" | head -n 1)
|
||||
|
||||
if [ -n "$info_plist" ]; then
|
||||
# Read version and build from the plist
|
||||
VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$info_plist" 2>/dev/null)
|
||||
BUILD=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$info_plist" 2>/dev/null)
|
||||
|
||||
if [ -n "$VERSION" ] && [ -n "$BUILD" ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback: try to read from the archive directly if it's in a known location
|
||||
local archive_plist="$ARCHIVE_PATH/Info.plist"
|
||||
if [ -f "$archive_plist" ]; then
|
||||
VERSION=$(/usr/libexec/PlistBuddy -c "Print :ApplicationProperties:CFBundleShortVersionString" "$archive_plist" 2>/dev/null)
|
||||
BUILD=$(/usr/libexec/PlistBuddy -c "Print :ApplicationProperties:CFBundleVersion" "$archive_plist" 2>/dev/null)
|
||||
|
||||
if [ -n "$VERSION" ] && [ -n "$BUILD" ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "❌ Could not extract version info from package"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ------------------------------------------
|
||||
# Ask if user wants to build or use existing
|
||||
# ------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "What do you want to do?"
|
||||
echo " 1) Build and submit to App Store"
|
||||
echo " 2) Build only"
|
||||
echo " 3) Submit existing PKG to App Store"
|
||||
echo ""
|
||||
read -p "Enter choice (1, 2, or 3): " -r CHOICE
|
||||
echo ""
|
||||
|
||||
# ------------------------------------------
|
||||
# Build PKG (for options 1 and 2)
|
||||
# ------------------------------------------
|
||||
|
||||
if [[ $CHOICE == "1" || $CHOICE == "2" ]]; then
|
||||
echo "Building browser extension..."
|
||||
cd ../..
|
||||
npm run build:safari
|
||||
cd safari-xcode/AliasVault
|
||||
|
||||
echo "Building PKG..."
|
||||
|
||||
# Clean + archive
|
||||
xcodebuild \
|
||||
-project "$PROJECT" \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration "$CONFIG" \
|
||||
-archivePath "$ARCHIVE_PATH" \
|
||||
clean archive \
|
||||
-allowProvisioningUpdates
|
||||
|
||||
# Export .pkg
|
||||
rm -rf "$EXPORT_DIR"
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE_PATH" \
|
||||
-exportOptionsPlist "$EXPORT_PLIST" \
|
||||
-exportPath "$EXPORT_DIR" \
|
||||
-allowProvisioningUpdates
|
||||
|
||||
PKG_PATH=$(ls "$EXPORT_DIR"/*.pkg)
|
||||
|
||||
# Extract version info from newly built PKG
|
||||
extract_version_info "$PKG_PATH"
|
||||
echo "PKG built at: $PKG_PATH"
|
||||
echo " Version: $VERSION"
|
||||
echo " Build: $BUILD"
|
||||
echo ""
|
||||
|
||||
# Exit if build-only
|
||||
if [[ $CHOICE == "2" ]]; then
|
||||
echo "✅ Build complete. Exiting."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# ------------------------------------------
|
||||
# Submit to App Store (for options 1 and 3)
|
||||
# ------------------------------------------
|
||||
|
||||
if [[ $CHOICE == "3" ]]; then
|
||||
# Use existing PKG
|
||||
PKG_PATH="$EXPORT_DIR/AliasVault.pkg"
|
||||
|
||||
if [ ! -f "$PKG_PATH" ]; then
|
||||
echo "❌ PKG file not found at: $PKG_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract version info from existing PKG
|
||||
extract_version_info "$PKG_PATH"
|
||||
echo "Using existing PKG: $PKG_PATH"
|
||||
echo " Version: $VERSION"
|
||||
echo " Build: $BUILD"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ $CHOICE != "1" && $CHOICE != "3" ]]; then
|
||||
echo "❌ Invalid choice. Please enter 1, 2, or 3."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo "Submitting to App Store:"
|
||||
echo " Version: $VERSION"
|
||||
echo " Build: $BUILD"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
read -p "Are you sure you want to push this to App Store? (y/n): " -r
|
||||
echo ""
|
||||
|
||||
if [[ ! $REPLY =~ ^([Yy]([Ee][Ss])?|[Yy])$ ]]; then
|
||||
echo "❌ Submission cancelled"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Proceeding with upload..."
|
||||
|
||||
fastlane deliver \
|
||||
--pkg "$PKG_PATH" \
|
||||
--skip_screenshots \
|
||||
--skip_metadata \
|
||||
--api_key_path "$API_KEY_PATH" \
|
||||
--run_precheck_before_submit=false
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
<key>destination</key>
|
||||
<string>export</string>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>compileBitcode</key>
|
||||
<true/>
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,13 +1,18 @@
|
||||
/**
|
||||
* Background script entry point - handles messages from the content script
|
||||
*/
|
||||
|
||||
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 { handleGetWebAuthnSettings, handleWebAuthnCreate, handleWebAuthnGet, handlePasskeyPopupResponse, handleGetRequestData } from '@/entrypoints/background/PasskeyHandler';
|
||||
import { handleOpenPopup, handlePopupWithCredential, handleOpenPopupCreateCredential, handleToggleContextMenu } from '@/entrypoints/background/PopupMessageHandler';
|
||||
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 { EncryptionKeyDerivationParams } from "@/utils/dist/shared/models/metadata";
|
||||
|
||||
import { defineBackground, storage, browser } from '#imports';
|
||||
|
||||
@@ -62,6 +67,13 @@ export default defineBackground({
|
||||
// Handle clipboard copied from context menu
|
||||
onMessage('CLIPBOARD_COPIED_FROM_CONTEXT', () => handleClipboardCopied());
|
||||
|
||||
// Passkey/WebAuthn management messages
|
||||
onMessage('GET_WEBAUTHN_SETTINGS', ({ data }) => handleGetWebAuthnSettings(data));
|
||||
onMessage('WEBAUTHN_CREATE', ({ data }) => handleWebAuthnCreate(data));
|
||||
onMessage('WEBAUTHN_GET', ({ data }) => handleWebAuthnGet(data));
|
||||
onMessage('PASSKEY_POPUP_RESPONSE', ({ data }) => handlePasskeyPopupResponse(data));
|
||||
onMessage('GET_REQUEST_DATA', ({ data }) => handleGetRequestData(data));
|
||||
|
||||
// Setup context menus
|
||||
const isContextMenuEnabled = await storage.getItem(GLOBAL_CONTEXT_MENU_ENABLED_KEY) ?? true;
|
||||
if (isContextMenuEnabled) {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* PasskeyHandler - Handles passkey popup management in background
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { extractDomain, extractRootDomain } from '@/entrypoints/contentScript/Filter';
|
||||
|
||||
import {
|
||||
PASSKEY_PROVIDER_ENABLED_KEY,
|
||||
PASSKEY_DISABLED_SITES_KEY
|
||||
} from '@/utils/Constants';
|
||||
import type {
|
||||
PasskeyPopupResponse,
|
||||
WebAuthnCreateRequest,
|
||||
WebAuthnGetRequest,
|
||||
PendingPasskeyRequest,
|
||||
PendingPasskeyCreateRequest,
|
||||
PendingPasskeyGetRequest,
|
||||
WebAuthnSettingsResponse,
|
||||
WebAuthnCreationPayload,
|
||||
WebAuthnPublicKeyGetPayload
|
||||
} from '@/utils/passkey/types';
|
||||
|
||||
import { browser, storage } from '#imports';
|
||||
|
||||
// Pending popup requests
|
||||
const pendingRequests = new Map<string, {
|
||||
resolve: (value: any) => void;
|
||||
reject: (error: any) => void;
|
||||
/**
|
||||
* Store window ID in order to close the popup window from background script later.
|
||||
*/
|
||||
windowId?: number;
|
||||
}>();
|
||||
|
||||
// Store request data temporarily (to avoid URL length limits)
|
||||
const pendingRequestData = new Map<string, PendingPasskeyRequest>();
|
||||
|
||||
/**
|
||||
* Handle WebAuthn settings request
|
||||
*/
|
||||
export async function handleGetWebAuthnSettings(data: any): Promise<WebAuthnSettingsResponse> {
|
||||
// Check if passkey provider is enabled in settings (default to true if not set)
|
||||
const globalEnabled = await storage.getItem(PASSKEY_PROVIDER_ENABLED_KEY);
|
||||
if (globalEnabled === false) {
|
||||
return { enabled: false };
|
||||
}
|
||||
|
||||
// If hostname is provided, check if it's disabled for that site
|
||||
const { hostname } = data || {};
|
||||
if (hostname) {
|
||||
// Extract base domain for matching
|
||||
const baseDomain = extractRootDomain(extractDomain(hostname));
|
||||
|
||||
// Check disabled sites
|
||||
const disabledSites = await storage.getItem(PASSKEY_DISABLED_SITES_KEY) as string[] ?? [];
|
||||
if (disabledSites.includes(baseDomain)) {
|
||||
return { enabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
return { enabled: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebAuthn create (registration) request
|
||||
*/
|
||||
export async function handleWebAuthnCreate(data: any): Promise<any> {
|
||||
const { publicKey, origin } = data as WebAuthnCreateRequest;
|
||||
const requestId = Math.random().toString(36).substr(2, 9);
|
||||
|
||||
// Store request data temporarily (to avoid URL length limits)
|
||||
const requestData: PendingPasskeyCreateRequest = {
|
||||
type: 'create',
|
||||
requestId,
|
||||
origin,
|
||||
publicKey: publicKey as WebAuthnCreationPayload
|
||||
};
|
||||
pendingRequestData.set(requestId, requestData);
|
||||
|
||||
// Create popup using main popup with hash navigation - only pass requestId
|
||||
const popupUrl = browser.runtime.getURL('/popup.html') + '#/passkeys/create?' + new URLSearchParams({
|
||||
requestId
|
||||
}).toString();
|
||||
|
||||
try {
|
||||
const popup = await browser.windows.create({
|
||||
url: popupUrl,
|
||||
type: 'popup',
|
||||
width: 450,
|
||||
height: 600,
|
||||
focused: true
|
||||
});
|
||||
|
||||
// Wait for response from popup
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingRequests.set(requestId, { resolve, reject, windowId: popup.id });
|
||||
|
||||
// Clean up if popup is closed without response
|
||||
const checkClosed = setInterval(async () => {
|
||||
try {
|
||||
if (popup.id) {
|
||||
const _window = await browser.windows.get(popup.id);
|
||||
// Window still exists, continue waiting
|
||||
}
|
||||
} catch {
|
||||
// Window no longer exists
|
||||
clearInterval(checkClosed);
|
||||
if (pendingRequests.has(requestId)) {
|
||||
pendingRequests.delete(requestId);
|
||||
pendingRequestData.delete(requestId);
|
||||
resolve({ cancelled: true });
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
} catch {
|
||||
return { error: 'Failed to create popup window' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebAuthn get (authentication) request
|
||||
* Note: Passkey retrieval is now handled in the popup via SqliteClient
|
||||
*/
|
||||
export async function handleWebAuthnGet(data: any): Promise<any> {
|
||||
const { publicKey, origin } = data as WebAuthnGetRequest;
|
||||
const requestId = Math.random().toString(36).substr(2, 9);
|
||||
|
||||
// Store request data temporarily (to avoid URL length limits)
|
||||
const requestData: PendingPasskeyGetRequest = {
|
||||
type: 'get',
|
||||
requestId,
|
||||
origin,
|
||||
publicKey: publicKey as WebAuthnPublicKeyGetPayload,
|
||||
passkeys: [] // Will be populated by the popup from vault
|
||||
};
|
||||
pendingRequestData.set(requestId, requestData);
|
||||
|
||||
// Create popup using main popup with hash navigation - only pass requestId
|
||||
const popupUrl = browser.runtime.getURL('/popup.html') + '#/passkeys/authenticate?' + new URLSearchParams({
|
||||
requestId
|
||||
}).toString();
|
||||
|
||||
try {
|
||||
const popup = await browser.windows.create({
|
||||
url: popupUrl,
|
||||
type: 'popup',
|
||||
width: 450,
|
||||
height: 600,
|
||||
focused: true
|
||||
});
|
||||
|
||||
// Wait for response from popup
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingRequests.set(requestId, { resolve, reject, windowId: popup.id });
|
||||
|
||||
// Clean up if popup is closed without response
|
||||
const checkClosed = setInterval(async () => {
|
||||
try {
|
||||
if (popup.id) {
|
||||
const _window = await browser.windows.get(popup.id);
|
||||
// Window still exists, continue waiting
|
||||
}
|
||||
} catch {
|
||||
// Window no longer exists
|
||||
clearInterval(checkClosed);
|
||||
if (pendingRequests.has(requestId)) {
|
||||
pendingRequests.delete(requestId);
|
||||
pendingRequestData.delete(requestId);
|
||||
resolve({ cancelled: true });
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
} catch {
|
||||
return { error: 'Failed to create popup window' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle response from passkey popup
|
||||
*/
|
||||
export async function handlePasskeyPopupResponse(data: any): Promise<{ success: boolean }> {
|
||||
const { requestId, credential, fallback, cancelled } = data as PasskeyPopupResponse;
|
||||
const request = pendingRequests.get(requestId);
|
||||
|
||||
if (!request) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the popup window from background script to ensure it always works.
|
||||
* Calling window.close() from the popup does not work in all browsers.
|
||||
*/
|
||||
if (request.windowId) {
|
||||
try {
|
||||
await browser.windows.remove(request.windowId);
|
||||
} catch (error) {
|
||||
// Window might already be closed, ignore error
|
||||
console.debug('Failed to close popup window:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up both maps
|
||||
pendingRequests.delete(requestId);
|
||||
pendingRequestData.delete(requestId);
|
||||
|
||||
if (cancelled) {
|
||||
request.resolve({ cancelled: true });
|
||||
} else if (fallback) {
|
||||
request.resolve({ fallback: true });
|
||||
} else if (credential) {
|
||||
request.resolve({ credential });
|
||||
} else {
|
||||
request.resolve({ cancelled: true });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request data by request ID
|
||||
*/
|
||||
export async function handleGetRequestData(data: any): Promise<PendingPasskeyRequest | null> {
|
||||
const { requestId } = data as { requestId: string };
|
||||
const requestData = pendingRequestData.get(requestId);
|
||||
return requestData || null;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { EncryptionKeyDerivationParams } from '@/utils/dist/shared/models/m
|
||||
import type { Vault, VaultResponse, VaultPostResponse } from '@/utils/dist/shared/models/webapi';
|
||||
import { EncryptionUtility } from '@/utils/EncryptionUtility';
|
||||
import { SqliteClient } from '@/utils/SqliteClient';
|
||||
import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError';
|
||||
import { BoolResponse as messageBoolResponse } from '@/utils/types/messaging/BoolResponse';
|
||||
import { CredentialsResponse as messageCredentialsResponse } from '@/utils/types/messaging/CredentialsResponse';
|
||||
import { IdentitySettingsResponse } from '@/utils/types/messaging/IdentitySettingsResponse';
|
||||
@@ -57,6 +58,18 @@ export async function handleCheckAuthStatus() : Promise<{ isLoggedIn: boolean, i
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking pending migrations:', error);
|
||||
|
||||
// If it's a version incompatibility error, we need to handle it specially
|
||||
if (error instanceof VaultVersionIncompatibleError) {
|
||||
// Return the error so the UI can handle it appropriately (logout user)
|
||||
return {
|
||||
isLoggedIn,
|
||||
isVaultLocked,
|
||||
hasPendingMigrations: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isLoggedIn,
|
||||
isVaultLocked,
|
||||
@@ -98,7 +111,7 @@ export async function handleStoreVault(
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to store vault:', error);
|
||||
return { success: false, error: await t('common.errors.failedToStoreVault') };
|
||||
return { success: false, error: await t('common.errors.unknownError') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,7 +502,7 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<Vaul
|
||||
client: '', // Empty on purpose, API will not use this for vault updates.
|
||||
updatedAt: new Date().toISOString(),
|
||||
username: username,
|
||||
version: sqliteClient.getDatabaseVersion().version
|
||||
version: (await sqliteClient.getDatabaseVersion()).version
|
||||
};
|
||||
|
||||
const webApi = new WebApiService(() => {});
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* Content script entry point - handles autofill UI and WebAuthn passkey interception
|
||||
*/
|
||||
|
||||
import '@/entrypoints/contentScript/style.css';
|
||||
import { onMessage } from "webext-bridge/content-script";
|
||||
|
||||
import { injectIcon, popupDebounceTimeHasPassed, validateInputField } from '@/entrypoints/contentScript/Form';
|
||||
import { isAutoShowPopupEnabled, openAutofillPopup, removeExistingPopup, createUpgradeRequiredPopup } from '@/entrypoints/contentScript/Popup';
|
||||
import { initializeWebAuthnInterceptor } from '@/entrypoints/contentScript/WebAuthnInterceptor';
|
||||
|
||||
import { FormDetector } from '@/utils/formDetector/FormDetector';
|
||||
import { BoolResponse as messageBoolResponse } from '@/utils/types/messaging/BoolResponse';
|
||||
@@ -26,6 +31,9 @@ export default defineContentScript({
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize WebAuthn interceptor for passkey support
|
||||
await initializeWebAuthnInterceptor(ctx);
|
||||
|
||||
// Wait for 750ms to give the host page time to load and to increase the chance that the body is available and ready.
|
||||
await new Promise(resolve => setTimeout(resolve, 750));
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ type CredentialWithPriority = Credential & {
|
||||
* @param url - URL or domain string
|
||||
* @returns Normalized domain without protocol or www
|
||||
*/
|
||||
function extractDomain(url: string): string {
|
||||
export function extractDomain(url: string): string {
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
@@ -36,6 +36,92 @@ function extractDomain(url: string): string {
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract root domain from a domain string.
|
||||
* E.g., "sub.example.com" -> "example.com"
|
||||
* E.g., "sub.example.com.au" -> "example.com.au"
|
||||
* E.g., "sub.example.co.uk" -> "example.co.uk"
|
||||
*/
|
||||
export function extractRootDomain(domain: string): string {
|
||||
const parts = domain.split('.');
|
||||
if (parts.length < 2) {
|
||||
return domain;
|
||||
}
|
||||
|
||||
// Common two-level public TLDs
|
||||
const twoLevelTlds = new Set([
|
||||
// Australia
|
||||
'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'asn.au', 'id.au',
|
||||
// United Kingdom
|
||||
'co.uk', 'org.uk', 'net.uk', 'ac.uk', 'gov.uk', 'plc.uk', 'ltd.uk', 'me.uk',
|
||||
// Canada
|
||||
'co.ca', 'net.ca', 'org.ca', 'gc.ca', 'ab.ca', 'bc.ca', 'mb.ca', 'nb.ca', 'nf.ca', 'nl.ca', 'ns.ca', 'nt.ca', 'nu.ca',
|
||||
'on.ca', 'pe.ca', 'qc.ca', 'sk.ca', 'yk.ca',
|
||||
// India
|
||||
'co.in', 'net.in', 'org.in', 'edu.in', 'gov.in', 'ac.in', 'res.in', 'gen.in', 'firm.in', 'ind.in',
|
||||
// Japan
|
||||
'co.jp', 'ne.jp', 'or.jp', 'ac.jp', 'ad.jp', 'ed.jp', 'go.jp', 'gr.jp', 'lg.jp',
|
||||
// South Africa
|
||||
'co.za', 'net.za', 'org.za', 'edu.za', 'gov.za', 'ac.za', 'web.za',
|
||||
// New Zealand
|
||||
'co.nz', 'net.nz', 'org.nz', 'edu.nz', 'govt.nz', 'ac.nz', 'geek.nz', 'gen.nz', 'kiwi.nz', 'maori.nz', 'mil.nz', 'school.nz',
|
||||
// Brazil
|
||||
'com.br', 'net.br', 'org.br', 'edu.br', 'gov.br', 'mil.br', 'art.br', 'etc.br', 'adv.br', 'arq.br', 'bio.br', 'cim.br',
|
||||
'cng.br', 'cnt.br', 'ecn.br', 'eng.br', 'esp.br', 'eti.br', 'far.br', 'fnd.br', 'fot.br', 'fst.br', 'g12.br', 'geo.br',
|
||||
'ggf.br', 'jor.br', 'lel.br', 'mat.br', 'med.br', 'mus.br', 'not.br', 'ntr.br', 'odo.br', 'ppg.br', 'pro.br', 'psc.br',
|
||||
'psi.br', 'qsl.br', 'rec.br', 'slg.br', 'srv.br', 'tmp.br', 'trd.br', 'tur.br', 'tv.br', 'vet.br', 'zlg.br',
|
||||
// Russia
|
||||
'com.ru', 'net.ru', 'org.ru', 'edu.ru', 'gov.ru', 'int.ru', 'mil.ru', 'spb.ru', 'msk.ru',
|
||||
// China
|
||||
'com.cn', 'net.cn', 'org.cn', 'edu.cn', 'gov.cn', 'mil.cn', 'ac.cn', 'ah.cn', 'bj.cn', 'cq.cn', 'fj.cn', 'gd.cn', 'gs.cn',
|
||||
'gz.cn', 'gx.cn', 'ha.cn', 'hb.cn', 'he.cn', 'hi.cn', 'hk.cn', 'hl.cn', 'hn.cn', 'jl.cn', 'js.cn', 'jx.cn', 'ln.cn', 'mo.cn',
|
||||
'nm.cn', 'nx.cn', 'qh.cn', 'sc.cn', 'sd.cn', 'sh.cn', 'sn.cn', 'sx.cn', 'tj.cn', 'tw.cn', 'xj.cn', 'xz.cn', 'yn.cn', 'zj.cn',
|
||||
// Mexico
|
||||
'com.mx', 'net.mx', 'org.mx', 'edu.mx', 'gob.mx',
|
||||
// Argentina
|
||||
'com.ar', 'net.ar', 'org.ar', 'edu.ar', 'gov.ar', 'mil.ar', 'int.ar',
|
||||
// Chile
|
||||
'com.cl', 'net.cl', 'org.cl', 'edu.cl', 'gov.cl', 'mil.cl',
|
||||
// Colombia
|
||||
'com.co', 'net.co', 'org.co', 'edu.co', 'gov.co', 'mil.co', 'nom.co',
|
||||
// Venezuela
|
||||
'com.ve', 'net.ve', 'org.ve', 'edu.ve', 'gov.ve', 'mil.ve', 'web.ve',
|
||||
// Peru
|
||||
'com.pe', 'net.pe', 'org.pe', 'edu.pe', 'gob.pe', 'mil.pe', 'nom.pe',
|
||||
// Ecuador
|
||||
'com.ec', 'net.ec', 'org.ec', 'edu.ec', 'gov.ec', 'mil.ec', 'med.ec', 'fin.ec', 'pro.ec', 'info.ec',
|
||||
// Europe
|
||||
'co.at', 'or.at', 'ac.at', 'gv.at', 'priv.at',
|
||||
'co.be', 'ac.be',
|
||||
'co.dk', 'ac.dk',
|
||||
'co.il', 'net.il', 'org.il', 'ac.il', 'gov.il', 'idf.il', 'k12.il', 'muni.il',
|
||||
'co.no', 'ac.no', 'priv.no',
|
||||
'co.pl', 'net.pl', 'org.pl', 'edu.pl', 'gov.pl', 'mil.pl', 'nom.pl', 'com.pl',
|
||||
'co.th', 'net.th', 'org.th', 'edu.th', 'gov.th', 'mil.th', 'ac.th', 'in.th',
|
||||
'co.kr', 'net.kr', 'org.kr', 'edu.kr', 'gov.kr', 'mil.kr', 'ac.kr', 'go.kr', 'ne.kr', 'or.kr', 'pe.kr', 're.kr', 'seoul.kr',
|
||||
'kyonggi.kr',
|
||||
// Others
|
||||
'co.id', 'net.id', 'org.id', 'edu.id', 'gov.id', 'mil.id', 'web.id', 'ac.id', 'sch.id',
|
||||
'co.ma', 'net.ma', 'org.ma', 'edu.ma', 'gov.ma', 'ac.ma', 'press.ma',
|
||||
'co.ke', 'net.ke', 'org.ke', 'edu.ke', 'gov.ke', 'ac.ke', 'go.ke', 'info.ke', 'me.ke', 'mobi.ke', 'sc.ke',
|
||||
'co.ug', 'net.ug', 'org.ug', 'edu.ug', 'gov.ug', 'ac.ug', 'sc.ug', 'go.ug', 'ne.ug', 'or.ug',
|
||||
'co.tz', 'net.tz', 'org.tz', 'edu.tz', 'gov.tz', 'ac.tz', 'go.tz', 'hotel.tz', 'info.tz', 'me.tz', 'mil.tz', 'mobi.tz',
|
||||
'ne.tz', 'or.tz', 'sc.tz', 'tv.tz',
|
||||
]);
|
||||
|
||||
// Check if the last two parts form a known two-level TLD
|
||||
if (parts.length >= 3) {
|
||||
const lastTwoParts = parts.slice(-2).join('.');
|
||||
if (twoLevelTlds.has(lastTwoParts)) {
|
||||
// Take the last three parts for two-level TLDs
|
||||
return parts.slice(-3).join('.');
|
||||
}
|
||||
}
|
||||
|
||||
// Default to last two parts for regular TLDs
|
||||
return parts.length >= 2 ? parts.slice(-2).join('.') : domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two domains match, supporting partial matches
|
||||
* @param domain1 - First domain
|
||||
@@ -60,13 +146,9 @@ function domainsMatch(domain1: string, domain2: string): boolean {
|
||||
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('.');
|
||||
// Check root domain match
|
||||
const d1Root = extractRootDomain(d1);
|
||||
const d2Root = extractRootDomain(d2);
|
||||
|
||||
return d1Root === d2Root;
|
||||
}
|
||||
|
||||
@@ -633,10 +633,44 @@ function createCredentialList(credentials: Credential[], input: HTMLInputElement
|
||||
const credTextContainer = document.createElement('div');
|
||||
credTextContainer.className = 'av-credential-text';
|
||||
|
||||
// Service name (primary text)
|
||||
// Service name (primary text) with passkey indicator
|
||||
const serviceName = document.createElement('div');
|
||||
serviceName.className = 'av-service-name';
|
||||
serviceName.textContent = cred.ServiceName;
|
||||
|
||||
// Create a flex container for service name and passkey icon
|
||||
const serviceNameContainer = document.createElement('div');
|
||||
serviceNameContainer.style.display = 'flex';
|
||||
serviceNameContainer.style.alignItems = 'center';
|
||||
serviceNameContainer.style.gap = '4px';
|
||||
|
||||
const serviceNameText = document.createElement('span');
|
||||
serviceNameText.textContent = cred.ServiceName;
|
||||
serviceNameContainer.appendChild(serviceNameText);
|
||||
|
||||
// Add passkey indicator if credential has a passkey
|
||||
if (cred.HasPasskey) {
|
||||
const passkeyIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
passkeyIcon.setAttribute('class', 'av-passkey-icon');
|
||||
passkeyIcon.setAttribute('viewBox', '0 0 24 24');
|
||||
passkeyIcon.setAttribute('fill', 'none');
|
||||
passkeyIcon.setAttribute('stroke', 'currentColor');
|
||||
passkeyIcon.setAttribute('stroke-width', '2');
|
||||
passkeyIcon.setAttribute('stroke-linecap', 'round');
|
||||
passkeyIcon.setAttribute('stroke-linejoin', 'round');
|
||||
passkeyIcon.setAttribute('aria-label', 'Has passkey');
|
||||
passkeyIcon.style.width = '14px';
|
||||
passkeyIcon.style.height = '14px';
|
||||
passkeyIcon.style.flexShrink = '0';
|
||||
passkeyIcon.style.opacity = '0.7';
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4');
|
||||
|
||||
passkeyIcon.appendChild(path);
|
||||
serviceNameContainer.appendChild(passkeyIcon);
|
||||
}
|
||||
|
||||
serviceName.appendChild(serviceNameContainer);
|
||||
|
||||
// Details container (secondary text)
|
||||
const detailsContainer = document.createElement('div');
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* WebAuthn Interceptor - Handles communication between page and extension
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { sendMessage } from 'webext-bridge/content-script';
|
||||
|
||||
import type { WebAuthnSettingsResponse } from '@/utils/passkey/types';
|
||||
|
||||
import { browser } from '#imports';
|
||||
|
||||
// Firefox-specific global function for cloning objects into page context
|
||||
declare function cloneInto<T>(obj: T, targetScope: any): T;
|
||||
|
||||
let interceptorInitialized = false;
|
||||
|
||||
/**
|
||||
* Track last cancelled request to prevent rapid-fire popups.
|
||||
* This is used to track the last time a WebAuthn request was cancelled.
|
||||
* Some websites try to automatically re-trigger a WebAuthn request after a cancellation.
|
||||
* which results in a jarring UX for the user.
|
||||
* This cooldown prevents rapid-fire popups by waiting for a short period after a cancellation.
|
||||
*/
|
||||
let lastCancelledTimestamp = 0;
|
||||
const CANCEL_COOLDOWN_MS = 500; // 500ms cooldown after a recent cancellation
|
||||
|
||||
/**
|
||||
* Check if page is ready for WebAuthn interactions.
|
||||
* Safari and other browsers can trigger WebAuthn requests during URL autocomplete
|
||||
* or page prefetch, which creates popups before the user actually navigates to the page.
|
||||
* We check if the document is visible and interactive to prevent these spurious requests.
|
||||
*/
|
||||
function isPageReadyForWebAuthn(): boolean {
|
||||
// If page is hidden (prefetch/background tab), block the request
|
||||
if (document.hidden || document.visibilityState === 'hidden') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If document is still loading (not even interactive), block the request
|
||||
if (document.readyState === 'loading') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Page is visible and at least interactive - allow the request
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the WebAuthn interceptor
|
||||
*/
|
||||
export async function initializeWebAuthnInterceptor(_ctx: any): Promise<void> {
|
||||
if (interceptorInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for WebAuthn create events from the page
|
||||
window.addEventListener('aliasvault:webauthn:create', async (event: any) => {
|
||||
const { requestId, publicKey, origin } = event.detail;
|
||||
|
||||
/**
|
||||
* Helper to dispatch event with Firefox compatibility
|
||||
* Firefox has strict cross-context security, so we serialize to JSON and back
|
||||
*/
|
||||
const dispatchResponse = (detail: any): void => {
|
||||
let eventDetail: any;
|
||||
|
||||
/*
|
||||
* For Firefox, we need to ensure the detail is accessible in the page context
|
||||
* cloneInto is a global function in Firefox content scripts
|
||||
*/
|
||||
if (typeof cloneInto !== 'undefined') {
|
||||
// Firefox: serialize and clone into page context
|
||||
const serialized = JSON.parse(JSON.stringify(detail));
|
||||
eventDetail = cloneInto(serialized, (window as any).wrappedJSObject || window);
|
||||
} else {
|
||||
// Chrome/Edge: direct assignment works
|
||||
eventDetail = detail;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('aliasvault:webauthn:create:response', {
|
||||
detail: eventDetail
|
||||
}));
|
||||
};
|
||||
|
||||
try {
|
||||
/**
|
||||
* Note: We don't block create (registration) requests based on page readiness.
|
||||
* Registration is always user-initiated (button click), so it's never spurious.
|
||||
*/
|
||||
|
||||
// Check if we're in cooldown period after a recent cancellation
|
||||
const now = Date.now();
|
||||
if (lastCancelledTimestamp > 0 && (now - lastCancelledTimestamp) < CANCEL_COOLDOWN_MS) {
|
||||
// Silently fall back to native implementation during cooldown
|
||||
dispatchResponse({
|
||||
requestId,
|
||||
fallback: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if passkey provider is enabled
|
||||
const enabled = await isWebAuthnInterceptionEnabled();
|
||||
if (!enabled) {
|
||||
// If disabled, signal fallback to native browser implementation
|
||||
dispatchResponse({
|
||||
requestId,
|
||||
fallback: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Send to background script to handle
|
||||
const result = await sendMessage('WEBAUTHN_CREATE', {
|
||||
publicKey,
|
||||
origin
|
||||
}, 'background');
|
||||
|
||||
// Track if user cancelled to enable cooldown
|
||||
if (result && typeof result === 'object' && (result as any).cancelled) {
|
||||
lastCancelledTimestamp = Date.now();
|
||||
}
|
||||
|
||||
// Send response back to page
|
||||
dispatchResponse({
|
||||
requestId,
|
||||
...(typeof result === 'object' && result !== null ? result : {})
|
||||
});
|
||||
} catch (error: any) {
|
||||
dispatchResponse({
|
||||
requestId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for WebAuthn get events from the page
|
||||
window.addEventListener('aliasvault:webauthn:get', async (event: any) => {
|
||||
const { requestId, publicKey, origin } = event.detail;
|
||||
|
||||
/**
|
||||
* Helper to dispatch event with Firefox compatibility
|
||||
* Firefox has strict cross-context security, so we serialize to JSON and back
|
||||
*/
|
||||
const dispatchResponse = (detail: any): void => {
|
||||
let eventDetail: any;
|
||||
|
||||
/*
|
||||
* For Firefox, we need to ensure the detail is accessible in the page context
|
||||
* cloneInto is a global function in Firefox content scripts
|
||||
*/
|
||||
if (typeof cloneInto !== 'undefined') {
|
||||
// Firefox: serialize and clone into page context
|
||||
const serialized = JSON.parse(JSON.stringify(detail));
|
||||
eventDetail = cloneInto(serialized, (window as any).wrappedJSObject || window);
|
||||
} else {
|
||||
// Chrome/Edge: direct assignment works
|
||||
eventDetail = detail;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('aliasvault:webauthn:get:response', {
|
||||
detail: eventDetail
|
||||
}));
|
||||
};
|
||||
|
||||
try {
|
||||
// Block requests if page isn't ready (prevents prefetch/autocomplete popups)
|
||||
if (!isPageReadyForWebAuthn()) {
|
||||
dispatchResponse({
|
||||
requestId,
|
||||
fallback: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're in cooldown period after a recent cancellation
|
||||
const now = Date.now();
|
||||
if (lastCancelledTimestamp > 0 && (now - lastCancelledTimestamp) < CANCEL_COOLDOWN_MS) {
|
||||
// Silently fall back to native implementation during cooldown
|
||||
dispatchResponse({
|
||||
requestId,
|
||||
fallback: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if passkey provider is enabled
|
||||
const enabled = await isWebAuthnInterceptionEnabled();
|
||||
if (!enabled) {
|
||||
// If disabled, signal fallback to native browser implementation
|
||||
dispatchResponse({
|
||||
requestId,
|
||||
fallback: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Send to background script to handle
|
||||
const result = await sendMessage('WEBAUTHN_GET', {
|
||||
publicKey,
|
||||
origin
|
||||
}, 'background');
|
||||
|
||||
// Track if user cancelled to enable cooldown
|
||||
if (result && typeof result === 'object' && (result as any).cancelled) {
|
||||
lastCancelledTimestamp = Date.now();
|
||||
}
|
||||
|
||||
// Send response back to page
|
||||
dispatchResponse({
|
||||
requestId,
|
||||
...(typeof result === 'object' && result !== null ? result : {})
|
||||
});
|
||||
} catch (error: any) {
|
||||
dispatchResponse({
|
||||
requestId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Inject the page script
|
||||
const script = document.createElement('script');
|
||||
script.src = browser.runtime.getURL('/webauthn.js');
|
||||
script.async = true;
|
||||
(document.head || document.documentElement).appendChild(script);
|
||||
/**
|
||||
* onload
|
||||
*/
|
||||
script.onload = () : void => {
|
||||
script.remove();
|
||||
};
|
||||
/**
|
||||
* onerror
|
||||
*/
|
||||
script.onerror = () : void => {
|
||||
// Ignore
|
||||
};
|
||||
|
||||
interceptorInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WebAuthn interception is enabled for the current site
|
||||
*/
|
||||
export async function isWebAuthnInterceptionEnabled(): Promise<boolean> {
|
||||
try {
|
||||
const response = await sendMessage('GET_WEBAUTHN_SETTINGS', {
|
||||
hostname: window.location.hostname
|
||||
}, 'background') as unknown as WebAuthnSettingsResponse;
|
||||
return response.enabled ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -292,6 +292,68 @@ describe('Filter - Credential URL Matching', () => {
|
||||
expect(matches[0].ServiceName).toBe('Reddit');
|
||||
});
|
||||
|
||||
/**
|
||||
* [#20] - Test reversed domain (Android package name) doesn't match on TLD
|
||||
* Note: Android package name filtering is not applicable to browser extensions.
|
||||
* This test is included for consistency with Android and iOS test suites but is skipped.
|
||||
*/
|
||||
it.skip('should not match credentials based on TLD when filtering reversed domains', () => {
|
||||
/**
|
||||
* Android package name detection is not implemented in browser extensions
|
||||
* since they only deal with web URLs, not Android app contexts.
|
||||
*/
|
||||
});
|
||||
|
||||
/**
|
||||
* [#21] - Test Android package names are properly detected and handled
|
||||
* Note: Android package name filtering is not applicable to browser extensions.
|
||||
* This test is included for consistency with Android and iOS test suites but is skipped.
|
||||
*/
|
||||
it.skip('should properly handle Android package names in filtering', () => {
|
||||
/**
|
||||
* Android package name detection is not implemented in browser extensions
|
||||
* since they only deal with web URLs, not Android app contexts.
|
||||
*/
|
||||
});
|
||||
|
||||
// [#22] - Test multi-part TLDs like .com.au don't match incorrectly
|
||||
it('should handle multi-part TLDs correctly without false matches', () => {
|
||||
// Create test data with different .com.au domains
|
||||
const australianCredentials = [
|
||||
createTestCredential('Example Site AU', 'https://example.com.au', 'user@example.com.au'),
|
||||
createTestCredential('BlaBla AU', 'https://blabla.blabla.com.au', 'user@blabla.com.au'),
|
||||
createTestCredential('Another AU', 'https://another.com.au', 'user@another.com.au'),
|
||||
createTestCredential('UK Site', 'https://example.co.uk', 'user@example.co.uk'),
|
||||
];
|
||||
|
||||
// Test that blabla.blabla.com.au doesn't match other .com.au sites
|
||||
const blablaMatches = filterCredentials(
|
||||
australianCredentials,
|
||||
'https://blabla.blabla.com.au',
|
||||
''
|
||||
);
|
||||
expect(blablaMatches).toHaveLength(1);
|
||||
expect(blablaMatches[0].ServiceName).toBe('BlaBla AU');
|
||||
|
||||
// Test that example.com.au doesn't match blabla.blabla.com.au
|
||||
const exampleMatches = filterCredentials(
|
||||
australianCredentials,
|
||||
'https://example.com.au',
|
||||
''
|
||||
);
|
||||
expect(exampleMatches).toHaveLength(1);
|
||||
expect(exampleMatches[0].ServiceName).toBe('Example Site AU');
|
||||
|
||||
// Test that .co.uk domains work correctly too
|
||||
const ukMatches = filterCredentials(
|
||||
australianCredentials,
|
||||
'https://example.co.uk',
|
||||
''
|
||||
);
|
||||
expect(ukMatches).toHaveLength(1);
|
||||
expect(ukMatches[0].ServiceName).toBe('UK Site');
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HashRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { HashRouter as Router, Routes, Route, useLocation } 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 DefaultLayout from '@/entrypoints/popup/components/Layout/DefaultLayout';
|
||||
import PasskeyLayout from '@/entrypoints/popup/components/Layout/PasskeyLayout';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { NavigationProvider } from '@/entrypoints/popup/context/NavigationContext';
|
||||
import AuthSettings from '@/entrypoints/popup/pages/auth/AuthSettings';
|
||||
import Login from '@/entrypoints/popup/pages/auth/Login';
|
||||
import Logout from '@/entrypoints/popup/pages/auth/Logout';
|
||||
import Unlock from '@/entrypoints/popup/pages/auth/Unlock';
|
||||
import UnlockSuccess from '@/entrypoints/popup/pages/auth/UnlockSuccess';
|
||||
import Upgrade from '@/entrypoints/popup/pages/auth/Upgrade';
|
||||
@@ -23,17 +21,31 @@ import CredentialsList from '@/entrypoints/popup/pages/credentials/CredentialsLi
|
||||
import EmailDetails from '@/entrypoints/popup/pages/emails/EmailDetails';
|
||||
import EmailsList from '@/entrypoints/popup/pages/emails/EmailsList';
|
||||
import Index from '@/entrypoints/popup/pages/Index';
|
||||
import PasskeyAuthenticate from '@/entrypoints/popup/pages/passkeys/PasskeyAuthenticate';
|
||||
import PasskeyCreate from '@/entrypoints/popup/pages/passkeys/PasskeyCreate';
|
||||
import Reinitialize from '@/entrypoints/popup/pages/Reinitialize';
|
||||
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 PasskeySettings from '@/entrypoints/popup/pages/settings/PasskeySettings';
|
||||
import Settings from '@/entrypoints/popup/pages/settings/Settings';
|
||||
|
||||
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
|
||||
|
||||
import '@/entrypoints/popup/style.css';
|
||||
import { clearPendingRedirectUrl } from './hooks/useVaultLockRedirect';
|
||||
|
||||
/**
|
||||
* Available layout types for different page contexts.
|
||||
*/
|
||||
enum LayoutType {
|
||||
/** Default layout with header, footer navigation, and full UI */
|
||||
DEFAULT = 'default',
|
||||
/** Minimal layout for passkey operations - logo only, no footer */
|
||||
PASSKEY = 'passkey',
|
||||
}
|
||||
|
||||
/**
|
||||
* Route configuration.
|
||||
@@ -43,6 +55,81 @@ type RouteConfig = {
|
||||
element: React.ReactNode;
|
||||
showBackButton?: boolean;
|
||||
title?: string;
|
||||
/** Layout type to use for this route. Defaults to LayoutType.DEFAULT if not specified. */
|
||||
layout?: LayoutType;
|
||||
};
|
||||
|
||||
/**
|
||||
* AppContent - Wrapper component that switches between different layout types
|
||||
*/
|
||||
const AppContent: React.FC<{
|
||||
routes: RouteConfig[];
|
||||
isLoading: boolean;
|
||||
message: string | null;
|
||||
headerButtons: React.ReactNode;
|
||||
}> = ({ routes, isLoading, message, headerButtons }) => {
|
||||
const location = useLocation();
|
||||
|
||||
// Find the current route configuration
|
||||
const currentRoute = routes.find(route => {
|
||||
const pattern = route.path.replace(/:\w+/g, '[^/]+');
|
||||
const regex = new RegExp(`^${pattern}$`);
|
||||
return regex.test(location.pathname);
|
||||
});
|
||||
|
||||
// Get layout type, defaulting to DEFAULT if not specified
|
||||
const layoutType = currentRoute?.layout ?? LayoutType.DEFAULT;
|
||||
|
||||
// Common loading overlay
|
||||
const loadingOverlay = isLoading && (
|
||||
<div className="fixed inset-0 bg-white dark:bg-gray-900 z-50 flex items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
// Common routes component
|
||||
const routesComponent = (
|
||||
<Routes>
|
||||
{routes.map((route) => (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
))}
|
||||
</Routes>
|
||||
);
|
||||
|
||||
// Render based on layout type
|
||||
switch (layoutType) {
|
||||
case LayoutType.PASSKEY:
|
||||
// Passkey layout - minimal UI with just logo header
|
||||
return (
|
||||
<PasskeyLayout>
|
||||
{loadingOverlay}
|
||||
{message && (
|
||||
<p className="text-red-500 mb-4">{message}</p>
|
||||
)}
|
||||
{routesComponent}
|
||||
</PasskeyLayout>
|
||||
);
|
||||
|
||||
case LayoutType.DEFAULT:
|
||||
default:
|
||||
// Default layout with full header, footer, navigation
|
||||
return (
|
||||
<>
|
||||
{loadingOverlay}
|
||||
<DefaultLayout
|
||||
routes={routes}
|
||||
headerButtons={headerButtons}
|
||||
message={message}
|
||||
>
|
||||
{routesComponent}
|
||||
</DefaultLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -50,7 +137,7 @@ type RouteConfig = {
|
||||
*/
|
||||
const App: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const authContext = useAuth();
|
||||
const app = useApp();
|
||||
const { isInitialLoading } = useLoading();
|
||||
const [isLoading, setIsLoading] = useMinDurationLoading(true, 150);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
@@ -69,6 +156,8 @@ const App: React.FC = () => {
|
||||
{ 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: '/passkeys/create', element: <PasskeyCreate />, layout: LayoutType.PASSKEY },
|
||||
{ path: '/passkeys/authenticate', element: <PasskeyAuthenticate />, layout: LayoutType.PASSKEY },
|
||||
{ path: '/emails', element: <EmailsList />, showBackButton: false },
|
||||
{ path: '/emails/:id', element: <EmailDetails />, showBackButton: true, title: t('emails.title') },
|
||||
{ path: '/settings', element: <Settings />, showBackButton: false },
|
||||
@@ -77,7 +166,7 @@ const App: React.FC = () => {
|
||||
{ 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 },
|
||||
{ path: '/settings/passkeys', element: <PasskeySettings />, showBackButton: true, title: t('settings.passkeySettings') },
|
||||
], [t]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -109,57 +198,36 @@ const App: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* On initial load, clear any stale pending redirect URL if popup was not opened with a specific hash path.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const hasHashPath = window.location.hash && window.location.hash !== '#/' && window.location.hash !== '#';
|
||||
if (!hasHashPath) {
|
||||
clearPendingRedirectUrl();
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Print global message if it exists.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (authContext.globalMessage) {
|
||||
setMessage(authContext.globalMessage);
|
||||
if (app.globalMessage) {
|
||||
setMessage(app.globalMessage);
|
||||
} else {
|
||||
setMessage(null);
|
||||
}
|
||||
}, [authContext, authContext.globalMessage]);
|
||||
}, [app, app.globalMessage]);
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<NavigationProvider>
|
||||
<div className="min-h-screen min-w-[350px] bg-white dark:bg-gray-900 flex flex-col max-h-[600px]">
|
||||
{isLoading && (
|
||||
<div className="fixed inset-0 bg-white dark:bg-gray-900 z-50 flex items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ClipboardCountdownBar />
|
||||
<Header
|
||||
routes={routes}
|
||||
rightButtons={headerButtons}
|
||||
/>
|
||||
|
||||
<main
|
||||
className="flex-1 overflow-y-auto bg-gray-100 dark:bg-gray-900"
|
||||
style={{
|
||||
paddingTop: '64px',
|
||||
height: 'calc(100% - 120px)',
|
||||
}}
|
||||
>
|
||||
<div className="p-4 mb-16">
|
||||
{message && (
|
||||
<p className="text-red-500 mb-4">{message}</p>
|
||||
)}
|
||||
<Routes>
|
||||
{routes.map((route) => (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
))}
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
<AppContent
|
||||
routes={routes}
|
||||
isLoading={isLoading}
|
||||
message={message}
|
||||
headerButtons={headerButtons}
|
||||
/>
|
||||
</NavigationProvider>
|
||||
</Router>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
type AlertVariant = 'info' | 'warning' | 'error' | 'success';
|
||||
|
||||
interface IAlertProps {
|
||||
variant: AlertVariant;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable alert component with consistent styling
|
||||
*/
|
||||
const Alert: React.FC<IAlertProps> = ({ variant, children, className = '' }) => {
|
||||
const variantStyles = {
|
||||
info: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200',
|
||||
warning: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200',
|
||||
error: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200',
|
||||
success: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`p-3 border rounded-lg ${variantStyles[variant]} ${className}`}>
|
||||
<p className="text-sm">
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
type ButtonProps = {
|
||||
onClick?: () => void;
|
||||
id?: string;
|
||||
children: React.ReactNode;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
variant?: 'primary' | 'secondary';
|
||||
@@ -10,12 +11,13 @@ type ButtonProps = {
|
||||
/**
|
||||
* Button component
|
||||
*/
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
onClick,
|
||||
id,
|
||||
children,
|
||||
type = 'button',
|
||||
variant = 'primary'
|
||||
}) => {
|
||||
}, ref) => {
|
||||
const colorClasses = {
|
||||
primary: 'bg-primary-500 hover:bg-primary-600',
|
||||
secondary: 'bg-gray-500 hover:bg-gray-600'
|
||||
@@ -23,13 +25,17 @@ const Button: React.FC<ButtonProps> = ({
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`${colorClasses[variant]} text-white font-medium rounded-lg px-4 py-2 text-sm w-full`}
|
||||
onClick={onClick}
|
||||
type={type}
|
||||
id={id}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export default Button;
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FormInputCopyToClipboard } from '@/entrypoints/popup/components/FormInputCopyToClipboard';
|
||||
|
||||
import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
|
||||
type LoginCredentialsBlockProps = {
|
||||
credential: Credential;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
if (!email && !username && !password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{t('common.loginCredentials')}</h2>
|
||||
{email && (
|
||||
<FormInputCopyToClipboard
|
||||
id="email"
|
||||
label={t('common.email')}
|
||||
value={email}
|
||||
/>
|
||||
)}
|
||||
{username && (
|
||||
<FormInputCopyToClipboard
|
||||
id="username"
|
||||
label={t('common.username')}
|
||||
value={username}
|
||||
/>
|
||||
)}
|
||||
{password && (
|
||||
<FormInputCopyToClipboard
|
||||
id="password"
|
||||
label={t('common.password')}
|
||||
value={password}
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginCredentialsBlock;
|
||||
@@ -69,8 +69,38 @@ const CredentialCard: React.FC<CredentialCardProps> = ({ credential }) => {
|
||||
target.src = '/assets/images/service-placeholder.webp';
|
||||
}}
|
||||
/>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{getCredentialServiceName(credential)}</p>
|
||||
<div className="text-left flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{getCredentialServiceName(credential)}</p>
|
||||
{credential.HasPasskey && (
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-label="Has passkey"
|
||||
>
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
||||
</svg>
|
||||
)}
|
||||
{credential.HasAttachment && (
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-label="Has attachments"
|
||||
>
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{getDisplayText(credential)}</p>
|
||||
</div>
|
||||
</button>
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FormInputCopyToClipboard } from '@/entrypoints/popup/components/FormInputCopyToClipboard';
|
||||
import { FormInputCopyToClipboard } from '@/entrypoints/popup/components/Forms/FormInputCopyToClipboard';
|
||||
|
||||
import { IdentityHelperUtils } from '@/utils/dist/shared/identity-generator';
|
||||
import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
@@ -27,7 +27,7 @@ const EmailBlock: React.FC<EmailBlockProps> = ({ email }) => {
|
||||
const privateDomains = vaultMetadata?.privateEmailDomains ?? [];
|
||||
|
||||
return [...publicDomains, ...privateDomains].some(supportedDomain =>
|
||||
domain === supportedDomain || domain.endsWith(`.${supportedDomain}`)
|
||||
domain === supportedDomain.toLowerCase()
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FormInputCopyToClipboard } from '@/entrypoints/popup/components/Forms/FormInputCopyToClipboard';
|
||||
|
||||
import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
|
||||
type LoginCredentialsBlockProps = {
|
||||
credential: Credential;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
if (!email && !username && !password && !credential.HasPasskey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{t('common.loginCredentials')}</h2>
|
||||
{email && (
|
||||
<FormInputCopyToClipboard
|
||||
id="email"
|
||||
label={t('common.email')}
|
||||
value={email}
|
||||
/>
|
||||
)}
|
||||
{username && (
|
||||
<FormInputCopyToClipboard
|
||||
id="username"
|
||||
label={t('common.username')}
|
||||
value={username}
|
||||
/>
|
||||
)}
|
||||
{credential.HasPasskey && (
|
||||
<div className="p-3 rounded bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-600 dark:text-gray-400 mt-0.5 flex-shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{t('passkeys.passkey')}</span>
|
||||
</div>
|
||||
<div className="space-y-1 mb-2">
|
||||
{credential.PasskeyRpId && (
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{t('passkeys.site')}: </span>
|
||||
<span className="text-sm text-gray-900 dark:text-white">{credential.PasskeyRpId}</span>
|
||||
</div>
|
||||
)}
|
||||
{credential.PasskeyDisplayName && (
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{t('passkeys.displayName')}: </span>
|
||||
<span className="text-sm text-gray-900 dark:text-white">{credential.PasskeyDisplayName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{t('passkeys.helpText')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{password && (
|
||||
<FormInputCopyToClipboard
|
||||
id="password"
|
||||
label={t('common.password')}
|
||||
value={password}
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginCredentialsBlock;
|
||||
@@ -39,7 +39,7 @@ const HelpModal: React.FC<HelpModalProps> = ({ titleKey, contentKey, className =
|
||||
</button>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-70 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">
|
||||
@@ -37,7 +37,7 @@ const Modal: React.FC<IModalProps> = ({
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 bg-black bg-opacity-60 transition-opacity" onClick={onClose} />
|
||||
<div className="fixed inset-0 bg-black bg-opacity-80 transition-opacity" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Button from '../Button';
|
||||
|
||||
type PasskeyBypassDialogProps = {
|
||||
origin: string;
|
||||
onChoice: (choice: 'once' | 'always') => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dialog for choosing how to bypass AliasVault passkey provider
|
||||
*/
|
||||
const PasskeyBypassDialog: React.FC<PasskeyBypassDialogProps> = ({
|
||||
origin,
|
||||
onChoice,
|
||||
onCancel
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('passkeys.bypass.title')}
|
||||
</h2>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
{t('passkeys.bypass.description', { origin })}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => onChoice('once')}
|
||||
>
|
||||
{t('passkeys.bypass.thisTimeOnly')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onChoice('always')}
|
||||
>
|
||||
{t('passkeys.bypass.alwaysForSite')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasskeyBypassDialog;
|
||||
@@ -57,7 +57,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
const isPublicDomain = async (emailAddress: string): Promise<boolean> => {
|
||||
// Get metadata from storage
|
||||
const publicEmailDomains = await storage.getItem('session:publicEmailDomains') as string[] ?? [];
|
||||
return publicEmailDomains.some(domain => emailAddress.toLowerCase().endsWith(domain));
|
||||
return publicEmailDomains.some(domain => emailAddress.toLowerCase().endsWith(`@${domain.toLowerCase()}`));
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -66,7 +66,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
const isPrivateDomain = async (emailAddress: string): Promise<boolean> => {
|
||||
// Get metadata from storage
|
||||
const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[] ?? [];
|
||||
return privateEmailDomains.some(domain => emailAddress.toLowerCase().endsWith(domain));
|
||||
return privateEmailDomains.some(domain => emailAddress.toLowerCase().endsWith(`@${domain.toLowerCase()}`));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -101,7 +101,8 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [value, privateEmailDomains, showPrivateDomains, selectedDomain]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value, privateEmailDomains, showPrivateDomains]);
|
||||
|
||||
// Handle local part changes
|
||||
const handleLocalPartChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -1,13 +1,12 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import PasswordConfigDialog from '@/entrypoints/popup/components/Dialogs/PasswordConfigDialog';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
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;
|
||||
@@ -34,7 +34,7 @@ const BottomNav: React.FC = () => {
|
||||
};
|
||||
|
||||
// Auth pages that don't show bottom navigation but still show header
|
||||
const authPages = ['/login', '/auth-settings', '/unlock', '/unlock-success', '/upgrade'];
|
||||
const authPages = ['/', '/login', '/auth-settings', '/unlock', '/unlock-success', '/upgrade'];
|
||||
const isAuthPage = authPages.includes(location.pathname);
|
||||
|
||||
if (isAuthPage) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
|
||||
import { ClipboardCountdownBar } from '@/entrypoints/popup/components/ClipboardCountdownBar';
|
||||
import BottomNav from '@/entrypoints/popup/components/Layout/BottomNav';
|
||||
import Header from '@/entrypoints/popup/components/Layout/Header';
|
||||
|
||||
/**
|
||||
* Route configuration type.
|
||||
*/
|
||||
type RouteConfig = {
|
||||
path: string;
|
||||
element: React.ReactNode;
|
||||
showBackButton?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* DefaultLayout props.
|
||||
*/
|
||||
type DefaultLayoutProps = {
|
||||
routes: RouteConfig[];
|
||||
headerButtons: React.ReactNode;
|
||||
message?: string | null;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* DefaultLayout - Standard layout with full header, footer navigation, and complete UI.
|
||||
* This is the main layout used for most pages in the extension.
|
||||
*/
|
||||
const DefaultLayout: React.FC<DefaultLayoutProps> = ({ routes, headerButtons, message, children }) => {
|
||||
return (
|
||||
<div className="min-h-screen min-w-[350px] bg-white dark:bg-gray-900 flex flex-col max-h-[600px]">
|
||||
<ClipboardCountdownBar />
|
||||
|
||||
<Header
|
||||
routes={routes}
|
||||
rightButtons={headerButtons}
|
||||
/>
|
||||
|
||||
<main
|
||||
className="flex-1 overflow-y-auto bg-gray-100 dark:bg-gray-900"
|
||||
style={{
|
||||
paddingTop: '64px',
|
||||
height: 'calc(100% - 120px)',
|
||||
}}
|
||||
>
|
||||
<div className="p-4 mb-16">
|
||||
{message && (
|
||||
<p className="text-red-500 mb-4">{message}</p>
|
||||
)}
|
||||
{children || (
|
||||
<Routes>
|
||||
{routes.map((route) => (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
))}
|
||||
</Routes>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultLayout;
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
import Logo from '@/entrypoints/popup/components/Logo';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
|
||||
/**
|
||||
* Header props.
|
||||
@@ -25,7 +25,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
rightButtons
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const authContext = useAuth();
|
||||
const app = useApp();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -54,7 +54,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
}
|
||||
|
||||
// If logged in, navigate to credentials.
|
||||
if (authContext.isLoggedIn) {
|
||||
if (app.isLoggedIn) {
|
||||
navigate('/credentials');
|
||||
} else {
|
||||
// If not logged in, navigate to index.
|
||||
@@ -105,7 +105,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<div className="flex-grow" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!authContext.isLoggedIn ? (
|
||||
{!app.isLoggedIn ? (
|
||||
<>
|
||||
{rightButtons}
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
import Logo from '@/entrypoints/popup/components/Logo';
|
||||
|
||||
/**
|
||||
* PasskeyLayout - Minimal layout for passkey create/authenticate pages.
|
||||
* Shows only the AliasVault logo header, no navigation, no footer.
|
||||
*/
|
||||
const PasskeyLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<div className="min-h-screen min-w-[350px] bg-white dark:bg-gray-900 flex flex-col max-h-[600px]">
|
||||
{/* Minimal header with just logo */}
|
||||
<header className="fixed z-30 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<div className="flex items-center justify-center h-16 px-4">
|
||||
<Logo
|
||||
width={125}
|
||||
height={40}
|
||||
showText={true}
|
||||
className="text-gray-900 dark:text-white"
|
||||
/>
|
||||
{/* 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] font-normal">BETA</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content without footer padding */}
|
||||
<main
|
||||
className="flex-1 overflow-y-auto bg-gray-100 dark:bg-gray-900"
|
||||
style={{
|
||||
paddingTop: '64px',
|
||||
}}
|
||||
>
|
||||
<div className="p-4">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasskeyLayout;
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { createContext, useContext, useMemo, useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
|
||||
import { logoutEventEmitter } from '@/events/LogoutEventEmitter';
|
||||
|
||||
type AppContextType = {
|
||||
isLoggedIn: boolean;
|
||||
isInitialized: boolean;
|
||||
username: string | null;
|
||||
logout: (errorMessage?: string) => Promise<void>;
|
||||
initializeAuth: () => Promise<boolean>;
|
||||
setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise<void>;
|
||||
globalMessage: string | null;
|
||||
clearGlobalMessage: () => void;
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* AppProvider that coordinates between auth, db, and webApi contexts.
|
||||
*/
|
||||
export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const auth = useAuth();
|
||||
const webApi = useWebApi();
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const isLoggingOutRef = useRef(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* Logout the user by revoking tokens and clearing the auth tokens from storage.
|
||||
* Prevents recursive logout calls by tracking logout state.
|
||||
*/
|
||||
const logout = useCallback(async (errorMessage?: string): Promise<void> => {
|
||||
if (isLoggingOutRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoggingOutRef.current = true;
|
||||
await webApi.revokeTokens();
|
||||
await auth.clearAuth(errorMessage);
|
||||
} catch (error) {
|
||||
console.error('Error during logout:', error);
|
||||
} finally {
|
||||
isLoggingOutRef.current = false;
|
||||
setIsLoggedIn(false);
|
||||
}
|
||||
}, [auth, webApi]);
|
||||
|
||||
/**
|
||||
* Initialize the authentication state.
|
||||
*
|
||||
* @returns boolean indicating whether the user is logged in.
|
||||
*/
|
||||
const initializeAuth = useCallback(async () : Promise<boolean> => {
|
||||
const isLoggedIn = await auth.initializeAuth();
|
||||
setIsLoggedIn(isLoggedIn);
|
||||
return isLoggedIn;
|
||||
}, [auth]);
|
||||
|
||||
/**
|
||||
* Subscribe to logout events from WebApiService.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const unsubscribe = logoutEventEmitter.subscribe(async (errorKey: string) => {
|
||||
await logout(t(errorKey));
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [logout, t]);
|
||||
|
||||
/**
|
||||
* Check for tokens in browser local storage on initial load when this context is mounted.
|
||||
*/
|
||||
useEffect(() => {
|
||||
initializeAuth();
|
||||
}, [initializeAuth]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
// Pass through auth state
|
||||
isInitialized: auth.isInitialized,
|
||||
username: auth.username,
|
||||
globalMessage: auth.globalMessage,
|
||||
// Wrap auth methods
|
||||
logout,
|
||||
initializeAuth,
|
||||
setAuthTokens: auth.setAuthTokens,
|
||||
clearGlobalMessage: auth.clearGlobalMessage,
|
||||
isLoggedIn: isLoggedIn,
|
||||
}), [
|
||||
auth.isInitialized,
|
||||
auth.username,
|
||||
auth.globalMessage,
|
||||
auth.setAuthTokens,
|
||||
auth.clearGlobalMessage,
|
||||
logout,
|
||||
initializeAuth,
|
||||
isLoggedIn,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to use the AppContext.
|
||||
*/
|
||||
export const useApp = (): AppContextType => {
|
||||
const context = useContext(AppContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useApp must be used within an AppProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import React, { createContext, useContext, useState, useMemo, useCallback } from 'react';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
@@ -8,13 +8,11 @@ import { VAULT_LOCKED_DISMISS_UNTIL_KEY } from '@/utils/Constants';
|
||||
import { storage } from '#imports';
|
||||
|
||||
type AuthContextType = {
|
||||
isLoggedIn: boolean;
|
||||
isInitialized: boolean;
|
||||
username: string | null;
|
||||
initializeAuth: () => Promise<{ isLoggedIn: boolean }>;
|
||||
initializeAuth: () => Promise<boolean>;
|
||||
setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise<void>;
|
||||
login: () => Promise<void>;
|
||||
logout: (errorMessage?: string) => Promise<void>;
|
||||
clearAuth: (errorMessage?: string) => Promise<void>;
|
||||
globalMessage: string | null;
|
||||
clearGlobalMessage: () => void;
|
||||
}
|
||||
@@ -28,7 +26,6 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
* AuthProvider to provide the authentication state to the app that components can use.
|
||||
*/
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [globalMessage, setGlobalMessage] = useState<string | null>(null);
|
||||
@@ -37,30 +34,20 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
/**
|
||||
* Initialize the authentication state.
|
||||
*
|
||||
* @returns object containing whether the user is logged in.
|
||||
* @returns boolean indicating whether the user is logged in.
|
||||
*/
|
||||
const initializeAuth = useCallback(async () : Promise<{ isLoggedIn: boolean }> => {
|
||||
let isLoggedIn = false;
|
||||
|
||||
const initializeAuth = useCallback(async () : Promise<boolean> => {
|
||||
const accessToken = await storage.getItem('local:accessToken') as string;
|
||||
const refreshToken = await storage.getItem('local:refreshToken') as string;
|
||||
const username = await storage.getItem('local:username') as string;
|
||||
setIsInitialized(true);
|
||||
if (accessToken && refreshToken && username) {
|
||||
setUsername(username);
|
||||
setIsLoggedIn(true);
|
||||
isLoggedIn = true;
|
||||
return true;
|
||||
}
|
||||
setIsInitialized(true);
|
||||
|
||||
return { isLoggedIn };
|
||||
}, [setUsername, setIsLoggedIn]);
|
||||
|
||||
/**
|
||||
* Check for tokens in browser local storage on initial load when this context is mounted.
|
||||
*/
|
||||
useEffect(() => {
|
||||
initializeAuth();
|
||||
}, [initializeAuth]);
|
||||
return false;
|
||||
}, [setUsername]);
|
||||
|
||||
/**
|
||||
* Set auth tokens in browser local storage as part of the login process. After db is initialized, the login method should be called as well.
|
||||
@@ -70,23 +57,18 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
await storage.setItem('local:accessToken', accessToken);
|
||||
await storage.setItem('local:refreshToken', refreshToken);
|
||||
|
||||
// Clear dismiss until (which can be enabled after user has dimissed vault is locked popup) to ensure popup is shown.
|
||||
await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, 0);
|
||||
|
||||
setUsername(username);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Set logged in status to true which refreshes the app.
|
||||
* Clear authentication data and tokens from storage.
|
||||
* This is called by AppContext after revoking tokens on the server.
|
||||
*/
|
||||
const login = useCallback(async () : Promise<void> => {
|
||||
setIsLoggedIn(true);
|
||||
|
||||
// Clear dismiss until (which can be enabled after user has dimissed vault is locked popup) to ensure popup is shown.
|
||||
await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, 0);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Logout the user and clear the auth tokens from chrome storage.
|
||||
*/
|
||||
const logout = useCallback(async (errorMessage?: string) : Promise<void> => {
|
||||
const clearAuth = useCallback(async (errorMessage?: string) : Promise<void> => {
|
||||
// Clear vault from background worker and remove local storage tokens.
|
||||
await sendMessage('CLEAR_VAULT', {}, 'background');
|
||||
await storage.removeItems(['local:username', 'local:accessToken', 'local:refreshToken']);
|
||||
dbContext?.clearDatabase();
|
||||
@@ -97,7 +79,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}
|
||||
|
||||
setUsername(null);
|
||||
setIsLoggedIn(false);
|
||||
}, [dbContext]);
|
||||
|
||||
/**
|
||||
@@ -108,16 +89,14 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
isLoggedIn,
|
||||
isInitialized,
|
||||
username,
|
||||
initializeAuth,
|
||||
setAuthTokens,
|
||||
login,
|
||||
logout,
|
||||
clearAuth,
|
||||
globalMessage,
|
||||
clearGlobalMessage,
|
||||
}), [isLoggedIn, isInitialized, username, initializeAuth, globalMessage, setAuthTokens, login, logout, clearGlobalMessage]);
|
||||
}), [isInitialized, username, initializeAuth, globalMessage, setAuthTokens, clearAuth, clearGlobalMessage]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
import { storage } from '#imports';
|
||||
@@ -29,21 +29,32 @@ const NavigationContext = createContext<NavigationContextType | undefined>(undef
|
||||
*/
|
||||
export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Auth and DB state
|
||||
const { isInitialized: authInitialized, isLoggedIn } = useAuth();
|
||||
const { dbInitialized, dbAvailable, upgradeRequired } = useDb();
|
||||
const { isInitialized: authInitialized, isLoggedIn } = useApp();
|
||||
const { dbInitialized, dbAvailable } = useDb();
|
||||
|
||||
// Derived state
|
||||
const isFullyInitialized = authInitialized && dbInitialized;
|
||||
const requiresAuth = isFullyInitialized && (!isLoggedIn || (!dbAvailable && !upgradeRequired));
|
||||
const requiresAuth = isFullyInitialized && (!isLoggedIn || !dbAvailable);
|
||||
|
||||
/**
|
||||
* Store the current page path, timestamp, and navigation history in storage.
|
||||
*/
|
||||
const storeCurrentPage = useCallback(async (): Promise<void> => {
|
||||
// Pages that are not allowed to be stored as these are auth conditional pages.
|
||||
const notAllowedPaths = ['/', '/reinitialize', '/login', '/unlock', '/unlock-success', '/auth-settings', '/upgrade', '/logout'];
|
||||
// Pages that are not allowed to be stored as these are auth conditional pages or dedicated popup pages.
|
||||
const notAllowedPaths = [
|
||||
'/',
|
||||
'/reinitialize',
|
||||
'/login',
|
||||
'/unlock',
|
||||
'/unlock-success',
|
||||
'/auth-settings',
|
||||
'/upgrade',
|
||||
'/passkeys/create',
|
||||
'/passkeys/authenticate'
|
||||
];
|
||||
|
||||
// Only store the page if we're fully initialized and don't need auth
|
||||
if (isFullyInitialized && !requiresAuth && !notAllowedPaths.includes(location.pathname)) {
|
||||
@@ -55,7 +66,7 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
let currentPath = '';
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
currentPath += '/' + segments[i];
|
||||
|
||||
|
||||
/*
|
||||
* For settings subpages, include both /settings and the subpage
|
||||
* For email details, include both /emails and the specific email
|
||||
@@ -82,6 +93,15 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
}
|
||||
}, [location.pathname, location.search, location.hash, isFullyInitialized, storeCurrentPage]);
|
||||
|
||||
// Listen on isloggedin state to redirect to login page if not logged in
|
||||
useEffect(() => {
|
||||
if (isFullyInitialized && !isLoggedIn) {
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isFullyInitialized, isLoggedIn]);
|
||||
|
||||
// Return the context value
|
||||
const contextValue = useMemo(() => ({
|
||||
storeCurrentPage,
|
||||
isFullyInitialized,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
|
||||
import { WebApiService } from '@/utils/WebApiService';
|
||||
|
||||
const WebApiContext = createContext<WebApiService | null>(null);
|
||||
@@ -10,24 +8,15 @@ const WebApiContext = createContext<WebApiService | null>(null);
|
||||
* WebApiProvider to provide the WebApiService to the app that components can use.
|
||||
*/
|
||||
export const WebApiProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { logout } = useAuth();
|
||||
const [webApiService, setWebApiService] = useState<WebApiService | null>(null);
|
||||
|
||||
/**
|
||||
* Initialize WebApiService
|
||||
*/
|
||||
useEffect(() : void => {
|
||||
const service = new WebApiService(
|
||||
(statusError: string | null) => {
|
||||
if (statusError) {
|
||||
logout(statusError);
|
||||
} else {
|
||||
logout();
|
||||
}
|
||||
}
|
||||
);
|
||||
const service = new WebApiService();
|
||||
setWebApiService(service);
|
||||
}, [logout]);
|
||||
}, []);
|
||||
|
||||
if (!webApiService) {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
import { PENDING_REDIRECT_URL_KEY } from '@/utils/Constants';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
/**
|
||||
* Hook to handle vault lock redirects.
|
||||
* Automatically redirects to unlock page if vault is locked,
|
||||
* preserving the current URL for restoration after unlock.
|
||||
*/
|
||||
export function useVaultLockRedirect(options: { enabled?: boolean } = {}): { isLocked: boolean } {
|
||||
const { enabled = true } = options;
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { dbInitialized, dbAvailable } = useDb();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !dbInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if vault is locked
|
||||
if (!dbAvailable) {
|
||||
// Store the full current URL (pathname + search) for restoration after unlock
|
||||
const currentUrl = `${location.pathname}${location.search}`;
|
||||
storage.setItem(PENDING_REDIRECT_URL_KEY, currentUrl);
|
||||
|
||||
// Navigate to unlock without redirect in URL - we use storage instead
|
||||
navigate('/unlock');
|
||||
}
|
||||
}, [enabled, dbInitialized, dbAvailable, location, navigate]);
|
||||
|
||||
return {
|
||||
isLocked: dbInitialized && !dbAvailable
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and clear the pending redirect URL from storage.
|
||||
* Used by Reinitialize page to restore user's intended destination after unlock.
|
||||
*
|
||||
* @returns The pending redirect URL, or null if none exists
|
||||
*/
|
||||
export async function consumePendingRedirectUrl(): Promise<string | null> {
|
||||
const url = await storage.getItem<string>(PENDING_REDIRECT_URL_KEY);
|
||||
if (url) {
|
||||
await storage.removeItem(PENDING_REDIRECT_URL_KEY);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the pending redirect URL from storage.
|
||||
* Used when popup is opened without a specific hash path to clear stale redirects.
|
||||
*/
|
||||
export async function clearPendingRedirectUrl(): Promise<void> {
|
||||
await storage.removeItem(PENDING_REDIRECT_URL_KEY);
|
||||
}
|
||||
@@ -43,55 +43,52 @@ export function useVaultMutate() : {
|
||||
|
||||
setSyncStatus(t('common.uploadingVaultToServer'));
|
||||
|
||||
try {
|
||||
// Upload the updated vault to the server.
|
||||
const base64Vault = dbContext.sqliteClient!.exportToBase64();
|
||||
// Upload the updated vault to the server.
|
||||
const base64Vault = dbContext.sqliteClient!.exportToBase64();
|
||||
|
||||
// Get encryption key from background worker
|
||||
const encryptionKey = await sendMessage('GET_ENCRYPTION_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,
|
||||
encryptionKey
|
||||
);
|
||||
// Encrypt the vault.
|
||||
const encryptedVaultBlob = await EncryptionUtility.symmetricEncrypt(
|
||||
base64Vault,
|
||||
encryptionKey
|
||||
);
|
||||
|
||||
const request: UploadVaultRequest = {
|
||||
vaultBlob: encryptedVaultBlob,
|
||||
};
|
||||
const request: UploadVaultRequest = {
|
||||
vaultBlob: encryptedVaultBlob,
|
||||
};
|
||||
|
||||
const response = await sendMessage('UPLOAD_VAULT', request, 'background') as messageVaultUploadResponse;
|
||||
const response = await sendMessage('UPLOAD_VAULT', request, 'background') as messageVaultUploadResponse;
|
||||
|
||||
/*
|
||||
* If we get here, it means we have a valid connection to the server.
|
||||
* TODO: offline mode is not implemented for browser extension yet.
|
||||
* authContext.setOfflineMode(false);
|
||||
*/
|
||||
/*
|
||||
* If we get here, it means we have a valid connection to the server.
|
||||
* TODO: offline mode is not implemented for browser extension yet.
|
||||
* authContext.setOfflineMode(false);
|
||||
*/
|
||||
|
||||
if (response.status === 0 && response.newRevisionNumber) {
|
||||
await dbContext.setCurrentVaultRevisionNumber(response.newRevisionNumber);
|
||||
options.onSuccess?.();
|
||||
} else if (response.status === 1) {
|
||||
// Note: vault merge is no longer allowed by the API as of 0.20.0, updates with the same revision number are rejected. So this check can be removed later.
|
||||
throw new Error('Vault merge required. Please login via the web app to merge the multiple pending updates to your vault.');
|
||||
} else if (response.status === 2) {
|
||||
throw new Error('Your vault is outdated. Please login on the AliasVault website and follow the steps.');
|
||||
} else {
|
||||
throw new Error('Failed to upload vault to server. Please try again by re-opening the app.');
|
||||
}
|
||||
} catch (error) {
|
||||
// Check if it's a network error
|
||||
if (error instanceof Error && (error.message.includes('network') || error.message.includes('timeout'))) {
|
||||
/*
|
||||
* Network error, mark as offline and track pending changes
|
||||
* TODO: offline mode is not implemented for browser extension yet.
|
||||
* authContext.setOfflineMode(true);
|
||||
*/
|
||||
options.onError?.(new Error('Network error'));
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
if (response.status === 0 && response.newRevisionNumber) {
|
||||
await dbContext.setCurrentVaultRevisionNumber(response.newRevisionNumber);
|
||||
options.onSuccess?.();
|
||||
} else if (response.status === 1) {
|
||||
// Note: vault merge is no longer allowed by the API as of 0.20.0, updates with the same revision number are rejected. So this check can be removed later.
|
||||
throw new Error('Vault merge required. Please login via the web app to merge the multiple pending updates to your vault.');
|
||||
} else if (response.status === 2) {
|
||||
throw new Error(t('common.errors.failedToUploadVault'));
|
||||
} else {
|
||||
throw new Error(t('common.errors.failedToUploadVault'));
|
||||
}
|
||||
|
||||
// Check if it's a network error
|
||||
/*
|
||||
* if (error instanceof Error && (error.message.includes('network') || error.message.includes('timeout'))) {
|
||||
*
|
||||
* // Network error, mark as offline and track pending changes - TODO: offline mode is not implemented for browser extension yet.
|
||||
* // authContext.setOfflineMode(true);
|
||||
*options.onError?.(new Error('Network error'));
|
||||
*return;
|
||||
*}
|
||||
*/
|
||||
}, [dbContext, t]);
|
||||
|
||||
/**
|
||||
@@ -130,28 +127,12 @@ export function useVaultMutate() : {
|
||||
* Handle error during vault sync.
|
||||
*/
|
||||
onError: (error) => {
|
||||
/**
|
||||
*Toast.show({
|
||||
*type: 'error',
|
||||
*text1: 'Failed to sync vault',
|
||||
*text2: error,
|
||||
*position: 'bottom'
|
||||
*});
|
||||
*/
|
||||
options.onError?.(new Error(error));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error during vault mutation:', error);
|
||||
/*
|
||||
* Toast.show({
|
||||
*type: 'error',
|
||||
*text1: 'Operation failed',
|
||||
*text2: error instanceof Error ? error.message : 'Unknown error',
|
||||
*position: 'bottom'
|
||||
*});
|
||||
*/
|
||||
options.onError?.(error instanceof Error ? error : new Error('Unknown error'));
|
||||
options.onError?.(error instanceof Error ? error : new Error(t('common.errors.unknownError')));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setSyncStatus('');
|
||||
|
||||
@@ -2,12 +2,13 @@ import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
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';
|
||||
import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError';
|
||||
|
||||
/**
|
||||
* Utility function to ensure a minimum time has elapsed for an operation
|
||||
@@ -49,7 +50,7 @@ export const useVaultSync = () : {
|
||||
syncVault: (options?: VaultSyncOptions) => Promise<boolean>;
|
||||
} => {
|
||||
const { t } = useTranslation();
|
||||
const authContext = useAuth();
|
||||
const app = useApp();
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
|
||||
@@ -60,7 +61,7 @@ export const useVaultSync = () : {
|
||||
const enableDelay = initialSync;
|
||||
|
||||
try {
|
||||
const { isLoggedIn } = await authContext.initializeAuth();
|
||||
const isLoggedIn = await app.initializeAuth();
|
||||
|
||||
if (!isLoggedIn) {
|
||||
// Not authenticated, return false immediately
|
||||
@@ -73,7 +74,9 @@ export const useVaultSync = () : {
|
||||
|
||||
// Check if server is actually available, 0.0.0 indicates connection error which triggers offline mode.
|
||||
if (statusResponse.serverVersion === '0.0.0') {
|
||||
// Offline mode is not implemented for browser extension yet, let it fail below due to the validateStatusResponse check.
|
||||
// Offline mode is not implemented for browser extension yet, so logout the user.
|
||||
onError?.(t('common.errors.serverNotAvailable'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const statusError = webApi.validateStatusResponse(statusResponse);
|
||||
@@ -91,7 +94,7 @@ export const useVaultSync = () : {
|
||||
* 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'));
|
||||
await app.logout(t('common.errors.passwordChanged'));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -109,24 +112,6 @@ export const useVaultSync = () : {
|
||||
onStatus?.(t('common.syncingUpdatedVault'));
|
||||
const vaultResponseJson = await withMinimumDelay(() => webApi.get<VaultResponse>('Vault'), 1000, enableDelay);
|
||||
|
||||
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')) {
|
||||
await webApi.logout(vaultError);
|
||||
onError?.(vaultError);
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* TODO: browser extension does not support offline mode yet.
|
||||
* For other errors, go into offline mode
|
||||
* authContext.setOfflineMode(true);
|
||||
*/
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get encryption key from background worker
|
||||
const encryptionKey = await sendMessage('GET_ENCRYPTION_KEY', {}, 'background') as string;
|
||||
@@ -142,9 +127,8 @@ export const useVaultSync = () : {
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Check if it's a version-related error (app needs to be updated)
|
||||
if (error instanceof Error && error.message.includes('This browser extension is outdated')) {
|
||||
await webApi.logout(error.message);
|
||||
onError?.(error.message);
|
||||
if (error instanceof VaultVersionIncompatibleError) {
|
||||
await app.logout(error.message);
|
||||
return false;
|
||||
}
|
||||
// Vault could not be decrypted, throw an error
|
||||
@@ -165,9 +149,8 @@ export const useVaultSync = () : {
|
||||
console.error('Vault sync error:', err);
|
||||
|
||||
// Check if it's a version-related error (app needs to be updated)
|
||||
if (errorMessage.includes('This browser extension is outdated')) {
|
||||
await webApi.logout(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
if (err instanceof VaultVersionIncompatibleError) {
|
||||
await app.logout(errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -185,7 +168,7 @@ export const useVaultSync = () : {
|
||||
onError?.(errorMessage);
|
||||
return false;
|
||||
}
|
||||
}, [authContext, dbContext, webApi, t]);
|
||||
}, [app, dbContext, webApi, t]);
|
||||
|
||||
return { syncVault };
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
import App from '@/entrypoints/popup/App';
|
||||
import { AppProvider } from '@/entrypoints/popup/context/AppContext';
|
||||
import { AuthProvider } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { DbProvider } from '@/entrypoints/popup/context/DbContext';
|
||||
import { HeaderButtonsProvider } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
@@ -17,17 +18,19 @@ 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>
|
||||
<WebApiProvider>
|
||||
<AuthProvider>
|
||||
<AppProvider>
|
||||
<LoadingProvider>
|
||||
<HeaderButtonsProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</HeaderButtonsProvider>
|
||||
</LoadingProvider>
|
||||
</AppProvider>
|
||||
</AuthProvider>
|
||||
</WebApiProvider>
|
||||
</DbProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,9 +2,10 @@ import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { consumePendingRedirectUrl } from '@/entrypoints/popup/hooks/useVaultLockRedirect';
|
||||
import { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync';
|
||||
|
||||
import { storage } from '#imports';
|
||||
@@ -31,7 +32,7 @@ const Reinitialize: React.FC = () => {
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
// Auth and DB state
|
||||
const { isInitialized: authInitialized, isLoggedIn } = useAuth();
|
||||
const { isInitialized: authInitialized, isLoggedIn } = useApp();
|
||||
const { dbInitialized, dbAvailable } = useDb();
|
||||
|
||||
// Derived state
|
||||
@@ -78,11 +79,20 @@ const Reinitialize: React.FC = () => {
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for inline unlock mode
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const inlineUnlock = urlParams.get('mode') === 'inline_unlock';
|
||||
/**
|
||||
* Handle initialization and redirect logic
|
||||
*/
|
||||
const handleInitialization = async (): Promise<void> => {
|
||||
// Check for inline unlock mode
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const inlineUnlock = urlParams.get('mode') === 'inline_unlock';
|
||||
|
||||
if (isFullyInitialized) {
|
||||
// Check for pending redirect URL in storage (set by useVaultLockRedirect hook)
|
||||
const pendingRedirectUrl = await consumePendingRedirectUrl();
|
||||
|
||||
if (!isFullyInitialized) {
|
||||
return;
|
||||
}
|
||||
// Prevent multiple vault syncs (only run sync once)
|
||||
const shouldRunSync = !hasInitialized.current;
|
||||
|
||||
@@ -110,6 +120,10 @@ const Reinitialize: React.FC = () => {
|
||||
if (inlineUnlock) {
|
||||
setIsInitialLoading(false);
|
||||
navigate('/unlock-success', { replace: true });
|
||||
} else if (pendingRedirectUrl) {
|
||||
// If there's a pending redirect URL in storage, use it (most reliable)
|
||||
setIsInitialLoading(false);
|
||||
navigate(pendingRedirectUrl, { replace: true });
|
||||
} else {
|
||||
await restoreLastPage();
|
||||
}
|
||||
@@ -138,7 +152,9 @@ const Reinitialize: React.FC = () => {
|
||||
setIsInitialLoading(false);
|
||||
restoreLastPage();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleInitialization();
|
||||
}, [isFullyInitialized, requiresAuth, isLoggedIn, dbAvailable, navigate, setIsInitialLoading, syncVault, restoreLastPage]);
|
||||
|
||||
// This component doesn't render anything visible - it just handles initialization
|
||||
|
||||
@@ -172,76 +172,105 @@ 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="font-medium text-gray-900 dark:text-white">{t('common.language')}</p>
|
||||
<LanguageSwitcher variant="dropdown" size="sm" />
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Server Configuration Section */}
|
||||
<div className="space-y-4 pb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t('settings.serverConfiguration', 'Server Configuration')}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('settings.serverConfigurationDescription', 'Configure the AliasVault server URL for self-hosted instances')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="api-connection" className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
{t('settings.serverUrl')}
|
||||
</label>
|
||||
<select
|
||||
id="api-connection"
|
||||
value={selectedOption}
|
||||
onChange={handleOptionChange}
|
||||
className="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||
>
|
||||
{DEFAULT_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedOption === 'custom' && (
|
||||
<div className="space-y-4 pl-4 border-l-2 border-primary-500">
|
||||
<div>
|
||||
<label htmlFor="custom-api-url" className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
{t('settings.customApiUrl', 'API URL')}
|
||||
</label>
|
||||
<input
|
||||
id="custom-api-url"
|
||||
type="text"
|
||||
value={customUrl}
|
||||
onChange={handleCustomUrlChange}
|
||||
placeholder="https://vault.example.com/api"
|
||||
className={`w-full bg-gray-50 border ${errors.apiUrl ? 'border-red-500' : 'border-gray-300'} text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white`}
|
||||
/>
|
||||
{errors.apiUrl && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.apiUrl}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('settings.apiUrlHint', 'The API endpoint URL (usually client URL + /api)')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="custom-client-url" className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
{t('settings.customClientUrl', 'Client URL')}
|
||||
</label>
|
||||
<input
|
||||
id="custom-client-url"
|
||||
type="text"
|
||||
value={customClientUrl}
|
||||
onChange={handleCustomClientUrlChange}
|
||||
placeholder="https://vault.example.com"
|
||||
className={`w-full bg-gray-50 border ${errors.clientUrl ? 'border-red-500' : 'border-gray-300'} text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white`}
|
||||
/>
|
||||
{errors.clientUrl && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.clientUrl}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('settings.clientUrlHint', 'The web interface URL of your self-hosted instance')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="api-connection" className="block font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
{t('settings.serverUrl')}
|
||||
</label>
|
||||
<select
|
||||
value={selectedOption}
|
||||
onChange={handleOptionChange}
|
||||
className="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||
>
|
||||
{DEFAULT_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Autofill Settings Section */}
|
||||
<div className="space-y-4 pb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t('settings.autofillSettings', 'Autofill Settings')}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('settings.autofillSettingsDescription', 'Enable or disable the autofill popup on web pages')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedOption === 'custom' && (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<label htmlFor="custom-client-url" className="block font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
Custom client URL
|
||||
</label>
|
||||
<input
|
||||
id="custom-client-url"
|
||||
type="text"
|
||||
value={customClientUrl}
|
||||
onChange={handleCustomClientUrlChange}
|
||||
placeholder="https://my-aliasvault-instance.com"
|
||||
className={`w-full bg-gray-50 border ${errors.clientUrl ? 'border-red-500' : 'border-gray-300'} text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white`}
|
||||
/>
|
||||
{errors.clientUrl && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.clientUrl}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('settings.autofillEnabled')}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{isGloballyEnabled
|
||||
? t('settings.autofillEnabledDescription', 'Autofill suggestions will appear on login forms')
|
||||
: t('settings.autofillDisabledDescription', 'Autofill suggestions are disabled globally')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label htmlFor="custom-api-url" className="block font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
Custom API URL
|
||||
</label>
|
||||
<input
|
||||
id="custom-api-url"
|
||||
type="text"
|
||||
value={customUrl}
|
||||
onChange={handleCustomUrlChange}
|
||||
placeholder="https://my-aliasvault-instance.com/api"
|
||||
className={`w-full bg-gray-50 border ${errors.apiUrl ? 'border-red-500' : 'border-gray-300'} text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white`}
|
||||
/>
|
||||
{errors.apiUrl && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.apiUrl}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Autofill Popup Settings Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{t('settings.autofillEnabled')}</p>
|
||||
<button
|
||||
onClick={toggleGlobalPopup}
|
||||
className={`px-4 py-2 rounded-md transition-colors ${
|
||||
className={`px-4 py-2 rounded-md transition-colors font-medium text-sm ${
|
||||
isGloballyEnabled
|
||||
? 'bg-green-200 text-green-800 hover:bg-green-300 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
|
||||
: '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'
|
||||
@@ -252,7 +281,21 @@ const AuthSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-gray-400 dark:text-gray-600">
|
||||
{/* Language Settings Section */}
|
||||
<div className="space-y-4 pb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t('settings.languageSettings', 'Language')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LanguageSwitcher variant="dropdown" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Info */}
|
||||
<div className="text-center text-xs text-gray-400 dark:text-gray-600 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{t('settings.version')}: {AppInfo.VERSION}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import Button from '@/entrypoints/popup/components/Button';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIcon, HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import LoginServerInfo from '@/entrypoints/popup/components/LoginServerInfo';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
@@ -30,7 +30,7 @@ import { storage } from '#imports';
|
||||
const Login: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const authContext = useAuth();
|
||||
const app = useApp();
|
||||
const dbContext = useDb();
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
const [credentials, setCredentials] = useState({
|
||||
@@ -65,15 +65,8 @@ const Login: React.FC = () => {
|
||||
'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);
|
||||
await app.setAuthTokens(username, token, refreshToken);
|
||||
|
||||
// Store the encryption key and derivation params separately
|
||||
await dbContext.storeEncryptionKey(passwordHashBase64);
|
||||
@@ -86,9 +79,6 @@ const Login: React.FC = () => {
|
||||
// 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()) {
|
||||
@@ -97,7 +87,7 @@ const Login: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
await authContext.logout();
|
||||
await app.logout();
|
||||
setError(err instanceof Error ? err.message : t('auth.errors.migrationError'));
|
||||
hideLoading();
|
||||
return;
|
||||
@@ -157,7 +147,7 @@ const Login: React.FC = () => {
|
||||
showLoading();
|
||||
|
||||
// Clear global message if set with every login attempt.
|
||||
authContext.clearGlobalMessage();
|
||||
app.clearGlobalMessage();
|
||||
|
||||
// Use the srpUtil instance instead of the imported singleton
|
||||
const loginResponse = await srpUtil.initiateLogin(ConversionUtility.normalizeUsername(credentials.username));
|
||||
@@ -233,7 +223,7 @@ const Login: React.FC = () => {
|
||||
showLoading();
|
||||
|
||||
if (!passwordHashString || !passwordHashBase64 || !loginResponse) {
|
||||
throw new Error(t('auth.errors.loginDataMissing'));
|
||||
throw new Error(t('common.errors.unknownError'));
|
||||
}
|
||||
|
||||
// Validate that 2FA code is a 6-digit number
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
|
||||
/**
|
||||
* Logout page.
|
||||
*/
|
||||
const Logout: React.FC = () => {
|
||||
const authContext = useAuth();
|
||||
const webApi = useWebApi();
|
||||
const navigate = useNavigate();
|
||||
/**
|
||||
* Logout and navigate to home page.
|
||||
*/
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Perform logout via async method to ensure logout is completed before navigating to home page.
|
||||
*/
|
||||
const performLogout = async () : Promise<void> => {
|
||||
await webApi.logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
performLogout();
|
||||
}, [authContext, navigate, webApi]);
|
||||
|
||||
// Return null since this is just a functional component that handles logout.
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Logout;
|
||||
@@ -7,6 +7,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import Button from '@/entrypoints/popup/components/Button';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIcon, HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
@@ -18,6 +19,7 @@ import SrpUtility from '@/entrypoints/popup/utils/SrpUtility';
|
||||
import { VAULT_LOCKED_DISMISS_UNTIL_KEY } from '@/utils/Constants';
|
||||
import type { VaultResponse } from '@/utils/dist/shared/models/webapi';
|
||||
import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
@@ -26,6 +28,7 @@ import { storage } from '#imports';
|
||||
*/
|
||||
const Unlock: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const app = useApp();
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
const navigate = useNavigate();
|
||||
@@ -39,22 +42,32 @@ const Unlock: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { showLoading, hideLoading, setIsInitialLoading } = useLoading();
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Make status call to API which acts as health check.
|
||||
*/
|
||||
const checkStatus = async () : Promise<void> => {
|
||||
const statusResponse = await webApi.getStatus();
|
||||
const statusError = webApi.validateStatusResponse(statusResponse);
|
||||
if (statusError !== null) {
|
||||
await webApi.logout(t('common.errors.' + statusError));
|
||||
navigate('/logout');
|
||||
}
|
||||
setIsInitialLoading(false);
|
||||
};
|
||||
/**
|
||||
* Make status call to API which acts as health check.
|
||||
* This runs only once during component mount.
|
||||
*/
|
||||
const checkStatus = async () : Promise<boolean> => {
|
||||
const statusResponse = await webApi.getStatus();
|
||||
const statusError = webApi.validateStatusResponse(statusResponse);
|
||||
|
||||
if (statusResponse.serverVersion === '0.0.0') {
|
||||
setError(t('common.errors.serverNotAvailable'));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (statusError !== null) {
|
||||
await app.logout(t('common.errors.' + statusError));
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsInitialLoading(false);
|
||||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
}, [webApi, authContext, setIsInitialLoading, navigate, t]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Only run once on mount
|
||||
|
||||
// Set header buttons on mount and clear on unmount
|
||||
useEffect((): (() => void) => {
|
||||
@@ -81,6 +94,12 @@ const Unlock: React.FC = () => {
|
||||
setError(null);
|
||||
showLoading();
|
||||
|
||||
const isStatusOk = await checkStatus();
|
||||
if (!isStatusOk) {
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Initiate login to get salt and server ephemeral
|
||||
const loginResponse = await srpUtil.initiateLogin(authContext.username!);
|
||||
@@ -96,13 +115,6 @@ const Unlock: React.FC = () => {
|
||||
// Make API call to get latest vault
|
||||
const vaultResponseJson = await webApi.get<VaultResponse>('Vault');
|
||||
|
||||
const vaultError = webApi.validateVaultResponse(vaultResponseJson, t);
|
||||
if (vaultError) {
|
||||
setError(t('common.apiErrors.' + vaultError));
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the derived key as base64 string required for decryption.
|
||||
const passwordHashBase64 = Buffer.from(passwordHash).toString('base64');
|
||||
|
||||
@@ -110,15 +122,26 @@ const Unlock: React.FC = () => {
|
||||
await dbContext.storeEncryptionKey(passwordHashBase64);
|
||||
|
||||
// Initialize the SQLite context with the new vault data.
|
||||
await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
|
||||
const sqliteClient = await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
|
||||
|
||||
// Check if there are pending migrations
|
||||
if (await sqliteClient.hasPendingMigrations()) {
|
||||
navigate('/upgrade', { replace: true });
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear dismiss until (which can be enabled after user has dimissed vault is locked popup) to ensure popup is shown.
|
||||
await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, 0);
|
||||
|
||||
// Redirect to reinitialize page
|
||||
navigate('/reinitialize', { replace: true });
|
||||
} catch (err) {
|
||||
setError(t('auth.errors.wrongPassword'));
|
||||
// Check if it's a version incompatibility error
|
||||
if (err instanceof VaultVersionIncompatibleError) {
|
||||
await app.logout(err.message);
|
||||
} else {
|
||||
setError(t('auth.errors.wrongPassword'));
|
||||
}
|
||||
console.error('Unlock error:', err);
|
||||
} finally {
|
||||
hideLoading();
|
||||
@@ -129,7 +152,7 @@ const Unlock: React.FC = () => {
|
||||
* Handle logout
|
||||
*/
|
||||
const handleLogout = () : void => {
|
||||
navigate('/logout', { replace: true });
|
||||
app.logout();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import Button from '@/entrypoints/popup/components/Button';
|
||||
import Modal from '@/entrypoints/popup/components/Dialogs/Modal';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import Modal from '@/entrypoints/popup/components/Modal';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
@@ -24,7 +24,7 @@ import { VaultSqlGenerator } from '@/utils/dist/shared/vault-sql';
|
||||
*/
|
||||
const Upgrade: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { username } = useAuth();
|
||||
const { username, logout } = useApp();
|
||||
const dbContext = useDb();
|
||||
const { sqliteClient } = dbContext;
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
@@ -65,7 +65,7 @@ const Upgrade: React.FC = () => {
|
||||
const loadVersionInfo = useCallback(async () => {
|
||||
try {
|
||||
if (sqliteClient) {
|
||||
const current = sqliteClient.getDatabaseVersion();
|
||||
const current = await sqliteClient.getDatabaseVersion();
|
||||
const latest = await sqliteClient.getLatestDatabaseVersion();
|
||||
setCurrentVersion(current);
|
||||
setLatestVersion(latest);
|
||||
@@ -165,7 +165,7 @@ const Upgrade: React.FC = () => {
|
||||
console.debug('executeVaultMutation done?');
|
||||
} catch (error) {
|
||||
console.error('Upgrade failed:', error);
|
||||
setError(error instanceof Error ? error.message : t('upgrade.alerts.unknownErrorDuringUpgrade'));
|
||||
setError(error instanceof Error ? error.message : t('common.errors.unknownError'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -206,7 +206,7 @@ const Upgrade: React.FC = () => {
|
||||
* Handle the logout.
|
||||
*/
|
||||
const handleLogout = async (): Promise<void> => {
|
||||
navigate('/logout');
|
||||
logout();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -296,7 +296,7 @@ const Upgrade: React.FC = () => {
|
||||
<div className="flex justify-between items-center">
|
||||
<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 ?? '...'}
|
||||
{currentVersion?.compatibleUpToVersion ?? '...'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -312,6 +312,7 @@ const Upgrade: React.FC = () => {
|
||||
<div className="flex flex-col w-full space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
id="upgrade-button"
|
||||
onClick={handleUpgrade}
|
||||
>
|
||||
{isLoading || isVaultMutationLoading ? (syncStatus || t('upgrade.upgrading')) : t('upgrade.upgrade')}
|
||||
|
||||
@@ -8,15 +8,15 @@ 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 AttachmentUploader from '@/entrypoints/popup/components/Credentials/Details/AttachmentUploader';
|
||||
import Modal from '@/entrypoints/popup/components/Dialogs/Modal';
|
||||
import EmailDomainField from '@/entrypoints/popup/components/Forms/EmailDomainField';
|
||||
import { FormInput } from '@/entrypoints/popup/components/Forms/FormInput';
|
||||
import PasswordField from '@/entrypoints/popup/components/Forms/PasswordField';
|
||||
import UsernameField from '@/entrypoints/popup/components/Forms/UsernameField';
|
||||
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';
|
||||
@@ -92,6 +92,7 @@ const CredentialAddEdit: React.FC = () => {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [originalAttachmentIds, setOriginalAttachmentIds] = useState<string[]>([]);
|
||||
const [passkeyMarkedForDeletion, setPasskeyMarkedForDeletion] = useState(false);
|
||||
const webApi = useWebApi();
|
||||
|
||||
// Track last generated values to avoid overwriting manual entries
|
||||
@@ -550,6 +551,11 @@ const CredentialAddEdit: React.FC = () => {
|
||||
|
||||
if (isEditMode) {
|
||||
await dbContext.sqliteClient!.updateCredentialById(data, originalAttachmentIds, attachments);
|
||||
|
||||
// Delete passkeys if marked for deletion
|
||||
if (passkeyMarkedForDeletion) {
|
||||
await dbContext.sqliteClient!.deletePasskeysByCredentialId(data.Id);
|
||||
}
|
||||
} else {
|
||||
const credentialId = await dbContext.sqliteClient!.createCredential(data, attachments);
|
||||
data.Id = credentialId.toString();
|
||||
@@ -570,7 +576,7 @@ const CredentialAddEdit: React.FC = () => {
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate, mode, watch, generateRandomAlias, webApi, clearPersistedValues, originalAttachmentIds, attachments]);
|
||||
}, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate, mode, watch, generateRandomAlias, webApi, clearPersistedValues, originalAttachmentIds, attachments, passkeyMarkedForDeletion]);
|
||||
|
||||
// Set header buttons on mount and clear on unmount
|
||||
useEffect((): (() => void) => {
|
||||
@@ -695,30 +701,167 @@ const CredentialAddEdit: React.FC = () => {
|
||||
<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('credentials.loginCredentials')}</h2>
|
||||
<div className="space-y-4">
|
||||
<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={t('common.username')}
|
||||
value={watch('Username') ?? ''}
|
||||
onChange={(value) => setValue('Username', value)}
|
||||
error={errors.Username?.message}
|
||||
onRegenerate={generateRandomUsername}
|
||||
/>
|
||||
<PasswordField
|
||||
id="password"
|
||||
label={t('common.password')}
|
||||
value={watch('Password') ?? ''}
|
||||
onChange={(value) => setValue('Password', value)}
|
||||
error={errors.Password?.message}
|
||||
showPassword={showPassword}
|
||||
onShowPasswordChange={setShowPassword}
|
||||
/>
|
||||
{watch('HasPasskey') ? (
|
||||
<>
|
||||
{/* When passkey exists: username, passkey, email, password */}
|
||||
<UsernameField
|
||||
id="username"
|
||||
label={t('common.username')}
|
||||
value={watch('Username') ?? ''}
|
||||
onChange={(value) => setValue('Username', value)}
|
||||
error={errors.Username?.message}
|
||||
onRegenerate={generateRandomUsername}
|
||||
/>
|
||||
{!passkeyMarkedForDeletion && (
|
||||
<div className="p-3 rounded bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-600 dark:text-gray-400 mt-0.5 flex-shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{t('passkeys.passkey')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPasskeyMarkedForDeletion(true)}
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
||||
title="Delete passkey"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<line x1="10" y1="11" x2="10" y2="17" />
|
||||
<line x1="14" y1="11" x2="14" y2="17" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1 mb-2">
|
||||
{watch('PasskeyRpId') && (
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{t('passkeys.site')}: </span>
|
||||
<span className="text-sm text-gray-900 dark:text-white">{watch('PasskeyRpId')}</span>
|
||||
</div>
|
||||
)}
|
||||
{watch('PasskeyDisplayName') && (
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{t('passkeys.displayName')}: </span>
|
||||
<span className="text-sm text-gray-900 dark:text-white">{watch('PasskeyDisplayName')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{t('passkeys.helpText')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{passkeyMarkedForDeletion && (
|
||||
<div className="p-3 rounded bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-red-900 dark:text-red-100">{t('passkeys.passkeyMarkedForDeletion')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPasskeyMarkedForDeletion(false)}
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
title="Undo"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 7v6h6" />
|
||||
<path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-red-800 dark:text-red-200">
|
||||
{t('passkeys.passkeyWillBeDeleted')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<EmailDomainField
|
||||
id="email"
|
||||
label={t('common.email')}
|
||||
value={watch('Alias.Email') ?? ''}
|
||||
onChange={(value: string) => setValue('Alias.Email', value)}
|
||||
error={errors.Alias?.Email?.message}
|
||||
/>
|
||||
<PasswordField
|
||||
id="password"
|
||||
label={t('common.password')}
|
||||
value={watch('Password') ?? ''}
|
||||
onChange={(value) => setValue('Password', value)}
|
||||
error={errors.Password?.message}
|
||||
showPassword={showPassword}
|
||||
onShowPasswordChange={setShowPassword}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* When no passkey: email, username, password */}
|
||||
<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={t('common.username')}
|
||||
value={watch('Username') ?? ''}
|
||||
onChange={(value) => setValue('Username', value)}
|
||||
error={errors.Username?.message}
|
||||
onRegenerate={generateRandomUsername}
|
||||
/>
|
||||
<PasswordField
|
||||
id="password"
|
||||
label={t('common.password')}
|
||||
value={watch('Password') ?? ''}
|
||||
onChange={(value) => setValue('Password', value)}
|
||||
error={errors.Password?.message}
|
||||
showPassword={showPassword}
|
||||
onShowPasswordChange={setShowPassword}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
AliasBlock,
|
||||
NotesBlock,
|
||||
AttachmentBlock
|
||||
} from '@/entrypoints/popup/components/CredentialDetails';
|
||||
} from '@/entrypoints/popup/components/Credentials/Details';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
@@ -2,15 +2,15 @@ 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';
|
||||
import CredentialCard from '@/entrypoints/popup/components/Credentials/CredentialCard';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import ReloadButton from '@/entrypoints/popup/components/ReloadButton';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
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 { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync';
|
||||
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
|
||||
|
||||
@@ -18,18 +18,64 @@ import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
|
||||
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
|
||||
|
||||
type FilterType = 'all' | 'passkeys' | 'aliases' | 'userpass' | 'attachments';
|
||||
|
||||
const FILTER_STORAGE_KEY = 'credentials-filter';
|
||||
const FILTER_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Get stored filter from localStorage if not expired
|
||||
*/
|
||||
const getStoredFilter = (): FilterType => {
|
||||
try {
|
||||
const stored = localStorage.getItem(FILTER_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
const { filter, timestamp } = JSON.parse(stored);
|
||||
const now = Date.now();
|
||||
|
||||
// Check if expired (5 minutes)
|
||||
if (now - timestamp > FILTER_EXPIRY_MS) {
|
||||
localStorage.removeItem(FILTER_STORAGE_KEY);
|
||||
return 'all';
|
||||
}
|
||||
|
||||
return filter as FilterType;
|
||||
} catch {
|
||||
return 'all';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Store filter in localStorage with timestamp
|
||||
*/
|
||||
const storeFilter = (filter: FilterType): void => {
|
||||
try {
|
||||
localStorage.setItem(FILTER_STORAGE_KEY, JSON.stringify({
|
||||
filter,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Credentials list page.
|
||||
*/
|
||||
const CredentialsList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
const app = useApp();
|
||||
const navigate = useNavigate();
|
||||
const { syncVault } = useVaultSync();
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterType, setFilterType] = useState<FilterType>(getStoredFilter());
|
||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
|
||||
/**
|
||||
@@ -72,16 +118,13 @@ const CredentialsList: React.FC = () => {
|
||||
*/
|
||||
onError: async (error) => {
|
||||
console.error('Error syncing vault:', error);
|
||||
await webApi.logout('Error while syncing vault, please re-authenticate.');
|
||||
navigate('/logout');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error refreshing credentials:', err);
|
||||
await webApi.logout('Error while syncing vault, please re-authenticate.');
|
||||
navigate('/logout');
|
||||
await app.logout('Error while syncing vault, please re-authenticate.');
|
||||
}
|
||||
}, [dbContext, webApi, syncVault, navigate]);
|
||||
}, [dbContext, app, syncVault]);
|
||||
|
||||
/**
|
||||
* Get latest vault from server and refresh the credentials list.
|
||||
@@ -135,8 +178,67 @@ const CredentialsList: React.FC = () => {
|
||||
refreshCredentials();
|
||||
}, [dbContext?.sqliteClient, setIsLoading, setIsInitialLoading]);
|
||||
|
||||
const filteredCredentials = credentials.filter(credential => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
/**
|
||||
* Get the title based on the active filter
|
||||
*/
|
||||
const getFilterTitle = () : string => {
|
||||
switch (filterType) {
|
||||
case 'passkeys':
|
||||
return t('credentials.filters.passkeys');
|
||||
case 'aliases':
|
||||
return t('credentials.filters.aliases');
|
||||
case 'userpass':
|
||||
return t('credentials.filters.userpass');
|
||||
case 'attachments':
|
||||
return t('credentials.filters.attachments');
|
||||
default:
|
||||
return t('credentials.title');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCredentials = credentials.filter((credential: Credential) => {
|
||||
// First apply type filter
|
||||
let passesTypeFilter = true;
|
||||
|
||||
if (filterType === 'passkeys') {
|
||||
passesTypeFilter = credential.HasPasskey === true;
|
||||
} else if (filterType === 'aliases') {
|
||||
// Check for non-empty alias fields (excluding email which is used everywhere)
|
||||
passesTypeFilter = !!(
|
||||
(credential.Alias?.FirstName && credential.Alias.FirstName.trim()) ||
|
||||
(credential.Alias?.LastName && credential.Alias.LastName.trim()) ||
|
||||
(credential.Alias?.NickName && credential.Alias.NickName.trim()) ||
|
||||
(credential.Alias?.Gender && credential.Alias.Gender.trim()) ||
|
||||
(credential.Alias?.BirthDate && credential.Alias.BirthDate.trim() && credential.Alias.BirthDate.trim().startsWith('0001-01-01') !== true)
|
||||
);
|
||||
} else if (filterType === 'userpass') {
|
||||
// Show only credentials that have username/password AND do NOT have alias fields AND do NOT have passkey
|
||||
const hasAliasFields = !!(
|
||||
(credential.Alias?.FirstName && credential.Alias.FirstName.trim()) ||
|
||||
(credential.Alias?.LastName && credential.Alias.LastName.trim()) ||
|
||||
(credential.Alias?.NickName && credential.Alias.NickName.trim()) ||
|
||||
(credential.Alias?.Gender && credential.Alias.Gender.trim()) ||
|
||||
(credential.Alias?.BirthDate && credential.Alias.BirthDate.trim() && credential.Alias.BirthDate.trim().startsWith('0001-01-01') !== true)
|
||||
);
|
||||
const hasUsernameOrPassword = !!(
|
||||
(credential.Username && credential.Username.trim()) ||
|
||||
(credential.Password && credential.Password.trim())
|
||||
);
|
||||
passesTypeFilter = hasUsernameOrPassword && !credential.HasPasskey && !hasAliasFields;
|
||||
} else if (filterType === 'attachments') {
|
||||
passesTypeFilter = credential.HasAttachment === true;
|
||||
}
|
||||
|
||||
if (!passesTypeFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Then apply search filter
|
||||
const searchLower = searchTerm.toLowerCase().trim();
|
||||
|
||||
if (!searchLower) {
|
||||
return true; // No search term, include all
|
||||
}
|
||||
|
||||
/**
|
||||
* We filter credentials by searching in the following fields:
|
||||
@@ -147,13 +249,20 @@ const CredentialsList: React.FC = () => {
|
||||
* - Notes
|
||||
*/
|
||||
const searchableFields = [
|
||||
credential.ServiceName?.toLowerCase(),
|
||||
credential.Username?.toLowerCase(),
|
||||
credential.Alias?.Email?.toLowerCase(),
|
||||
credential.ServiceUrl?.toLowerCase(),
|
||||
credential.Notes?.toLowerCase(),
|
||||
credential.ServiceName?.toLowerCase() || '',
|
||||
credential.Username?.toLowerCase() || '',
|
||||
credential.Alias?.Email?.toLowerCase() || '',
|
||||
credential.ServiceUrl?.toLowerCase() || '',
|
||||
credential.Notes?.toLowerCase() || '',
|
||||
];
|
||||
return searchableFields.some(field => field?.includes(searchLower));
|
||||
|
||||
// Split search term into words for AND search
|
||||
const searchWords = searchLower.split(/\s+/).filter(word => word.length > 0);
|
||||
|
||||
// All search words must be found (each in at least one field)
|
||||
return searchWords.every(word =>
|
||||
searchableFields.some(field => field.includes(word))
|
||||
);
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
@@ -167,7 +276,106 @@ 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">{t('credentials.title')}</h2>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowFilterMenu(!showFilterMenu)}
|
||||
className="flex items-center gap-1 text-gray-900 dark:text-white text-xl hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none"
|
||||
>
|
||||
<h2 className="flex items-baseline gap-1.5">
|
||||
{getFilterTitle()}
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">({filteredCredentials.length})</span>
|
||||
</h2>
|
||||
<svg
|
||||
className="w-4 h-4 mt-1"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showFilterMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setShowFilterMenu(false)}
|
||||
/>
|
||||
<div className="absolute left-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20">
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
const newFilter = 'all';
|
||||
setFilterType(newFilter);
|
||||
storeFilter(newFilter);
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
filterType === 'all' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('credentials.filters.all')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newFilter = 'passkeys';
|
||||
setFilterType(newFilter);
|
||||
storeFilter(newFilter);
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
filterType === 'passkeys' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('credentials.filters.passkeys')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newFilter = 'aliases';
|
||||
setFilterType(newFilter);
|
||||
storeFilter(newFilter);
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
filterType === 'aliases' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('credentials.filters.aliases')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newFilter = 'userpass';
|
||||
setFilterType(newFilter);
|
||||
storeFilter(newFilter);
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
filterType === 'userpass' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('credentials.filters.userpass')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newFilter = 'attachments';
|
||||
setFilterType(newFilter);
|
||||
storeFilter(newFilter);
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
filterType === 'attachments' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('credentials.filters.attachments')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ReloadButton onClick={syncVaultAndRefresh} />
|
||||
</div>
|
||||
|
||||
@@ -195,6 +403,17 @@ const CredentialsList: React.FC = () => {
|
||||
{t('credentials.welcomeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredCredentials.length === 0 ? (
|
||||
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
|
||||
<p>
|
||||
{filterType === 'passkeys'
|
||||
? t('credentials.noPasskeysFound')
|
||||
: filterType === 'attachments'
|
||||
? t('credentials.noAttachmentsFound')
|
||||
: t('credentials.noMatchingCredentials')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{filteredCredentials.map(cred => (
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
import Modal from '@/entrypoints/popup/components/Dialogs/Modal';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import Modal from '@/entrypoints/popup/components/Modal';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { extractDomain, extractRootDomain } from '@/entrypoints/contentScript/Filter';
|
||||
import Button from '@/entrypoints/popup/components/Button';
|
||||
import PasskeyBypassDialog from '@/entrypoints/popup/components/Dialogs/PasskeyBypassDialog';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { useVaultLockRedirect } from '@/entrypoints/popup/hooks/useVaultLockRedirect';
|
||||
|
||||
import { PASSKEY_DISABLED_SITES_KEY } from '@/utils/Constants';
|
||||
import { PasskeyAuthenticator } from '@/utils/passkey/PasskeyAuthenticator';
|
||||
import { PasskeyHelper } from '@/utils/passkey/PasskeyHelper';
|
||||
import type { GetRequest, PasskeyGetCredentialResponse, PendingPasskeyGetRequest, StoredPasskeyRecord } from '@/utils/passkey/types';
|
||||
|
||||
import { storage } from "#imports";
|
||||
|
||||
/**
|
||||
* PasskeyAuthenticate
|
||||
*/
|
||||
const PasskeyAuthenticate: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const dbContext = useDb();
|
||||
const [request, setRequest] = useState<PendingPasskeyGetRequest | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [availablePasskeys, setAvailablePasskeys] = useState<Array<{ id: string; displayName: string; rpId: string; serviceName?: string | null }>>([]);
|
||||
const [showBypassDialog, setShowBypassDialog] = useState(false);
|
||||
const { isLocked } = useVaultLockRedirect();
|
||||
const firstPasskeyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* fetchRequestData
|
||||
*/
|
||||
const fetchRequestData = async () : Promise<void> => {
|
||||
// Wait for DB to be initialized
|
||||
if (!dbContext.dbInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If vault is locked, the hook will handle redirect, we just return
|
||||
if (isLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the requestId from URL
|
||||
const params = new URLSearchParams(location.search);
|
||||
const requestId = params.get('requestId');
|
||||
|
||||
if (requestId) {
|
||||
try {
|
||||
// Fetch the full request data from background
|
||||
const data = await sendMessage('GET_REQUEST_DATA', { requestId }, 'background') as unknown as PendingPasskeyGetRequest;
|
||||
|
||||
if (data && data.type === 'get') {
|
||||
setRequest(data);
|
||||
|
||||
// Get passkeys for this rpId from the vault
|
||||
const rpId = data.publicKey.rpId || new URL(data.origin).hostname;
|
||||
const passkeys = dbContext.sqliteClient!.getPasskeysByRpId(rpId);
|
||||
|
||||
// Filter by allowCredentials if specified
|
||||
let filteredPasskeys = passkeys;
|
||||
if (data.publicKey.allowCredentials && data.publicKey.allowCredentials.length > 0) {
|
||||
// Convert the RP's base64url credential IDs to GUIDs for comparison
|
||||
const allowedGuids = new Set(
|
||||
data.publicKey.allowCredentials.map(c => {
|
||||
try {
|
||||
return PasskeyHelper.base64urlToGuid(c.id);
|
||||
} catch (e) {
|
||||
console.warn('Failed to convert credential ID to GUID:', c.id, e);
|
||||
return null;
|
||||
}
|
||||
}).filter((id): id is string => id !== null)
|
||||
);
|
||||
filteredPasskeys = passkeys.filter(pk => allowedGuids.has(pk.Id));
|
||||
}
|
||||
|
||||
// Map to display format
|
||||
setAvailablePasskeys(filteredPasskeys.map(pk => ({
|
||||
id: pk.Id,
|
||||
displayName: pk.DisplayName,
|
||||
serviceName: pk.ServiceName,
|
||||
rpId: pk.RpId,
|
||||
username: pk.Username
|
||||
})));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch request data:', error);
|
||||
setError(t('common.errors.unknownError'));
|
||||
}
|
||||
}
|
||||
|
||||
// Mark initial loading as complete
|
||||
setIsInitialLoading(false);
|
||||
};
|
||||
|
||||
fetchRequestData();
|
||||
}, [location, setIsInitialLoading, dbContext.dbInitialized, isLocked, dbContext.sqliteClient, t]);
|
||||
|
||||
// Auto-focus first passkey
|
||||
useEffect(() => {
|
||||
if (availablePasskeys.length > 0 && firstPasskeyRef.current) {
|
||||
firstPasskeyRef.current.focus();
|
||||
}
|
||||
}, [availablePasskeys.length]);
|
||||
|
||||
// Handle Enter key to select first passkey
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Handle Enter key to select first passkey
|
||||
*/
|
||||
const handleKeyDown = (e: KeyboardEvent) : void => {
|
||||
if (e.key === 'Enter' && !loading && availablePasskeys.length > 0) {
|
||||
handleUsePasskey(availablePasskeys[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle Enter key to select first passkey
|
||||
*/
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () : void => window.removeEventListener('keydown', handleKeyDown);
|
||||
|
||||
/**
|
||||
* Handle Enter key to select first passkey
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, availablePasskeys]);
|
||||
|
||||
/**
|
||||
* Handle passkey authentication
|
||||
*/
|
||||
const handleUsePasskey = async (passkeyId: string) : Promise<void> => {
|
||||
if (!request || !dbContext.sqliteClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get the stored passkey from vault
|
||||
const storedPasskey = dbContext.sqliteClient.getPasskeyById(passkeyId);
|
||||
if (!storedPasskey) {
|
||||
throw new Error(t('common.errors.unknownError'));
|
||||
}
|
||||
|
||||
// Parse the stored keys
|
||||
const publicKey = JSON.parse(storedPasskey.PublicKey) as JsonWebKey;
|
||||
const privateKey = JSON.parse(storedPasskey.PrivateKey) as JsonWebKey;
|
||||
|
||||
// Extract PRF secret from PrfKey if available
|
||||
let prfSecret: string | undefined;
|
||||
|
||||
if (storedPasskey.PrfKey) {
|
||||
try {
|
||||
// Convert PrfKey bytes to base64url string
|
||||
prfSecret = PasskeyHelper.bytesToBase64url(storedPasskey.PrfKey);
|
||||
} catch (e) {
|
||||
console.warn('Failed to convert PrfKey to base64url', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the stored record for the provider
|
||||
* Convert UserHandle from byte array to base64 string for serialization
|
||||
*/
|
||||
let userIdBase64: string | null = null;
|
||||
if (storedPasskey.UserHandle) {
|
||||
try {
|
||||
const userHandleBytes = storedPasskey.UserHandle instanceof Uint8Array ? storedPasskey.UserHandle : new Uint8Array(storedPasskey.UserHandle);
|
||||
userIdBase64 = PasskeyHelper.bytesToBase64url(userHandleBytes);
|
||||
} catch (e) {
|
||||
console.warn('Failed to convert UserHandle to base64', e);
|
||||
}
|
||||
}
|
||||
|
||||
const storedRecord: StoredPasskeyRecord = {
|
||||
rpId: storedPasskey.RpId,
|
||||
credentialId: PasskeyHelper.guidToBase64url(storedPasskey.Id),
|
||||
publicKey,
|
||||
privateKey,
|
||||
userId: userIdBase64,
|
||||
userName: storedPasskey.Username ?? undefined,
|
||||
userDisplayName: storedPasskey.ServiceName ?? undefined,
|
||||
prfSecret
|
||||
};
|
||||
|
||||
// Build the GetRequest
|
||||
const getRequest: GetRequest = {
|
||||
origin: request.origin,
|
||||
requestId: request.requestId,
|
||||
publicKey: {
|
||||
rpId: request.publicKey.rpId,
|
||||
challenge: request.publicKey.challenge,
|
||||
userVerification: request.publicKey.userVerification
|
||||
}
|
||||
};
|
||||
|
||||
// Extract PRF inputs if requested
|
||||
let prfInputs: { first: ArrayBuffer | Uint8Array; second?: ArrayBuffer | Uint8Array } | undefined;
|
||||
if (request.publicKey.extensions?.prf?.eval) {
|
||||
// Handle numeric object format (serialized Uint8Array through events)
|
||||
const firstInput = request.publicKey.extensions.prf.eval.first;
|
||||
let firstBytes: Uint8Array;
|
||||
|
||||
if (typeof firstInput === 'object' && firstInput !== null && !Array.isArray(firstInput)) {
|
||||
// Numeric object format: {0: 68, 1: 204, ...}
|
||||
const keys = Object.keys(firstInput).map(Number).sort((a, b) => a - b);
|
||||
firstBytes = new Uint8Array(keys.length);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
firstBytes[i] = (firstInput as unknown as Record<string, number>)[i];
|
||||
}
|
||||
} else if (typeof firstInput === 'string') {
|
||||
// Base64 string format
|
||||
const firstDecoded = atob(firstInput);
|
||||
firstBytes = new Uint8Array(firstDecoded.length);
|
||||
for (let i = 0; i < firstDecoded.length; i++) {
|
||||
firstBytes[i] = firstDecoded.charCodeAt(i);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unknown PRF input format');
|
||||
}
|
||||
|
||||
prfInputs = { first: firstBytes };
|
||||
|
||||
if (request.publicKey.extensions.prf.eval.second) {
|
||||
const secondInput = request.publicKey.extensions.prf.eval.second;
|
||||
let secondBytes: Uint8Array;
|
||||
|
||||
if (typeof secondInput === 'object' && secondInput !== null && !Array.isArray(secondInput)) {
|
||||
const keys = Object.keys(secondInput).map(Number).sort((a, b) => a - b);
|
||||
secondBytes = new Uint8Array(keys.length);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
secondBytes[i] = (secondInput as unknown as Record<string, number>)[i];
|
||||
}
|
||||
} else if (typeof secondInput === 'string') {
|
||||
const secondDecoded = atob(secondInput);
|
||||
secondBytes = new Uint8Array(secondDecoded.length);
|
||||
for (let i = 0; i < secondDecoded.length; i++) {
|
||||
secondBytes[i] = secondDecoded.charCodeAt(i);
|
||||
}
|
||||
} else {
|
||||
console.error('[PasskeyAuth] Unknown PRF second input type:', typeof secondInput);
|
||||
throw new Error('Unknown PRF second input format');
|
||||
}
|
||||
|
||||
prfInputs.second = secondBytes;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the assertion using the static method
|
||||
const assertion = await PasskeyAuthenticator.getAssertion(getRequest, storedRecord, {
|
||||
uvPerformed: true, // TODO: implement explicit user verification check
|
||||
includeBEBS: true, // Backup eligible/state - defaults to true
|
||||
prfInputs
|
||||
});
|
||||
|
||||
// Convert PRF results to base64 for transport
|
||||
let prfResults: { first: string; second?: string } | undefined;
|
||||
if (assertion.prfResults) {
|
||||
prfResults = {
|
||||
first: PasskeyHelper.arrayBufferToBase64(assertion.prfResults.first)
|
||||
};
|
||||
if (assertion.prfResults.second) {
|
||||
prfResults.second = PasskeyHelper.arrayBufferToBase64(assertion.prfResults.second);
|
||||
}
|
||||
}
|
||||
|
||||
const credential: PasskeyGetCredentialResponse = {
|
||||
id: assertion.id,
|
||||
rawId: assertion.rawId,
|
||||
clientDataJSON: assertion.clientDataJSON,
|
||||
authenticatorData: assertion.authenticatorData,
|
||||
signature: assertion.signature,
|
||||
userHandle: assertion.userHandle,
|
||||
prfResults
|
||||
};
|
||||
|
||||
/*
|
||||
* Send response back
|
||||
* The background script will close the window (Safari-compatible)
|
||||
*/
|
||||
await sendMessage('PASSKEY_POPUP_RESPONSE', {
|
||||
requestId: request.requestId,
|
||||
credential
|
||||
}, 'background');
|
||||
} catch (error) {
|
||||
console.error('PasskeyAuthenticate: Error during authentication', error);
|
||||
setLoading(false);
|
||||
setError(t('common.errors.unknownError'));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle fallback - show bypass dialog first
|
||||
*/
|
||||
const handleFallback = async () : Promise<void> => {
|
||||
setShowBypassDialog(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle bypass choice
|
||||
*/
|
||||
const handleBypassChoice = async (choice: 'once' | 'always') : Promise<void> => {
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (choice === 'always') {
|
||||
// Add to permanent disabled list
|
||||
const hostname = new URL(request.origin).hostname;
|
||||
const baseDomain = extractRootDomain(extractDomain(hostname));
|
||||
|
||||
const disabledSites = await storage.getItem(PASSKEY_DISABLED_SITES_KEY) as string[] ?? [];
|
||||
if (!disabledSites.includes(baseDomain)) {
|
||||
disabledSites.push(baseDomain);
|
||||
await storage.setItem(PASSKEY_DISABLED_SITES_KEY, disabledSites);
|
||||
}
|
||||
}
|
||||
// For 'once', we don't store anything - just bypass this one time
|
||||
|
||||
/*
|
||||
* Tell background to use native implementation
|
||||
* The background script will close the window (Safari-compatible)
|
||||
*/
|
||||
await sendMessage('PASSKEY_POPUP_RESPONSE', {
|
||||
requestId: request.requestId,
|
||||
fallback: true
|
||||
}, 'background');
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle cancel
|
||||
*/
|
||||
const handleCancel = async () : Promise<void> => {
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Tell background user cancelled
|
||||
* The background script will close the window (Safari-compatible)
|
||||
*/
|
||||
await sendMessage('PASSKEY_POPUP_RESPONSE', {
|
||||
requestId: request.requestId,
|
||||
cancelled: true
|
||||
}, 'background');
|
||||
};
|
||||
|
||||
if (!request) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showBypassDialog && request && (
|
||||
<PasskeyBypassDialog
|
||||
origin={new URL(request.origin).hostname}
|
||||
onChoice={handleBypassChoice}
|
||||
onCancel={() => setShowBypassDialog(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('passkeys.authenticate.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('passkeys.authenticate.signInFor')} <strong>{request.origin}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{availablePasskeys && availablePasskeys.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('passkeys.authenticate.selectPasskey')}
|
||||
</label>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto border rounded-lg p-2 bg-gray-50 dark:bg-gray-800">
|
||||
{availablePasskeys.map((pk, index) => (
|
||||
<div
|
||||
key={pk.id}
|
||||
ref={index === 0 ? firstPasskeyRef : null}
|
||||
tabIndex={0}
|
||||
className="p-3 rounded-lg border cursor-pointer transition-colors bg-white border-gray-200 hover:bg-gray-100 hover:border-gray-300 dark:bg-gray-700 dark:border-gray-600 dark:hover:bg-gray-600 dark:hover:border-gray-500 focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
onClick={() => !loading && handleUsePasskey(pk.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !loading) {
|
||||
handleUsePasskey(pk.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-white text-sm truncate">
|
||||
{pk.serviceName}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="truncate">{pk.displayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('passkeys.authenticate.noPasskeysFound')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleFallback}
|
||||
>
|
||||
{t('passkeys.authenticate.useBrowserPasskey')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasskeyAuthenticate;
|
||||
@@ -0,0 +1,653 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { extractDomain, extractRootDomain } from '@/entrypoints/contentScript/Filter';
|
||||
import Alert from '@/entrypoints/popup/components/Alert';
|
||||
import Button from '@/entrypoints/popup/components/Button';
|
||||
import PasskeyBypassDialog from '@/entrypoints/popup/components/Dialogs/PasskeyBypassDialog';
|
||||
import { FormInput } from '@/entrypoints/popup/components/Forms/FormInput';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
import { useVaultLockRedirect } from '@/entrypoints/popup/hooks/useVaultLockRedirect';
|
||||
import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
|
||||
|
||||
import { PASSKEY_DISABLED_SITES_KEY } from '@/utils/Constants';
|
||||
import type { Passkey } from '@/utils/dist/shared/models/vault';
|
||||
import { PasskeyAuthenticator } from '@/utils/passkey/PasskeyAuthenticator';
|
||||
import { PasskeyHelper } from '@/utils/passkey/PasskeyHelper';
|
||||
import type { CreateRequest, PasskeyCreateCredentialResponse, PendingPasskeyCreateRequest } from '@/utils/passkey/types';
|
||||
|
||||
import { storage } from "#imports";
|
||||
|
||||
/**
|
||||
* PasskeyCreate
|
||||
*/
|
||||
const PasskeyCreate: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
const { executeVaultMutation, isLoading: isMutating, syncStatus } = useVaultMutate();
|
||||
const [request, setRequest] = useState<PendingPasskeyCreateRequest | null>(null);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isLocked } = useVaultLockRedirect();
|
||||
const [existingPasskeys, setExistingPasskeys] = useState<Array<Passkey & { Username?: string | null; ServiceName?: string | null }>>([]);
|
||||
const [selectedPasskeyToReplace, setSelectedPasskeyToReplace] = useState<string | null>(null);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [localLoading, setLocalLoading] = useState(false);
|
||||
const [showBypassDialog, setShowBypassDialog] = useState(false);
|
||||
const createNewButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const displayNameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* fetchRequestData
|
||||
*/
|
||||
const fetchRequestData = async () : Promise<void> => {
|
||||
// Wait for DB to be initialized
|
||||
if (!dbContext.dbInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If vault is locked, the hook will handle redirect, we just return
|
||||
if (isLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the requestId from URL
|
||||
const params = new URLSearchParams(location.search);
|
||||
const requestId = params.get('requestId');
|
||||
|
||||
if (requestId) {
|
||||
try {
|
||||
// Fetch the full request data from background
|
||||
const data = await sendMessage('GET_REQUEST_DATA', { requestId }, 'background') as unknown as PendingPasskeyCreateRequest;
|
||||
if (data && data.type === 'create') {
|
||||
setRequest(data);
|
||||
|
||||
/**
|
||||
* Set default displayName: use rp.name if available, otherwise use rpId
|
||||
* This aligns with iOS/Android behavior
|
||||
*/
|
||||
const defaultName = data.publicKey?.rp?.name || data.publicKey?.rp?.id || 'Passkey';
|
||||
setDisplayName(defaultName);
|
||||
|
||||
// Check for existing passkeys for this RP ID and user
|
||||
if (dbContext.sqliteClient && data.publicKey?.rp?.id) {
|
||||
const allPasskeysForRpId = dbContext.sqliteClient.getPasskeysByRpId(data.publicKey.rp.id);
|
||||
|
||||
/**
|
||||
* Filter by user ID and/or username if provided
|
||||
* This allows for multiple users on the same site
|
||||
*/
|
||||
let filtered = allPasskeysForRpId;
|
||||
|
||||
if (data.publicKey.user?.id || data.publicKey.user?.name) {
|
||||
filtered = allPasskeysForRpId.filter(passkey => {
|
||||
/**
|
||||
* Match by user handle if both are available
|
||||
* The request has base64url encoded user.id, passkey has UserHandle as byte array
|
||||
* Convert request's user.id to bytes for comparison
|
||||
*/
|
||||
if (data.publicKey.user?.id && passkey.UserHandle) {
|
||||
try {
|
||||
const requestUserIdBytes = PasskeyHelper.base64urlToBytes(data.publicKey.user.id);
|
||||
const passkeyUserHandle = passkey.UserHandle instanceof Uint8Array ? passkey.UserHandle : new Uint8Array(passkey.UserHandle);
|
||||
|
||||
// Compare byte arrays
|
||||
if (requestUserIdBytes.length === passkeyUserHandle.length &&
|
||||
requestUserIdBytes.every((byte, idx) => byte === passkeyUserHandle[idx])) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// If conversion fails, skip this passkey
|
||||
}
|
||||
}
|
||||
|
||||
// Also match by username if available (from the credential)
|
||||
if (data.publicKey.user?.name && passkey.Username) {
|
||||
if (passkey.Username === data.publicKey.user.name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If neither user ID nor username match, exclude this passkey
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
setExistingPasskeys(filtered);
|
||||
// If no existing passkeys for this user, go straight to create form
|
||||
if (filtered.length === 0) {
|
||||
setShowCreateForm(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch request data:', error);
|
||||
setError(t('common.errors.unknownError'));
|
||||
}
|
||||
}
|
||||
|
||||
setIsInitialLoading(false);
|
||||
};
|
||||
|
||||
fetchRequestData();
|
||||
}, [location, setIsInitialLoading, dbContext.dbInitialized, dbContext.sqliteClient, isLocked, t]);
|
||||
|
||||
// Auto-focus create new button or input field
|
||||
useEffect(() => {
|
||||
if (showCreateForm && displayNameInputRef.current) {
|
||||
displayNameInputRef.current.focus();
|
||||
} else if (!showCreateForm && existingPasskeys.length > 0 && createNewButtonRef.current) {
|
||||
createNewButtonRef.current.focus();
|
||||
}
|
||||
}, [showCreateForm, existingPasskeys.length]);
|
||||
|
||||
// Handle Enter key to submit
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Handle Enter key to submit
|
||||
*/
|
||||
const handleKeyDown = (e: KeyboardEvent) : void => {
|
||||
if (e.key === 'Enter' && !localLoading && !isMutating) {
|
||||
if (showCreateForm) {
|
||||
handleCreate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () : void => window.removeEventListener('keydown', handleKeyDown);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showCreateForm, localLoading, isMutating]);
|
||||
|
||||
/**
|
||||
* Handle when user clicks "Create New Passkey" button
|
||||
*/
|
||||
const handleCreateNew = () : void => {
|
||||
setSelectedPasskeyToReplace(null);
|
||||
setShowCreateForm(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle when user selects an existing passkey to replace
|
||||
*/
|
||||
const handleSelectReplace = (passkeyId: string) : void => {
|
||||
setSelectedPasskeyToReplace(passkeyId);
|
||||
setShowCreateForm(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle passkey creation
|
||||
*/
|
||||
const handleCreate = async () : Promise<void> => {
|
||||
if (!request || !dbContext.sqliteClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Extract favicon from origin URL
|
||||
let faviconLogo: Uint8Array | undefined = undefined;
|
||||
if (request.origin) {
|
||||
setLocalLoading(true);
|
||||
try {
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Favicon extraction timed out')), 5000)
|
||||
);
|
||||
|
||||
const faviconPromise = webApi.get<{ image: string }>('Favicon/Extract?url=' + request.origin);
|
||||
const faviconResponse = await Promise.race([faviconPromise, timeoutPromise]) as { image: string };
|
||||
|
||||
if (faviconResponse?.image) {
|
||||
// Use browser-compatible base64 decoding
|
||||
const binaryString = atob(faviconResponse.image);
|
||||
const decodedImage = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
decodedImage[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
faviconLogo = decodedImage;
|
||||
}
|
||||
} catch {
|
||||
// Favicon extraction failed or timed out, this is not a critical error so we can ignore it.
|
||||
}
|
||||
}
|
||||
|
||||
// Build the CreateRequest
|
||||
const createRequest: CreateRequest = {
|
||||
origin: request.origin,
|
||||
requestId: request.requestId,
|
||||
publicKey: {
|
||||
rp: request.publicKey.rp,
|
||||
user: request.publicKey.user,
|
||||
challenge: request.publicKey.challenge,
|
||||
pubKeyCredParams: request.publicKey.pubKeyCredParams,
|
||||
attestation: request.publicKey.attestation,
|
||||
authenticatorSelection: request.publicKey.authenticatorSelection
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a new GUID for the passkey which will be embedded in the passkey
|
||||
* metadata and send back to the RP as the credential.id and credential.rawId.
|
||||
*/
|
||||
const newPasskeyGuid = crypto.randomUUID().toUpperCase();
|
||||
const newPasskeyGuidBytes = PasskeyHelper.guidToBytes(newPasskeyGuid);
|
||||
const newPasskeyGuidBase64url = PasskeyHelper.guidToBase64url(newPasskeyGuid);
|
||||
|
||||
// Check if PRF evaluation is requested during registration
|
||||
const prfExtension = request.publicKey?.extensions?.prf;
|
||||
const enablePrf = !!prfExtension;
|
||||
const prfEvalInputs = prfExtension?.eval;
|
||||
|
||||
// Create passkey using static method (generates keys and credential ID)
|
||||
const result = await PasskeyAuthenticator.createPasskey(newPasskeyGuidBytes, createRequest, {
|
||||
uvPerformed: true,
|
||||
credentialIdBytes: 16,
|
||||
enablePrf,
|
||||
prfInputs: prfEvalInputs // Pass PRF evaluation salts if provided
|
||||
});
|
||||
|
||||
const { credential, stored, prfEnabled, prfResults } = result;
|
||||
|
||||
// Use vault mutation to store both credential and passkey
|
||||
await executeVaultMutation(
|
||||
async () => {
|
||||
if (selectedPasskeyToReplace) {
|
||||
// Replace existing passkey: update the credential and passkey
|
||||
const existingPasskey = dbContext.sqliteClient!.getPasskeyById(selectedPasskeyToReplace);
|
||||
if (existingPasskey) {
|
||||
// Update the parent credential with new favicon and user-provided display name
|
||||
await dbContext.sqliteClient!.updateCredentialById(
|
||||
{
|
||||
Id: existingPasskey.CredentialId,
|
||||
ServiceName: displayName,
|
||||
ServiceUrl: request.origin,
|
||||
Username: request.publicKey.user.name,
|
||||
Password: '',
|
||||
Notes: '',
|
||||
Logo: faviconLogo ?? undefined,
|
||||
Alias: {
|
||||
FirstName: '',
|
||||
LastName: '',
|
||||
NickName: '',
|
||||
BirthDate: '0001-01-01 00:00:00',
|
||||
Gender: '',
|
||||
Email: ''
|
||||
},
|
||||
},
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
// Delete the old passkey
|
||||
await dbContext.sqliteClient!.deletePasskeyById(selectedPasskeyToReplace);
|
||||
|
||||
/**
|
||||
* Create new passkey with same credential
|
||||
* Convert userId from base64 string to byte array for database storage
|
||||
*/
|
||||
let userHandleBytes: Uint8Array | null = null;
|
||||
if (stored.userId) {
|
||||
try {
|
||||
userHandleBytes = PasskeyHelper.base64urlToBytes(stored.userId);
|
||||
} catch {
|
||||
// If conversion fails, store as null
|
||||
userHandleBytes = null;
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.sqliteClient!.createPasskey({
|
||||
Id: newPasskeyGuid,
|
||||
CredentialId: existingPasskey.CredentialId,
|
||||
RpId: stored.rpId,
|
||||
UserHandle: userHandleBytes,
|
||||
PublicKey: JSON.stringify(stored.publicKey),
|
||||
PrivateKey: JSON.stringify(stored.privateKey),
|
||||
DisplayName: request.publicKey.user.displayName || request.publicKey.user.name || '',
|
||||
PrfKey: stored.prfSecret ? PasskeyHelper.base64urlToBytes(stored.prfSecret) : undefined,
|
||||
AdditionalData: null
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Create new credential and passkey
|
||||
const credentialId = await dbContext.sqliteClient!.createCredential(
|
||||
{
|
||||
Id: '',
|
||||
ServiceName: displayName,
|
||||
ServiceUrl: request.origin,
|
||||
Username: request.publicKey.user.name,
|
||||
Password: '',
|
||||
Notes: '',
|
||||
Logo: faviconLogo ?? undefined,
|
||||
Alias: {
|
||||
FirstName: '',
|
||||
LastName: '',
|
||||
NickName: '',
|
||||
BirthDate: '0001-01-01 00:00:00', // TODO: once birthdate is made nullable in datamodel refactor, remove this.
|
||||
Gender: '',
|
||||
Email: ''
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Create the Passkey linked to the credential
|
||||
* Note: We let the database generate a GUID for Id, which we'll convert to base64url for the RP
|
||||
* Convert userId from base64 string to byte array for database storage
|
||||
*/
|
||||
let userHandleBytes: Uint8Array | null = null;
|
||||
if (stored.userId) {
|
||||
try {
|
||||
userHandleBytes = PasskeyHelper.base64urlToBytes(stored.userId);
|
||||
} catch {
|
||||
// If conversion fails, store as null
|
||||
userHandleBytes = null;
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.sqliteClient!.createPasskey({
|
||||
Id: newPasskeyGuid,
|
||||
CredentialId: credentialId,
|
||||
RpId: stored.rpId,
|
||||
UserHandle: userHandleBytes,
|
||||
PublicKey: JSON.stringify(stored.publicKey),
|
||||
PrivateKey: JSON.stringify(stored.privateKey),
|
||||
DisplayName: request.publicKey.user.displayName || request.publicKey.user.name || '',
|
||||
PrfKey: stored.prfSecret ? PasskeyHelper.base64urlToBytes(stored.prfSecret) : undefined,
|
||||
AdditionalData: null
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Wait for vault mutation to have synced with server, then send passkey create success response
|
||||
* with the GUID-based credential ID.
|
||||
*/
|
||||
onSuccess: async () => {
|
||||
// Prepare PRF extension response if PRF was enabled
|
||||
let prfExtensionResponse;
|
||||
if (prfEnabled) {
|
||||
prfExtensionResponse = {
|
||||
prf: {
|
||||
enabled: true,
|
||||
results: prfResults ? {
|
||||
first: PasskeyHelper.bytesToBase64url(new Uint8Array(prfResults.first)),
|
||||
second: prfResults.second ? PasskeyHelper.bytesToBase64url(new Uint8Array(prfResults.second)) : undefined
|
||||
} : undefined
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Use the GUID-based credential ID instead of the random one from the provider
|
||||
const flattenedCredential: PasskeyCreateCredentialResponse = {
|
||||
id: newPasskeyGuidBase64url,
|
||||
rawId: newPasskeyGuidBase64url,
|
||||
clientDataJSON: credential.response.clientDataJSON,
|
||||
attestationObject: credential.response.attestationObject,
|
||||
extensions: prfExtensionResponse
|
||||
};
|
||||
|
||||
/*
|
||||
* Send response back to background
|
||||
* The background script will close the window (Safari-compatible)
|
||||
*/
|
||||
await sendMessage('PASSKEY_POPUP_RESPONSE', {
|
||||
requestId: request.requestId,
|
||||
credential: flattenedCredential
|
||||
}, 'background');
|
||||
},
|
||||
/**
|
||||
* onError
|
||||
*/
|
||||
onError: (err) => {
|
||||
console.error('PasskeyCreate: Error storing passkey', err);
|
||||
setError(t('common.errors.unknownError'));
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('PasskeyCreate: Error creating passkey', error);
|
||||
setError(t('common.errors.unknownError'));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle fallback - show bypass dialog first
|
||||
*/
|
||||
const handleFallback = async () : Promise<void> => {
|
||||
setShowBypassDialog(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle bypass choice
|
||||
*/
|
||||
const handleBypassChoice = async (choice: 'once' | 'always') : Promise<void> => {
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (choice === 'always') {
|
||||
// Add to permanent disabled list
|
||||
const hostname = new URL(request.origin).hostname;
|
||||
const baseDomain = extractRootDomain(extractDomain(hostname));
|
||||
|
||||
const disabledSites = await storage.getItem(PASSKEY_DISABLED_SITES_KEY) as string[] ?? [];
|
||||
if (!disabledSites.includes(baseDomain)) {
|
||||
disabledSites.push(baseDomain);
|
||||
await storage.setItem(PASSKEY_DISABLED_SITES_KEY, disabledSites);
|
||||
}
|
||||
}
|
||||
// For 'once', we don't store anything - just bypass this one time
|
||||
|
||||
/*
|
||||
* Tell background to use native implementation
|
||||
* The background script will close the window (Safari-compatible)
|
||||
*/
|
||||
await sendMessage('PASSKEY_POPUP_RESPONSE', {
|
||||
requestId: request.requestId,
|
||||
fallback: true
|
||||
}, 'background');
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle cancel
|
||||
*/
|
||||
const handleCancel = async () : Promise<void> => {
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Tell background user cancelled
|
||||
* The background script will close the window (Safari-compatible)
|
||||
*/
|
||||
await sendMessage('PASSKEY_POPUP_RESPONSE', {
|
||||
requestId: request.requestId,
|
||||
cancelled: true
|
||||
}, 'background');
|
||||
};
|
||||
|
||||
if (!request) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showBypassDialog && request && (
|
||||
<PasskeyBypassDialog
|
||||
origin={new URL(request.origin).hostname}
|
||||
onChoice={handleBypassChoice}
|
||||
onCancel={() => setShowBypassDialog(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(localLoading || isMutating) && (
|
||||
<div className="fixed inset-0 flex flex-col justify-center items-center bg-white dark:bg-gray-900 bg-opacity-90 dark:bg-opacity-90 z-50">
|
||||
<LoadingSpinner />
|
||||
<div className="text-sm text-gray-500 mt-2">
|
||||
{syncStatus}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('passkeys.create.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('passkeys.create.createFor')} <strong>{request.origin}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="error">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Step 1: Show existing passkeys selection or create new option */}
|
||||
{!showCreateForm && existingPasskeys.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreateNew}
|
||||
ref={createNewButtonRef}
|
||||
>
|
||||
{t('passkeys.create.createNewPasskey')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleFallback}
|
||||
>
|
||||
{t('passkeys.create.useBrowserPasskey')}
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300 dark:border-gray-600" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">
|
||||
{t('common.or')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('passkeys.create.selectPasskeyToReplace')}
|
||||
</label>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto border rounded-lg p-2 bg-gray-50 dark:bg-gray-800">
|
||||
{existingPasskeys.map((passkey) => (
|
||||
<button
|
||||
key={passkey.Id}
|
||||
onClick={() => handleSelectReplace(passkey.Id)}
|
||||
className="w-full p-3 text-left rounded-lg border cursor-pointer transition-colors bg-white border-gray-200 hover:bg-gray-100 hover:border-gray-300 dark:bg-gray-700 dark:border-gray-600 dark:hover:bg-gray-600 dark:hover:border-gray-500 focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900 dark:text-white text-sm truncate">
|
||||
{passkey.ServiceName}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="truncate">{passkey.DisplayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Show create form with display name */}
|
||||
{showCreateForm && (
|
||||
<div className="space-y-4">
|
||||
{selectedPasskeyToReplace && (
|
||||
<Alert variant="warning">
|
||||
{t('passkeys.create.replacingPasskey', {
|
||||
displayName: existingPasskeys.find(p => p.Id === selectedPasskeyToReplace)?.DisplayName || ''
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormInput
|
||||
id="displayName"
|
||||
label={t('passkeys.create.titleLabel')}
|
||||
value={displayName}
|
||||
onChange={setDisplayName}
|
||||
placeholder={t('passkeys.create.titlePlaceholder')}
|
||||
ref={displayNameInputRef}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{selectedPasskeyToReplace ? t('passkeys.create.confirmReplace') : t('passkeys.create.createButton')}
|
||||
</Button>
|
||||
|
||||
{existingPasskeys.length > 0 ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setSelectedPasskeyToReplace(null);
|
||||
}}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleFallback}
|
||||
>
|
||||
{t('passkeys.create.useBrowserPasskey')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasskeyCreate;
|
||||
@@ -2,7 +2,7 @@ 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 HelpModal from '@/entrypoints/popup/components/Dialogs/HelpModal';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
|
||||
import { AUTO_LOCK_TIMEOUT_KEY } from '@/utils/Constants';
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { extractDomain, extractRootDomain } from '@/entrypoints/contentScript/Filter';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
|
||||
import {
|
||||
PASSKEY_PROVIDER_ENABLED_KEY,
|
||||
PASSKEY_DISABLED_SITES_KEY
|
||||
} from '@/utils/Constants';
|
||||
|
||||
import { storage, browser } from "#imports";
|
||||
|
||||
/**
|
||||
* Passkey settings type.
|
||||
*/
|
||||
type PasskeySettingsType = {
|
||||
disabledUrls: string[];
|
||||
currentUrl: string;
|
||||
isEnabled: boolean;
|
||||
isGloballyEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passkey settings page component.
|
||||
*/
|
||||
const PasskeySettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const [settings, setSettings] = useState<PasskeySettingsType>({
|
||||
disabledUrls: [],
|
||||
currentUrl: '',
|
||||
isEnabled: true,
|
||||
isGloballyEnabled: true
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 hostname = new URL(tab.url ?? '').hostname;
|
||||
const baseDomain = extractRootDomain(extractDomain(hostname));
|
||||
|
||||
// Load settings from local storage
|
||||
const disabledUrls = await storage.getItem(PASSKEY_DISABLED_SITES_KEY) as string[] ?? [];
|
||||
const isGloballyEnabled = await storage.getItem(PASSKEY_PROVIDER_ENABLED_KEY) !== false; // Default to true if not set
|
||||
|
||||
// Check if current base domain is disabled
|
||||
const isEnabled = !disabledUrls.includes(baseDomain);
|
||||
|
||||
setSettings({
|
||||
disabledUrls,
|
||||
currentUrl: baseDomain,
|
||||
isEnabled,
|
||||
isGloballyEnabled
|
||||
});
|
||||
setIsInitialLoading(false);
|
||||
}, [setIsInitialLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
/**
|
||||
* Toggle current site.
|
||||
*/
|
||||
const toggleCurrentSite = async () : Promise<void> => {
|
||||
const { currentUrl, disabledUrls, isEnabled } = settings;
|
||||
|
||||
let newDisabledUrls = [...disabledUrls];
|
||||
|
||||
if (isEnabled) {
|
||||
// When disabling, add to permanent disabled list
|
||||
if (!newDisabledUrls.includes(currentUrl)) {
|
||||
newDisabledUrls.push(currentUrl);
|
||||
}
|
||||
} else {
|
||||
// When enabling, remove from disabled list
|
||||
newDisabledUrls = newDisabledUrls.filter(url => url !== currentUrl);
|
||||
}
|
||||
|
||||
await storage.setItem(PASSKEY_DISABLED_SITES_KEY, newDisabledUrls);
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
disabledUrls: newDisabledUrls,
|
||||
isEnabled: !isEnabled
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset settings.
|
||||
*/
|
||||
const resetSettings = async () : Promise<void> => {
|
||||
await storage.setItem(PASSKEY_DISABLED_SITES_KEY, []);
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
disabledUrls: [],
|
||||
isEnabled: true
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle global passkey provider.
|
||||
*/
|
||||
const toggleGlobalPasskeyProvider = async () : Promise<void> => {
|
||||
const newGloballyEnabled = !settings.isGloballyEnabled;
|
||||
|
||||
await storage.setItem(PASSKEY_PROVIDER_ENABLED_KEY, newGloballyEnabled);
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
isGloballyEnabled: newGloballyEnabled
|
||||
}));
|
||||
};
|
||||
|
||||
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="font-medium text-gray-900 dark:text-white">{t('passkeys.settings.passkeyProvider')}</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 ? t('settings.activeOnAllSites') : t('settings.disabledOnAllSites')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleGlobalPasskeyProvider}
|
||||
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="font-medium text-gray-900 dark:text-white">{t('passkeys.settings.passkeyProviderOn')}{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 ? t('settings.enabledForThisSite') : t('settings.disabledForThisSite')}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasskeySettings;
|
||||
@@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { useTheme } from '@/entrypoints/popup/context/ThemeContext';
|
||||
@@ -21,7 +21,7 @@ import { browser } from "#imports";
|
||||
const Settings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const authContext = useAuth();
|
||||
const app = useApp();
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const { loadApiUrl, getDisplayUrl } = useApiUrl();
|
||||
@@ -109,7 +109,7 @@ const Settings: React.FC = () => {
|
||||
* Handle logout.
|
||||
*/
|
||||
const handleLogout = async () : Promise<void> => {
|
||||
navigate('/logout', { replace: true });
|
||||
app.logout();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -147,6 +147,13 @@ const Settings: React.FC = () => {
|
||||
navigate('/settings/context-menu');
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to passkey settings.
|
||||
*/
|
||||
const navigateToPasskeySettings = () : void => {
|
||||
navigate('/settings/passkeys');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
@@ -162,13 +169,13 @@ const Settings: React.FC = () => {
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
|
||||
<span className="text-primary-600 dark:text-primary-400 text-lg font-medium">
|
||||
{authContext.username?.[0]?.toUpperCase() || '?'}
|
||||
{app.username?.[0]?.toUpperCase() || '?'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text font-medium text-gray-900 dark:text-white">
|
||||
{authContext.username}
|
||||
{app.username}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('settings.loggedIn')}
|
||||
@@ -237,6 +244,38 @@ const Settings: React.FC = () => {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Passkey Settings */}
|
||||
<button
|
||||
onClick={navigateToPasskeySettings}
|
||||
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="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-gray-900 dark:text-white">{t('settings.passkeySettings')}</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}
|
||||
|
||||
599
apps/browser-extension/src/entrypoints/webauthn.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* WebAuthn override injection script - included in web_accessible_resources as "webauthn.js"
|
||||
* and runs in page context to override the browser's built-in credentials API.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import type {
|
||||
WebAuthnCreateEventDetail,
|
||||
WebAuthnGetEventDetail,
|
||||
WebAuthnCreateResponseDetail,
|
||||
WebAuthnGetResponseDetail,
|
||||
ProviderCreateCredential,
|
||||
ProviderGetCredential
|
||||
} from '@/utils/passkey/webauthn.types';
|
||||
|
||||
import { defineUnlistedScript } from '#imports';
|
||||
|
||||
export default defineUnlistedScript(() => {
|
||||
// Only run once
|
||||
if ((window as any).__aliasVaultWebAuthnIntercepted) {
|
||||
return;
|
||||
}
|
||||
(window as any).__aliasVaultWebAuthnIntercepted = true;
|
||||
|
||||
// Get the original implementations from the reservation script or bind directly
|
||||
const queue = (window as any).__aliasVaultWebAuthnQueue;
|
||||
const originalCreate = queue?.originalCreate || navigator.credentials.create.bind(navigator.credentials);
|
||||
const originalGet = queue?.originalGet || navigator.credentials.get.bind(navigator.credentials);
|
||||
const pendingQueue = queue?.pendingQueue || [];
|
||||
|
||||
/**
|
||||
* Helper to convert ArrayBuffer to base64
|
||||
*/
|
||||
function bufferToBase64(buffer: ArrayBuffer | ArrayBufferView): string {
|
||||
const bytes = buffer instanceof ArrayBuffer
|
||||
? new Uint8Array(buffer)
|
||||
: new Uint8Array(buffer.buffer, (buffer as any).byteOffset, (buffer as any).byteLength);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to convert ArrayBuffer to base64
|
||||
*/
|
||||
function base64ToBuffer(base64: string): ArrayBuffer {
|
||||
// Handle both base64 and base64url formats
|
||||
const base64Standard = base64.replace(/-/g, '+').replace(/_/g, '/');
|
||||
// Add padding if needed
|
||||
const padded = base64Standard + '==='.slice((base64Standard.length + 3) % 4);
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override credentials.create (monkey patch)
|
||||
*/
|
||||
navigator.credentials.create = async function(options?: CredentialCreationOptions) : Promise<Credential | null> {
|
||||
if (!options?.publicKey) {
|
||||
return originalCreate(options);
|
||||
}
|
||||
|
||||
// Send event to content script
|
||||
const requestId = Math.random().toString(36).substr(2, 9);
|
||||
|
||||
// Serialize PRF extensions if present (convert ArrayBuffers to base64)
|
||||
let serializedExtensions: any = undefined;
|
||||
if (options.publicKey.extensions) {
|
||||
serializedExtensions = { ...options.publicKey.extensions };
|
||||
if (serializedExtensions.prf?.eval) {
|
||||
const prfEval: any = { first: bufferToBase64(serializedExtensions.prf.eval.first) };
|
||||
if (serializedExtensions.prf.eval.second) {
|
||||
prfEval.second = bufferToBase64(serializedExtensions.prf.eval.second);
|
||||
}
|
||||
serializedExtensions.prf = { eval: prfEval };
|
||||
}
|
||||
}
|
||||
|
||||
const eventDetail: WebAuthnCreateEventDetail = {
|
||||
requestId,
|
||||
publicKey: {
|
||||
...options.publicKey,
|
||||
challenge: bufferToBase64(options.publicKey.challenge),
|
||||
user: {
|
||||
...options.publicKey.user,
|
||||
id: bufferToBase64(options.publicKey.user.id)
|
||||
},
|
||||
excludeCredentials: options.publicKey.excludeCredentials?.map(cred => ({
|
||||
...cred,
|
||||
id: bufferToBase64(cred.id)
|
||||
})),
|
||||
extensions: serializedExtensions
|
||||
},
|
||||
origin: window.location.origin
|
||||
};
|
||||
const event = new CustomEvent<WebAuthnCreateEventDetail>('aliasvault:webauthn:create', {
|
||||
detail: eventDetail
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
// Wait for response
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
// Timeout - fall back to native
|
||||
originalCreate(options).then(resolve).catch(reject);
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
/**
|
||||
* cleanup
|
||||
*/
|
||||
function cleanup() : void {
|
||||
clearTimeout(timeout);
|
||||
window.removeEventListener('aliasvault:webauthn:create:response', handler as EventListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* handler
|
||||
*/
|
||||
function handler(e: CustomEvent<WebAuthnCreateResponseDetail>) : void {
|
||||
if (e.detail.requestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
||||
if (e.detail.fallback) {
|
||||
// User chose to use native implementation
|
||||
originalCreate(options).then(resolve).catch(reject);
|
||||
} else if (e.detail.error) {
|
||||
reject(new Error(e.detail.error));
|
||||
} else if (e.detail.credential) {
|
||||
// Create a proper credential object with required methods
|
||||
const cred: ProviderCreateCredential = e.detail.credential;
|
||||
try {
|
||||
// Decode the attestation object to extract authenticator data
|
||||
const attestationObjectBuffer = base64ToBuffer(cred.attestationObject);
|
||||
const attObjBytes = new Uint8Array(attestationObjectBuffer);
|
||||
|
||||
/*
|
||||
* Simple CBOR parser to extract authData
|
||||
* CBOR map starts with 0xA3 (map with 3 items)
|
||||
* Keys are: "fmt" (0x63), "attStmt" (0x67), "authData" (0x68)
|
||||
*/
|
||||
let authDataBuffer = new ArrayBuffer(0);
|
||||
try {
|
||||
// Find "authData" key (0x68 0x61 0x75 0x74 0x68 0x44 0x61 0x74 0x61)
|
||||
const authDataKeyBytes = [0x68, 0x61, 0x75, 0x74, 0x68, 0x44, 0x61, 0x74, 0x61];
|
||||
for (let i = 0; i < attObjBytes.length - authDataKeyBytes.length; i++) {
|
||||
let match = true;
|
||||
for (let j = 0; j < authDataKeyBytes.length; j++) {
|
||||
if (attObjBytes[i + j] !== authDataKeyBytes[j]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
// Found "authData" key, next byte is the type (0x58 = byte string)
|
||||
const typeIdx = i + authDataKeyBytes.length;
|
||||
if (attObjBytes[typeIdx] === 0x58) {
|
||||
// Next byte is the length
|
||||
const length = attObjBytes[typeIdx + 1];
|
||||
authDataBuffer = attObjBytes.slice(typeIdx + 2, typeIdx + 2 + length).buffer;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Create response object with proper prototype
|
||||
const response = Object.create(AuthenticatorAttestationResponse.prototype);
|
||||
const clientDataJSONBuffer = base64ToBuffer(cred.clientDataJSON);
|
||||
Object.defineProperties(response, {
|
||||
clientDataJSON: {
|
||||
value: clientDataJSONBuffer,
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
attestationObject: {
|
||||
value: attestationObjectBuffer,
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
getTransports: {
|
||||
/**
|
||||
* getTransports
|
||||
*/
|
||||
value: function() : string[] {
|
||||
return ['internal'];
|
||||
},
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
getAuthenticatorData: {
|
||||
/**
|
||||
* getAuthenticatorData
|
||||
*/
|
||||
value: function() : ArrayBuffer {
|
||||
return authDataBuffer;
|
||||
},
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
getPublicKey: {
|
||||
/**
|
||||
* getPublicKey
|
||||
*/
|
||||
value: function() : JsonWebKey | null {
|
||||
return null;
|
||||
},
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
getPublicKeyAlgorithm: {
|
||||
/**
|
||||
* getPublicKeyAlgorithm
|
||||
*/
|
||||
value: function() : number {
|
||||
return -7; // ES256
|
||||
},
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
}
|
||||
});
|
||||
|
||||
// Create credential object with proper prototype chain
|
||||
const credential = Object.create(PublicKeyCredential.prototype);
|
||||
Object.defineProperties(credential, {
|
||||
id: {
|
||||
value: cred.id,
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
type: {
|
||||
value: 'public-key',
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
rawId: {
|
||||
value: base64ToBuffer(cred.rawId),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
authenticatorAttachment: {
|
||||
value: 'cross-platform',
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
response: {
|
||||
value: response,
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
getClientExtensionResults: {
|
||||
/**
|
||||
* getClientExtensionResults
|
||||
*/
|
||||
value: function() : any {
|
||||
const extensions: any = {};
|
||||
if (cred.extensions?.prf) {
|
||||
extensions.prf = { ...cred.extensions.prf };
|
||||
// Convert PRF results from base64url to ArrayBuffer if present
|
||||
if ((cred.extensions.prf as any).results) {
|
||||
extensions.prf.results = {
|
||||
first: base64ToBuffer((cred.extensions.prf as any).results.first)
|
||||
};
|
||||
if ((cred.extensions.prf as any).results.second) {
|
||||
extensions.prf.results.second = base64ToBuffer((cred.extensions.prf as any).results.second);
|
||||
}
|
||||
}
|
||||
}
|
||||
return extensions;
|
||||
},
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure the credential is recognized as a PublicKeyCredential instance
|
||||
Object.defineProperty(credential, Symbol.toStringTag, {
|
||||
value: 'PublicKeyCredential',
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
resolve(credential);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
} else {
|
||||
// Cancelled
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('aliasvault:webauthn:create:response', handler as EventListener);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Override credentials.get (monkey patch)
|
||||
*/
|
||||
navigator.credentials.get = async function(options?: CredentialRequestOptions) : Promise<Credential | null> {
|
||||
if (!options?.publicKey) {
|
||||
return originalGet(options);
|
||||
}
|
||||
|
||||
// Send event to content script
|
||||
const requestId = Math.random().toString(36).substr(2, 9);
|
||||
|
||||
// Serialize PRF extensions if present (convert ArrayBuffers to base64)
|
||||
let serializedExtensions: any = undefined;
|
||||
if (options.publicKey.extensions) {
|
||||
serializedExtensions = { ...options.publicKey.extensions };
|
||||
if (serializedExtensions.prf?.eval) {
|
||||
const prfEval: any = { first: bufferToBase64(serializedExtensions.prf.eval.first) };
|
||||
if (serializedExtensions.prf.eval.second) {
|
||||
prfEval.second = bufferToBase64(serializedExtensions.prf.eval.second);
|
||||
}
|
||||
serializedExtensions.prf = { eval: prfEval };
|
||||
}
|
||||
}
|
||||
|
||||
const eventDetail: WebAuthnGetEventDetail = {
|
||||
requestId,
|
||||
publicKey: {
|
||||
...options.publicKey,
|
||||
challenge: bufferToBase64(options.publicKey.challenge),
|
||||
allowCredentials: options.publicKey.allowCredentials?.map(cred => ({
|
||||
...cred,
|
||||
id: bufferToBase64(cred.id)
|
||||
})),
|
||||
extensions: serializedExtensions
|
||||
},
|
||||
origin: window.location.origin
|
||||
};
|
||||
const event = new CustomEvent<WebAuthnGetEventDetail>('aliasvault:webauthn:get', {
|
||||
detail: eventDetail
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
// Wait for response
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
// Timeout - fall back to native
|
||||
originalGet(options).then(resolve).catch(reject);
|
||||
}, 30000);
|
||||
|
||||
/**
|
||||
* cleanup
|
||||
*/
|
||||
function cleanup() : void {
|
||||
clearTimeout(timeout);
|
||||
window.removeEventListener('aliasvault:webauthn:get:response', handler as EventListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* handler
|
||||
*/
|
||||
function handler(e: CustomEvent<WebAuthnGetResponseDetail>) : void {
|
||||
if (e.detail.requestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
||||
if (e.detail.fallback) {
|
||||
// User chose to use native implementation
|
||||
originalGet(options).then(resolve).catch(reject);
|
||||
} else if (e.detail.error) {
|
||||
reject(new Error(e.detail.error));
|
||||
} else if (e.detail.credential) {
|
||||
// Create a proper credential object with required methods
|
||||
const cred: ProviderGetCredential = e.detail.credential;
|
||||
|
||||
// Create response object with proper prototype
|
||||
const response = Object.create(AuthenticatorAssertionResponse.prototype);
|
||||
Object.defineProperties(response, {
|
||||
clientDataJSON: {
|
||||
value: base64ToBuffer(cred.clientDataJSON),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
authenticatorData: {
|
||||
value: base64ToBuffer(cred.authenticatorData),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
signature: {
|
||||
value: base64ToBuffer(cred.signature),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
userHandle: {
|
||||
value: cred.userHandle ? base64ToBuffer(cred.userHandle) : null,
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
}
|
||||
});
|
||||
|
||||
// Create credential object with proper prototype chain
|
||||
const credential = Object.create(PublicKeyCredential.prototype);
|
||||
Object.defineProperties(credential, {
|
||||
id: {
|
||||
value: cred.id,
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
type: {
|
||||
value: 'public-key',
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
rawId: {
|
||||
value: base64ToBuffer(cred.rawId),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
authenticatorAttachment: {
|
||||
value: 'cross-platform',
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
response: {
|
||||
value: response,
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
getClientExtensionResults: {
|
||||
/**
|
||||
* getClientExtensionResults
|
||||
*/
|
||||
value: function() : any {
|
||||
const extensions: any = {};
|
||||
if (cred.prfResults) {
|
||||
extensions.prf = {
|
||||
results: {
|
||||
first: base64ToBuffer(cred.prfResults.first)
|
||||
}
|
||||
};
|
||||
if (cred.prfResults.second) {
|
||||
extensions.prf.results.second = base64ToBuffer(cred.prfResults.second);
|
||||
}
|
||||
}
|
||||
return extensions;
|
||||
},
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure the credential is recognized as a PublicKeyCredential instance
|
||||
Object.defineProperty(credential, Symbol.toStringTag, {
|
||||
value: 'PublicKeyCredential',
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
resolve(credential);
|
||||
} else {
|
||||
// Cancelled
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('aliasvault:webauthn:get:response', handler as EventListener);
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
* Store references to our override functions so we can re-apply them if needed.
|
||||
* We need to capture these before any potential overwrites.
|
||||
*/
|
||||
const getOverrideRef = navigator.credentials.get;
|
||||
const createOverrideRef = navigator.credentials.create;
|
||||
|
||||
// Add markers to our functions for easier verification
|
||||
(getOverrideRef as any).__aliasVaultPatched = true;
|
||||
(createOverrideRef as any).__aliasVaultPatched = true;
|
||||
|
||||
/**
|
||||
* Apply or re-apply the monkey patches
|
||||
*/
|
||||
const applyPatches = (): void => {
|
||||
const currentGet = navigator.credentials.get;
|
||||
const currentCreate = navigator.credentials.create;
|
||||
|
||||
// Re-apply get if it's missing our marker
|
||||
if (!(currentGet as any).__aliasVaultPatched) {
|
||||
console.warn('[AliasVault] Re-applying credentials.get patch');
|
||||
navigator.credentials.get = getOverrideRef;
|
||||
}
|
||||
|
||||
// Re-apply create if it's missing our marker
|
||||
if (!(currentCreate as any).__aliasVaultPatched) {
|
||||
console.warn('[AliasVault] Re-applying credentials.create patch');
|
||||
navigator.credentials.create = createOverrideRef;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Verification function to check if monkey patches are still in place
|
||||
* @returns True if patches are verified, false otherwise
|
||||
*/
|
||||
const verifyPatches = (): boolean => {
|
||||
const get = navigator.credentials.get;
|
||||
const create = navigator.credentials.create;
|
||||
|
||||
// Check for our marker
|
||||
if (!(get as any).__aliasVaultPatched || !(create as any).__aliasVaultPatched) {
|
||||
console.error('[AliasVault] CRITICAL: Monkey patch markers missing!', {
|
||||
hasGetMarker: !!(get as any).__aliasVaultPatched,
|
||||
hasCreateMarker: !!(create as any).__aliasVaultPatched
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Verify immediately
|
||||
if (!verifyPatches()) {
|
||||
console.error('[AliasVault] Initial verification failed - re-applying patches');
|
||||
applyPatches();
|
||||
}
|
||||
|
||||
// Periodic verification for first 5 seconds (catches if something overwrites us)
|
||||
let checkCount = 0;
|
||||
const verifyInterval = setInterval(() => {
|
||||
checkCount++;
|
||||
if (!verifyPatches()) {
|
||||
console.error('[AliasVault] Periodic verification failed - re-applying patches!');
|
||||
applyPatches();
|
||||
}
|
||||
|
||||
if (checkCount >= 10) {
|
||||
clearInterval(verifyInterval);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
/*
|
||||
* Process any queued requests from the reservation script.
|
||||
* This handles the case where the page called navigator.credentials
|
||||
* before our full implementation finished loading.
|
||||
*/
|
||||
if (pendingQueue.length > 0) {
|
||||
pendingQueue.forEach((request: any) => {
|
||||
if (request.type === 'create') {
|
||||
navigator.credentials.create(request.options)
|
||||
.then(request.resolve)
|
||||
.catch(request.reject);
|
||||
} else if (request.type === 'get') {
|
||||
navigator.credentials.get(request.options)
|
||||
.then(request.resolve)
|
||||
.catch(request.reject);
|
||||
}
|
||||
});
|
||||
// Clear the queue
|
||||
pendingQueue.length = 0;
|
||||
}
|
||||
|
||||
// Clean up the reservation script globals
|
||||
delete (window as any).__aliasVaultWebAuthnQueue;
|
||||
delete (window as any).__aliasVaultWebAuthnReserved;
|
||||
});
|
||||
38
apps/browser-extension/src/events/LogoutEventEmitter.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
type LogoutListener = (errorMessage: string) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Simple event emitter for logout events to avoid circular dependencies
|
||||
* between WebApiService and Auth contexts.
|
||||
*/
|
||||
class LogoutEventEmitter {
|
||||
private listeners: Set<LogoutListener> = new Set();
|
||||
|
||||
/**
|
||||
* Subscribe to logout events.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
public subscribe(listener: LogoutListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a logout event to all listeners.
|
||||
*
|
||||
* @param errorKey - The translation key of the error message to emit.
|
||||
*/
|
||||
public emit(errorTranslationKey: string): void {
|
||||
this.listeners.forEach(listener => {
|
||||
try {
|
||||
listener(errorTranslationKey);
|
||||
} catch (error) {
|
||||
console.error('Error in logout listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const logoutEventEmitter = new LogoutEventEmitter();
|
||||
@@ -9,6 +9,9 @@ import fiTranslations from './locales/fi.json';
|
||||
import heTranslations from './locales/he.json';
|
||||
import itTranslations from './locales/it.json';
|
||||
import nlTranslations from './locales/nl.json';
|
||||
import plTranslations from './locales/pl.json';
|
||||
import ptTranslations from './locales/pt.json';
|
||||
import ruTranslations from './locales/ru.json';
|
||||
import ukTranslations from './locales/uk.json';
|
||||
import zhTranslations from './locales/zh.json';
|
||||
|
||||
@@ -35,6 +38,15 @@ export const LANGUAGE_RESOURCES = {
|
||||
nl: {
|
||||
translation: nlTranslations
|
||||
},
|
||||
pl: {
|
||||
translation: plTranslations
|
||||
},
|
||||
pt: {
|
||||
translation: ptTranslations
|
||||
},
|
||||
ru: {
|
||||
translation: ruTranslations
|
||||
},
|
||||
uk: {
|
||||
translation: ukTranslations
|
||||
},
|
||||
@@ -84,6 +96,24 @@ export const AVAILABLE_LANGUAGES: ILanguageConfig[] = [
|
||||
nativeName: 'Nederlands',
|
||||
flag: '🇳🇱'
|
||||
},
|
||||
{
|
||||
code: 'pl',
|
||||
name: 'Polish',
|
||||
nativeName: 'Polski',
|
||||
flag: '🇵🇱'
|
||||
},
|
||||
{
|
||||
code: 'pt',
|
||||
name: 'Portuguese Brazilian',
|
||||
nativeName: 'Português Brasileiro',
|
||||
flag: '🇧🇷'
|
||||
},
|
||||
{
|
||||
code: 'ru',
|
||||
name: 'Russian',
|
||||
nativeName: 'Русский',
|
||||
flag: '🇷🇺'
|
||||
},
|
||||
{
|
||||
code: 'uk',
|
||||
name: 'Ukrainian',
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"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."
|
||||
"sessionExpired": "Your session has expired. Please log in again."
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
@@ -52,8 +52,10 @@
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"cancel": "Cancel",
|
||||
"back": "Back",
|
||||
"use": "Utilitza",
|
||||
"delete": "Suprimeix",
|
||||
"or": "Or",
|
||||
"close": "Tanca",
|
||||
"copied": "Copied!",
|
||||
"openInNewWindow": "Open in new window",
|
||||
@@ -89,12 +91,11 @@
|
||||
"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.",
|
||||
"browserExtensionOutdated": "This browser extension is outdated and cannot be used to access this vault. Please update this browser extension to continue.",
|
||||
"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",
|
||||
@@ -198,6 +199,9 @@
|
||||
"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.",
|
||||
"noPasskeysFound": "No passkeys have been created yet. Passkeys are created by visiting a website that offers passkeys as an authentication method.",
|
||||
"noAttachmentsFound": "No credentials with attachments found",
|
||||
"noMatchingCredentials": "No matching credentials found",
|
||||
"createdAt": "Created",
|
||||
"updatedAt": "Last updated",
|
||||
"autofill": "Autofill",
|
||||
@@ -213,6 +217,13 @@
|
||||
"saveCredential": "Save credential",
|
||||
"deleteCredentialTitle": "Delete Credential",
|
||||
"deleteCredentialConfirm": "Are you sure you want to delete this credential? This action cannot be undone.",
|
||||
"filters": {
|
||||
"all": "(All) Credentials",
|
||||
"passkeys": "Passkeys",
|
||||
"aliases": "Aliases",
|
||||
"userpass": "Passwords",
|
||||
"attachments": "Attachments"
|
||||
},
|
||||
"randomAlias": "Random Alias",
|
||||
"manual": "Manual",
|
||||
"service": "Service",
|
||||
@@ -345,11 +356,23 @@
|
||||
"autofillSettings": "Autofill Settings",
|
||||
"clipboardSettings": "Clipboard Settings",
|
||||
"contextMenuSettings": "Context Menu Settings",
|
||||
"passkeySettings": "Passkey 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",
|
||||
"serverConfiguration": "Server Configuration",
|
||||
"serverConfigurationDescription": "Configure the AliasVault server URL for self-hosted instances",
|
||||
"customApiUrl": "API URL",
|
||||
"customClientUrl": "Client URL",
|
||||
"apiUrlHint": "The API endpoint URL (usually client URL + /api)",
|
||||
"clientUrlHint": "The web interface URL of your self-hosted instance",
|
||||
"autofillSettingsDescription": "Enable or disable the autofill popup on web pages",
|
||||
"autofillEnabledDescription": "Autofill suggestions will appear on login forms",
|
||||
"autofillDisabledDescription": "Autofill suggestions are disabled globally",
|
||||
"languageSettings": "Language",
|
||||
"languageSettingsDescription": "Choose your preferred language",
|
||||
"validation": {
|
||||
"apiUrlRequired": "API URL is required",
|
||||
"apiUrlInvalid": "Please enter a valid API URL",
|
||||
@@ -357,12 +380,52 @@
|
||||
"clientUrlInvalid": "Please enter a valid client URL"
|
||||
}
|
||||
},
|
||||
"passkeys": {
|
||||
"passkey": "Passkey",
|
||||
"site": "Site",
|
||||
"displayName": "Name",
|
||||
"helpText": "Passkeys are created on the website when prompted. They cannot be manually edited. To remove this passkey, you can delete it from this credential. To replace this passkey or create a new one, visit the website and follow its prompts.",
|
||||
"passkeyMarkedForDeletion": "Passkey marked for deletion",
|
||||
"passkeyWillBeDeleted": "This passkey will be deleted when you save this credential.",
|
||||
"bypass": {
|
||||
"title": "Use Browser Passkey",
|
||||
"description": "How long would you like to use the browser's passkey provider for {{origin}}?",
|
||||
"thisTimeOnly": "This time only",
|
||||
"alwaysForSite": "Always for this site"
|
||||
},
|
||||
"authenticate": {
|
||||
"title": "Sign in with Passkey",
|
||||
"signInFor": "Sign in with passkey for",
|
||||
"selectPasskey": "Select a passkey to sign in:",
|
||||
"noPasskeysFound": "No passkeys found for this site",
|
||||
"useBrowserPasskey": "Use Browser Passkey"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Passkey",
|
||||
"createFor": "Create a new passkey for",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Enter a name for this passkey",
|
||||
"createButton": "Create Passkey",
|
||||
"creatingButton": "Creating...",
|
||||
"useBrowserPasskey": "Use Browser Passkey",
|
||||
"selectPasskeyToReplace": "Select a passkey to replace:",
|
||||
"createNewPasskey": "Create New Passkey",
|
||||
"replacingPasskey": "Replacing passkey: {{displayName}}",
|
||||
"confirmReplace": "Confirm Replace"
|
||||
},
|
||||
"settings": {
|
||||
"passkeyProvider": "Passkey Provider",
|
||||
"passkeyProviderOn": "Passkey Provider on ",
|
||||
"enable": "Enable AliasVault as passkey provider",
|
||||
"description": "When enabled, AliasVault will handle passkey requests from websites. When a website requests a passkey, the AliasVault popup will be shown instead of the native browser or OS passkey interface."
|
||||
}
|
||||
},
|
||||
"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:",
|
||||
"yourVault": "Your vault version:",
|
||||
"newVersion": "New available version:",
|
||||
"upgrade": "Upgrade Vault",
|
||||
"upgrading": "Upgrading...",
|
||||
"logout": "Logout",
|
||||
@@ -386,8 +449,7 @@
|
||||
"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."
|
||||
"failedToApplyMigration": "Failed to apply migration ({{current}} of {{total}})"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
"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."
|
||||
"sessionExpired": "Deine Sitzung ist abgelaufen. Bitte melde Dich erneut an."
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
@@ -52,8 +52,10 @@
|
||||
"error": "Fehler",
|
||||
"success": "Aktion erfolgreich",
|
||||
"cancel": "Abbrechen",
|
||||
"back": "Back",
|
||||
"use": "Benutzen",
|
||||
"delete": "Löschen",
|
||||
"or": "Or",
|
||||
"close": "Schließen",
|
||||
"copied": "Kopiert!",
|
||||
"openInNewWindow": "In neuem Fenster öffnen",
|
||||
@@ -89,12 +91,11 @@
|
||||
"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.",
|
||||
"browserExtensionOutdated": "Diese Browser-Erweiterung ist veraltet und kann nicht verwendet werden, um auf diesen Tresor zuzugreifen. Bitte aktualisiere die Browser-Erweiterung, um fortzufahren.",
|
||||
"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.",
|
||||
@@ -198,6 +199,9 @@
|
||||
"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.",
|
||||
"noPasskeysFound": "No passkeys have been created yet. Passkeys are created by visiting a website that offers passkeys as an authentication method.",
|
||||
"noAttachmentsFound": "No credentials with attachments found",
|
||||
"noMatchingCredentials": "No matching credentials found",
|
||||
"createdAt": "Erstellt",
|
||||
"updatedAt": "Zuletzt aktualisiert",
|
||||
"autofill": "Autofill",
|
||||
@@ -213,6 +217,13 @@
|
||||
"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.",
|
||||
"filters": {
|
||||
"all": "(All) Credentials",
|
||||
"passkeys": "Passkeys",
|
||||
"aliases": "Aliases",
|
||||
"userpass": "Passwords",
|
||||
"attachments": "Attachments"
|
||||
},
|
||||
"randomAlias": "Zufälliger Alias",
|
||||
"manual": "Manuell",
|
||||
"service": "Dienst",
|
||||
@@ -345,11 +356,23 @@
|
||||
"autofillSettings": "Autofill-Einstellungen",
|
||||
"clipboardSettings": "Zwischenablage-Einstellungen",
|
||||
"contextMenuSettings": "Kontextmenü-Einstellungen",
|
||||
"passkeySettings": "Passkey Settings",
|
||||
"contextMenu": "Kontextmenü",
|
||||
"contextMenuEnabled": "Kontextmenü ist aktiviert",
|
||||
"contextMenuDisabled": "Kontextmenü ist deaktiviert",
|
||||
"contextMenuDescription": "Rechtsklicke auf Eingabefelder, um auf AliasVault-Optionen zuzugreifen",
|
||||
"selectLanguage": "Sprache auswählen",
|
||||
"serverConfiguration": "Server Configuration",
|
||||
"serverConfigurationDescription": "Configure the AliasVault server URL for self-hosted instances",
|
||||
"customApiUrl": "API URL",
|
||||
"customClientUrl": "Client URL",
|
||||
"apiUrlHint": "The API endpoint URL (usually client URL + /api)",
|
||||
"clientUrlHint": "The web interface URL of your self-hosted instance",
|
||||
"autofillSettingsDescription": "Enable or disable the autofill popup on web pages",
|
||||
"autofillEnabledDescription": "Autofill suggestions will appear on login forms",
|
||||
"autofillDisabledDescription": "Autofill suggestions are disabled globally",
|
||||
"languageSettings": "Language",
|
||||
"languageSettingsDescription": "Choose your preferred language",
|
||||
"validation": {
|
||||
"apiUrlRequired": "API-URL ist erforderlich",
|
||||
"apiUrlInvalid": "Bitte gib eine gültige API-URL ein",
|
||||
@@ -357,12 +380,52 @@
|
||||
"clientUrlInvalid": "Bitte gib eine gültige Client-URL ein"
|
||||
}
|
||||
},
|
||||
"passkeys": {
|
||||
"passkey": "Passkey",
|
||||
"site": "Site",
|
||||
"displayName": "Name",
|
||||
"helpText": "Passkeys are created on the website when prompted. They cannot be manually edited. To remove this passkey, you can delete it from this credential. To replace this passkey or create a new one, visit the website and follow its prompts.",
|
||||
"passkeyMarkedForDeletion": "Passkey marked for deletion",
|
||||
"passkeyWillBeDeleted": "This passkey will be deleted when you save this credential.",
|
||||
"bypass": {
|
||||
"title": "Use Browser Passkey",
|
||||
"description": "How long would you like to use the browser's passkey provider for {{origin}}?",
|
||||
"thisTimeOnly": "This time only",
|
||||
"alwaysForSite": "Always for this site"
|
||||
},
|
||||
"authenticate": {
|
||||
"title": "Sign in with Passkey",
|
||||
"signInFor": "Sign in with passkey for",
|
||||
"selectPasskey": "Select a passkey to sign in:",
|
||||
"noPasskeysFound": "No passkeys found for this site",
|
||||
"useBrowserPasskey": "Use Browser Passkey"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Passkey",
|
||||
"createFor": "Create a new passkey for",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Enter a name for this passkey",
|
||||
"createButton": "Create Passkey",
|
||||
"creatingButton": "Creating...",
|
||||
"useBrowserPasskey": "Use Browser Passkey",
|
||||
"selectPasskeyToReplace": "Select a passkey to replace:",
|
||||
"createNewPasskey": "Create New Passkey",
|
||||
"replacingPasskey": "Replacing passkey: {{displayName}}",
|
||||
"confirmReplace": "Confirm Replace"
|
||||
},
|
||||
"settings": {
|
||||
"passkeyProvider": "Passkey Provider",
|
||||
"passkeyProviderOn": "Passkey Provider on ",
|
||||
"enable": "Enable AliasVault as passkey provider",
|
||||
"description": "When enabled, AliasVault will handle passkey requests from websites. When a website requests a passkey, the AliasVault popup will be shown instead of the native browser or OS passkey interface."
|
||||
}
|
||||
},
|
||||
"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:",
|
||||
"yourVault": "Version Deines Tresors:",
|
||||
"newVersion": "Neue verfügbare Version:",
|
||||
"upgrade": "Tresor aktualisieren",
|
||||
"upgrading": "Aktualisieren...",
|
||||
"logout": "Abmelden",
|
||||
@@ -386,8 +449,7 @@
|
||||
"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."
|
||||
"failedToApplyMigration": "Migration fehlgeschlagen ({{current}} von {{total}})"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
"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."
|
||||
"sessionExpired": "Your session has expired. Please log in again."
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
@@ -52,8 +52,10 @@
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"cancel": "Cancel",
|
||||
"back": "Back",
|
||||
"use": "Use",
|
||||
"delete": "Delete",
|
||||
"or": "Or",
|
||||
"close": "Close",
|
||||
"copied": "Copied!",
|
||||
"openInNewWindow": "Open in new window",
|
||||
@@ -89,12 +91,11 @@
|
||||
"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.",
|
||||
"browserExtensionOutdated": "This browser extension is outdated and cannot be used to access this vault. Please update this browser extension to continue.",
|
||||
"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",
|
||||
@@ -198,6 +199,9 @@
|
||||
"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.",
|
||||
"noPasskeysFound": "No passkeys have been created yet. Passkeys are created by visiting a website that offers passkeys as an authentication method.",
|
||||
"noAttachmentsFound": "No credentials with attachments found",
|
||||
"noMatchingCredentials": "No matching credentials found",
|
||||
"createdAt": "Created",
|
||||
"updatedAt": "Last updated",
|
||||
"autofill": "Autofill",
|
||||
@@ -213,6 +217,13 @@
|
||||
"saveCredential": "Save credential",
|
||||
"deleteCredentialTitle": "Delete Credential",
|
||||
"deleteCredentialConfirm": "Are you sure you want to delete this credential? This action cannot be undone.",
|
||||
"filters": {
|
||||
"all": "(All) Credentials",
|
||||
"passkeys": "Passkeys",
|
||||
"aliases": "Aliases",
|
||||
"userpass": "Passwords",
|
||||
"attachments": "Attachments"
|
||||
},
|
||||
"randomAlias": "Random Alias",
|
||||
"manual": "Manual",
|
||||
"service": "Service",
|
||||
@@ -345,11 +356,23 @@
|
||||
"autofillSettings": "Autofill Settings",
|
||||
"clipboardSettings": "Clipboard Settings",
|
||||
"contextMenuSettings": "Context Menu Settings",
|
||||
"passkeySettings": "Passkey 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",
|
||||
"serverConfiguration": "Server Configuration",
|
||||
"serverConfigurationDescription": "Configure the AliasVault server URL for self-hosted instances",
|
||||
"customApiUrl": "API URL",
|
||||
"customClientUrl": "Client URL",
|
||||
"apiUrlHint": "The API endpoint URL (usually client URL + /api)",
|
||||
"clientUrlHint": "The web interface URL of your self-hosted instance",
|
||||
"autofillSettingsDescription": "Enable or disable the autofill popup on web pages",
|
||||
"autofillEnabledDescription": "Autofill suggestions will appear on login forms",
|
||||
"autofillDisabledDescription": "Autofill suggestions are disabled globally",
|
||||
"languageSettings": "Language",
|
||||
"languageSettingsDescription": "Choose your preferred language",
|
||||
"validation": {
|
||||
"apiUrlRequired": "API URL is required",
|
||||
"apiUrlInvalid": "Please enter a valid API URL",
|
||||
@@ -357,12 +380,52 @@
|
||||
"clientUrlInvalid": "Please enter a valid client URL"
|
||||
}
|
||||
},
|
||||
"passkeys": {
|
||||
"passkey": "Passkey",
|
||||
"site": "Site",
|
||||
"displayName": "Name",
|
||||
"helpText": "Passkeys are created on the website when prompted. They cannot be manually edited. To remove this passkey, you can delete it from this credential. To replace this passkey or create a new one, visit the website and follow its prompts.",
|
||||
"passkeyMarkedForDeletion": "Passkey marked for deletion",
|
||||
"passkeyWillBeDeleted": "This passkey will be deleted when you save this credential.",
|
||||
"bypass": {
|
||||
"title": "Use Browser Passkey",
|
||||
"description": "How long would you like to use the browser's passkey provider for {{origin}}?",
|
||||
"thisTimeOnly": "This time only",
|
||||
"alwaysForSite": "Always for this site"
|
||||
},
|
||||
"authenticate": {
|
||||
"title": "Sign in with Passkey",
|
||||
"signInFor": "Sign in with passkey for",
|
||||
"selectPasskey": "Select a passkey to sign in:",
|
||||
"noPasskeysFound": "No passkeys found for this site",
|
||||
"useBrowserPasskey": "Use Browser Passkey"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Passkey",
|
||||
"createFor": "Create a new passkey for",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Enter a name for this passkey",
|
||||
"createButton": "Create Passkey",
|
||||
"creatingButton": "Creating...",
|
||||
"useBrowserPasskey": "Use Browser Passkey",
|
||||
"selectPasskeyToReplace": "Select a passkey to replace:",
|
||||
"createNewPasskey": "Create New Passkey",
|
||||
"replacingPasskey": "Replacing passkey: {{displayName}}",
|
||||
"confirmReplace": "Confirm Replace"
|
||||
},
|
||||
"settings": {
|
||||
"passkeyProvider": "Passkey Provider",
|
||||
"passkeyProviderOn": "Passkey Provider on ",
|
||||
"enable": "Enable AliasVault as passkey provider",
|
||||
"description": "When enabled, AliasVault will handle passkey requests from websites. When a website requests a passkey, the AliasVault popup will be shown instead of the native browser or OS passkey interface."
|
||||
}
|
||||
},
|
||||
"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:",
|
||||
"yourVault": "Your vault version:",
|
||||
"newVersion": "New available version:",
|
||||
"upgrade": "Upgrade Vault",
|
||||
"upgrading": "Upgrading...",
|
||||
"logout": "Logout",
|
||||
@@ -386,8 +449,7 @@
|
||||
"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."
|
||||
"failedToApplyMigration": "Failed to apply migration ({{current}} of {{total}})"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
"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."
|
||||
"sessionExpired": "Your session has expired. Please log in again."
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
@@ -52,8 +52,10 @@
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"cancel": "Cancel",
|
||||
"back": "Back",
|
||||
"use": "Use",
|
||||
"delete": "Delete",
|
||||
"or": "Or",
|
||||
"close": "Close",
|
||||
"copied": "Copied!",
|
||||
"openInNewWindow": "Open in new window",
|
||||
@@ -89,12 +91,11 @@
|
||||
"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.",
|
||||
"browserExtensionOutdated": "This browser extension is outdated and cannot be used to access this vault. Please update this browser extension to continue.",
|
||||
"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",
|
||||
@@ -198,6 +199,9 @@
|
||||
"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.",
|
||||
"noPasskeysFound": "No passkeys have been created yet. Passkeys are created by visiting a website that offers passkeys as an authentication method.",
|
||||
"noAttachmentsFound": "No credentials with attachments found",
|
||||
"noMatchingCredentials": "No matching credentials found",
|
||||
"createdAt": "Created",
|
||||
"updatedAt": "Last updated",
|
||||
"autofill": "Autofill",
|
||||
@@ -213,6 +217,13 @@
|
||||
"saveCredential": "Save credential",
|
||||
"deleteCredentialTitle": "Delete Credential",
|
||||
"deleteCredentialConfirm": "Are you sure you want to delete this credential? This action cannot be undone.",
|
||||
"filters": {
|
||||
"all": "(All) Credentials",
|
||||
"passkeys": "Passkeys",
|
||||
"aliases": "Aliases",
|
||||
"userpass": "Passwords",
|
||||
"attachments": "Attachments"
|
||||
},
|
||||
"randomAlias": "Random Alias",
|
||||
"manual": "Manual",
|
||||
"service": "Service",
|
||||
@@ -345,11 +356,23 @@
|
||||
"autofillSettings": "Autofill Settings",
|
||||
"clipboardSettings": "Clipboard Settings",
|
||||
"contextMenuSettings": "Context Menu Settings",
|
||||
"passkeySettings": "Passkey 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",
|
||||
"serverConfiguration": "Server Configuration",
|
||||
"serverConfigurationDescription": "Configure the AliasVault server URL for self-hosted instances",
|
||||
"customApiUrl": "API URL",
|
||||
"customClientUrl": "Client URL",
|
||||
"apiUrlHint": "The API endpoint URL (usually client URL + /api)",
|
||||
"clientUrlHint": "The web interface URL of your self-hosted instance",
|
||||
"autofillSettingsDescription": "Enable or disable the autofill popup on web pages",
|
||||
"autofillEnabledDescription": "Autofill suggestions will appear on login forms",
|
||||
"autofillDisabledDescription": "Autofill suggestions are disabled globally",
|
||||
"languageSettings": "Language",
|
||||
"languageSettingsDescription": "Choose your preferred language",
|
||||
"validation": {
|
||||
"apiUrlRequired": "API URL is required",
|
||||
"apiUrlInvalid": "Please enter a valid API URL",
|
||||
@@ -357,12 +380,52 @@
|
||||
"clientUrlInvalid": "Please enter a valid client URL"
|
||||
}
|
||||
},
|
||||
"passkeys": {
|
||||
"passkey": "Passkey",
|
||||
"site": "Site",
|
||||
"displayName": "Name",
|
||||
"helpText": "Passkeys are created on the website when prompted. They cannot be manually edited. To remove this passkey, you can delete it from this credential. To replace this passkey or create a new one, visit the website and follow its prompts.",
|
||||
"passkeyMarkedForDeletion": "Passkey marked for deletion",
|
||||
"passkeyWillBeDeleted": "This passkey will be deleted when you save this credential.",
|
||||
"bypass": {
|
||||
"title": "Use Browser Passkey",
|
||||
"description": "How long would you like to use the browser's passkey provider for {{origin}}?",
|
||||
"thisTimeOnly": "This time only",
|
||||
"alwaysForSite": "Always for this site"
|
||||
},
|
||||
"authenticate": {
|
||||
"title": "Sign in with Passkey",
|
||||
"signInFor": "Sign in with passkey for",
|
||||
"selectPasskey": "Select a passkey to sign in:",
|
||||
"noPasskeysFound": "No passkeys found for this site",
|
||||
"useBrowserPasskey": "Use Browser Passkey"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Passkey",
|
||||
"createFor": "Create a new passkey for",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Enter a name for this passkey",
|
||||
"createButton": "Create Passkey",
|
||||
"creatingButton": "Creating...",
|
||||
"useBrowserPasskey": "Use Browser Passkey",
|
||||
"selectPasskeyToReplace": "Select a passkey to replace:",
|
||||
"createNewPasskey": "Create New Passkey",
|
||||
"replacingPasskey": "Replacing passkey: {{displayName}}",
|
||||
"confirmReplace": "Confirm Replace"
|
||||
},
|
||||
"settings": {
|
||||
"passkeyProvider": "Passkey Provider",
|
||||
"passkeyProviderOn": "Passkey Provider on ",
|
||||
"enable": "Enable AliasVault as passkey provider",
|
||||
"description": "When enabled, AliasVault will handle passkey requests from websites. When a website requests a passkey, the AliasVault popup will be shown instead of the native browser or OS passkey interface."
|
||||
}
|
||||
},
|
||||
"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:",
|
||||
"yourVault": "Your vault version:",
|
||||
"newVersion": "New available version:",
|
||||
"upgrade": "Upgrade Vault",
|
||||
"upgrading": "Upgrading...",
|
||||
"logout": "Logout",
|
||||
@@ -386,8 +449,7 @@
|
||||
"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."
|
||||
"failedToApplyMigration": "Failed to apply migration ({{current}} of {{total}})"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,43 +6,43 @@
|
||||
"password": "Salasana",
|
||||
"passwordPlaceholder": "Syötä salasanasi",
|
||||
"rememberMe": "Muista minut",
|
||||
"loginButton": "Kirjaudu",
|
||||
"noAccount": "Eikö sinulla ole vielä tiliä?",
|
||||
"loginButton": "Kirjaudu sisään",
|
||||
"noAccount": "Eikö tiliä vielä ole?",
|
||||
"createVault": "Luo uusi holvi",
|
||||
"twoFactorTitle": "Ole hyvä ja syötä tunnistautumiskoodi tunnistautumissovelluksestasi.",
|
||||
"twoFactorTitle": "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",
|
||||
"unlockVault": "Avaa holvin lukitus",
|
||||
"unlockTitle": "Avaa holvisi lukitus",
|
||||
"unlockDescription": "Syötä pääsalasanasi avataksesi holvisi lukituksen.",
|
||||
"logout": "Kirjaudu ulos",
|
||||
"logout": "Uloskirjautuminen",
|
||||
"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.",
|
||||
"sessionExpired": "Istuntosi on vanhentunut. Kirjaudu sisään uudelleen.",
|
||||
"unlockSuccess": "Holvin lukitus avattu!",
|
||||
"unlockSuccessTitle": "Holvisi lukitus on avattu",
|
||||
"unlockSuccessDescription": "Voit nyt käyttää automaattista täyttöä sisäänkirjautumislomakkeissa selaimessasi.",
|
||||
"closePopup": "Sulje tämä ponnahdusikkuna",
|
||||
"browseVault": "Selaa holvin sisältöä",
|
||||
"connectingTo": "Yhdistetään palvelimeen",
|
||||
"connectingTo": "Yhdistetään kohteeseen",
|
||||
"switchAccounts": "Vaihdetaanko tiliä?",
|
||||
"loggedIn": "Kirjautuneena",
|
||||
"loggedIn": "Sisäänkirjautuneena",
|
||||
"errors": {
|
||||
"invalidCode": "Anna kelvollinen 6-numeroinen tunnistautumiskoodi.",
|
||||
"invalidCode": "Syötä 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",
|
||||
"noToken": "Sisäänkirjautuminen epäonnistui -- polettia 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.",
|
||||
"accountLocked": "Tili tilapäisesti lukittu liian monen epäonnistuneen yrityksen vuoksi.",
|
||||
"networkError": "Verkkovirhe: tarkista yhteytesi ja yritä uudelleen.",
|
||||
"loginDataMissing": "Kirjautumisistunto on vanhentunut. Yritä uudelleen."
|
||||
"sessionExpired": "Istuntosi on vanhentunut. Kirjaudu sisään uudelleen."
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"credentials": "Käyttäjätunnukset",
|
||||
"credentials": "Tunnistetiedot",
|
||||
"emails": "Sähköpostit",
|
||||
"settings": "Asetukset"
|
||||
},
|
||||
@@ -52,13 +52,15 @@
|
||||
"error": "Virhe",
|
||||
"success": "Onnistui",
|
||||
"cancel": "Peruuta",
|
||||
"back": "Takaisin",
|
||||
"use": "Käytä",
|
||||
"delete": "Poista",
|
||||
"or": "Tai",
|
||||
"close": "Sulje",
|
||||
"copied": "Kopioitu!",
|
||||
"openInNewWindow": "Avaa uudessa ikkunassa",
|
||||
"language": "Kieli",
|
||||
"enabled": "Käytössä",
|
||||
"enabled": "Otettu käyttöön",
|
||||
"disabled": "Pois käytöstä",
|
||||
"showPassword": "Näytä salasana",
|
||||
"hidePassword": "Piilota salasana",
|
||||
@@ -68,11 +70,11 @@
|
||||
"attachments": "Liitteet",
|
||||
"loadingAttachments": "Ladataan liitteitä...",
|
||||
"settings": "Asetukset",
|
||||
"recentEmails": "Viimeisimmät sähköpostit",
|
||||
"loginCredentials": "Sisäänkirjautumistiedot",
|
||||
"recentEmails": "Viimeaikaiset sähköpostit",
|
||||
"loginCredentials": "Sisäänkirjautumistunnistetiedot",
|
||||
"twoFactorAuthentication": "Kaksivaiheinen tunnistautuminen",
|
||||
"alias": "Alias",
|
||||
"notes": "Muistiinpanot",
|
||||
"notes": "Huomautukset",
|
||||
"fullName": "Koko nimi",
|
||||
"firstName": "Etunimi",
|
||||
"lastName": "Sukunimi",
|
||||
@@ -83,49 +85,48 @@
|
||||
"password": "Salasana",
|
||||
"syncingVault": "Synkronoidaan holvia",
|
||||
"savingChangesToVault": "Tallennetaan muutoksia holviin",
|
||||
"uploadingVaultToServer": "Lähetetään holvi palvelimelle",
|
||||
"checkingVaultUpdates": "Tarkistetaan holvin päivityksiä",
|
||||
"uploadingVaultToServer": "Ladataan holvi palvelimelle",
|
||||
"checkingVaultUpdates": "Tarkistetaan holvin päivitysten varalta",
|
||||
"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.",
|
||||
"clientVersionNotSupported": "Palvelin ei enää tue tätä AliasVault-selainlaajennuksen versiota. Päivitä selaimen laajennus uusimpaan versioon.",
|
||||
"browserExtensionOutdated": "Tämä selainlaajennus on vanhentunut, eikä sillä voi saada pääsyä tähän holviin. Päivitä tämä selainlaajennus jatkaaksesi.",
|
||||
"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ä",
|
||||
"vaultNotAvailable": "Holvi ei 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ä."
|
||||
"failedToUploadVault": "Holvin ulospäinlataaminen epäonnistui",
|
||||
"passwordChanged": "Salasanasi on muuttunut edellisen sisäänkirjautumisen jälkeen. Kirjaudu sisään 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_LOCKED": "Tili 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.",
|
||||
"REFRESH_TOKEN_REQUIRED": "Virkistyspoletti vaaditaan.",
|
||||
"INVALID_REFRESH_TOKEN": "Virheellinen virkistyspoletti.",
|
||||
"REFRESH_TOKEN_REVOKED_SUCCESSFULLY": "Virkistyspoletti peruutettu.",
|
||||
"PUBLIC_REGISTRATION_DISABLED": "Uuden tilin rekisteröinti on tällä hetkellä 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_ALREADY_IN_USE": "Käyttäjänimi 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ä.",
|
||||
"ACCOUNT_SUCCESSFULLY_DELETED": "Tili poistettu.",
|
||||
"USERNAME_EMPTY_OR_WHITESPACE": "Käyttäjänimi ei voi olla tyhjä eikä siinä voi olla välilyöntejä.",
|
||||
"USERNAME_TOO_SHORT": "Käyttäjänimi liian lyhyt: sen on oltava vähintään 3 merkkiä pitkä.",
|
||||
"USERNAME_TOO_LONG": "Käyttäjänimi liian pitkä: ei saa olla yli 40 merkkiä pidempi.",
|
||||
"USERNAME_INVALID_EMAIL": "Virheellinen sähköpostiosoite.",
|
||||
"USERNAME_INVALID_CHARACTERS": "Käyttäjätunnus on virheellinen, voi sisältää vain kirjaimia tai numeroita.",
|
||||
"USERNAME_INVALID_CHARACTERS": "Käyttäjänimi 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."
|
||||
"VAULT_ERROR": "Paikallinen holvi ei ole ajan tasalla. Synkronoi holvisi virkistämällä sivu ja yritä uudelleen."
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
@@ -135,7 +136,7 @@
|
||||
"search": "Etsi",
|
||||
"vaultLocked": "AliasVault on lukittu.",
|
||||
"creatingNewAlias": "Luodaan uutta aliasta...",
|
||||
"noMatchesFound": "Hakutuloksia ei löytynyt",
|
||||
"noMatchesFound": "Osumia ei löytynyt",
|
||||
"searchVault": "Etsi holvi...",
|
||||
"serviceName": "Palvelun nimi",
|
||||
"email": "Sähköposti",
|
||||
@@ -144,24 +145,24 @@
|
||||
"enterServiceName": "Syötä palvelun nimi",
|
||||
"enterEmailAddress": "Syötä sähköpostiosoite",
|
||||
"enterUsername": "Syötä käyttäjänimi",
|
||||
"hideFor1Hour": "Piilota 1 tunniksi (nykyinen sivusto)",
|
||||
"hideFor1Hour": "Piilota tunnin ajan (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",
|
||||
"createAndSaveCredential": "Luo ja tallenna tunnistetieto",
|
||||
"randomIdentityDescription": "Luo satunnainen identiteetti, jolla on satunnainen sähköpostiosoite, johon on pääsy AliasVaultissa.",
|
||||
"randomIdentityDescriptionDropdown": "Satunnainen identiteetti satunnaisella sähköpostiosoitteella",
|
||||
"randomIdentityDescriptionDropdown": "Satunnainen identiteetti satunnaisen sähköpostiosoitteen kanssa",
|
||||
"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.",
|
||||
"failedToCreateIdentity": "Identiteetin luominen 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ä",
|
||||
"togglePasswordVisibility": "Salasanan näkyvyyden päälle/pois päältä kytkeminen",
|
||||
"passwordCopiedToClipboard": "Salasana kopioitu leikepöydälle",
|
||||
"enterEmailAndOrUsernameError": "Syötä sähköpostiosoite ja/tai käyttäjänimi",
|
||||
"openAliasVaultToUpgrade": "Avaa AliasVault päivittääksesi",
|
||||
@@ -169,13 +170,13 @@
|
||||
"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",
|
||||
"title": "Tunnistetiedot",
|
||||
"addCredential": "Lisää tunnistetieto",
|
||||
"editCredential": "Muokkaa tunnistetietoa",
|
||||
"deleteCredential": "Poista tunnistetieto",
|
||||
"credentialDetails": "Tunnistetietojen yksityiskohdat",
|
||||
"serviceName": "Palvelun nimi",
|
||||
"serviceNamePlaceholder": "esim. Gmail, Facebook, Pankki",
|
||||
"serviceNamePlaceholder": "esim. Gmail, Facebook, pankki",
|
||||
"website": "Verkkosivusto",
|
||||
"websitePlaceholder": "https://esimerkki.fi",
|
||||
"username": "Käyttäjänimi",
|
||||
@@ -186,39 +187,49 @@
|
||||
"copyPassword": "Kopioi salasana",
|
||||
"showPassword": "Näytä salasana",
|
||||
"hidePassword": "Piilota salasana",
|
||||
"notes": "Muistiinpanot",
|
||||
"notesPlaceholder": "Muut huomautukset...",
|
||||
"notes": "Huomautukset",
|
||||
"notesPlaceholder": "Lisähuomautukset...",
|
||||
"totp": "Kaksivaiheinen tunnistautuminen",
|
||||
"totpCode": "TOTP koodi",
|
||||
"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...",
|
||||
"totpSecret": "TOTP-salaisuus",
|
||||
"totpSecretPlaceholder": "Syötä TOTP-salainen avain",
|
||||
"noCredentials": "Tunnistetietoja ei löytynyt",
|
||||
"noCredentialsDescription": "Lisää ensimmäinen tunnistetietosi aloittaaksesi",
|
||||
"searchPlaceholder": "Etsi tunnistetietoja...",
|
||||
"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.",
|
||||
"welcomeDescription": "Käyttääksesi AliasVault-selainlaajennusta, siirry jollekin verkkosivustolle ja käytä AliasVaultin automaattisen täytön ponnahdusikkunaa luodaksesi uuden tunnistetiedon.",
|
||||
"noPasskeysFound": "Todennusavaimia, Passkey ei ole vielä luotu. Todennusavaimet on luotu vierailemalla verkkosivustolla, joka tarjoaa todennusavaimia todennusmenetelmänä.",
|
||||
"noAttachmentsFound": "Tunnuksia liitteiden kanssa ei löytynyt",
|
||||
"noMatchingCredentials": "Vastaavia tunnistetietoja ei löytynyt",
|
||||
"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.",
|
||||
"deleteConfirm": "Oletko varma, että haluat poistaa tämän tunnistetiedon?",
|
||||
"saveSuccess": "Tunnistetieto tallennettu",
|
||||
"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",
|
||||
"saveCredential": "Tallenna tunnistetieto",
|
||||
"deleteCredentialTitle": "Poista tunnistetieto",
|
||||
"deleteCredentialConfirm": "Oletko varma, että haluat poistaa tämän tunnistetiedon? Tätä toimintoa ei voi perua.",
|
||||
"filters": {
|
||||
"all": "(All) Käyttäjätunnukset",
|
||||
"passkeys": "Sala-avaimet",
|
||||
"aliases": "Aliakset",
|
||||
"userpass": "Salasanat",
|
||||
"attachments": "Liitteet"
|
||||
},
|
||||
"randomAlias": "Sattumanvarainen alias",
|
||||
"manual": "Käyttöopas",
|
||||
"service": "Palvelu",
|
||||
"serviceUrl": "Palvelun URL-osoite",
|
||||
"loginCredentials": "Sisäänkirjautumistiedot",
|
||||
"generateRandomUsername": "Luo sattumanvarainen käyttäjätunnus",
|
||||
"loginCredentials": "Sisäänkirjautumistunnistetiedot",
|
||||
"generateRandomUsername": "Luo sattumanvarainen käyttäjänimi",
|
||||
"generateRandomPassword": "Luo sattumanvarainen salasana",
|
||||
"changePasswordComplexity": "Muuta salasanan monimutkaisuutta",
|
||||
"passwordLength": "Salasanan pituus",
|
||||
@@ -226,10 +237,10 @@
|
||||
"includeUppercase": "Sisällytä isot kirjaimet",
|
||||
"includeNumbers": "Sisällytä numerot",
|
||||
"includeSpecialChars": "Sisällytä erikoismerkit",
|
||||
"avoidAmbiguousChars": "Vältä epäselviä merkkejä (o, 0 jne.)",
|
||||
"avoidAmbiguousChars": "Vältä epäselviä merkkejä (o, 0, jne.)",
|
||||
"generateNewPreview": "Luo uusi esikatselu",
|
||||
"generateRandomAlias": "Luo sattumanvarainen alias",
|
||||
"clearAliasFields": "Tyhjennä aliaksen kentät",
|
||||
"clearAliasFields": "Tyhjennä alias-kentät",
|
||||
"alias": "Alias",
|
||||
"firstName": "Etunimi",
|
||||
"lastName": "Sukunimi",
|
||||
@@ -246,18 +257,18 @@
|
||||
},
|
||||
"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",
|
||||
"privateEmailDescription": "Päästä päähän (E2E) salattu, täysin yksityinen.",
|
||||
"publicEmailTitle": "Julkiset väliaikaisten sähköpostiosoitteiden tarjoajat (PTEP)",
|
||||
"publicEmailDescription": "Anonyymi, mutta rajoitettu yksityisyys. Sähköpostin sisällön voi lukea kuka tahansa, joka tietää osoitteen.",
|
||||
"useDomainChooser": "Käytä verkkotunnuksen valitsijaa",
|
||||
"enterCustomDomain": "Syötä mukautettu verkkotunnus",
|
||||
"enterFullEmail": "Syötä koko 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?",
|
||||
"deleteEmailConfirm": "Oletko varma, että haluat poistaa tämän sähköpostin pysyvästi?",
|
||||
"from": "Lähettäjä",
|
||||
"to": "Vastaanottaja",
|
||||
"date": "Päivämäärä",
|
||||
@@ -270,16 +281,16 @@
|
||||
"justNow": "juuri nyt",
|
||||
"minutesAgo_single": "{{count}} min sitten",
|
||||
"minutesAgo_plural": "{{count}} minuuttia sitten",
|
||||
"hoursAgo_single": "{{count}} h sitten",
|
||||
"hoursAgo_single": "{{count}} tunti 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."
|
||||
"emailUnexpectedError": "Odottamaton virhe sähköpostien lataamisen 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_MATCH_USER": "Nykyinen valittu sähköpostiosoite on jo käytössä. Vaihda sähköpostiosoite muokkaamalla tätä tunnistetietoa.",
|
||||
"CLAIM_DOES_NOT_EXIST": "Tapahtui virhe ladattaessa sähköposteja. Yritä muokata ja tallentaa tunnistetiedot synkronoidaksesi tietokannan, ja yritä sitten uudelleen."
|
||||
}
|
||||
},
|
||||
@@ -287,49 +298,49 @@
|
||||
"title": "Asetukset",
|
||||
"serverUrl": "Palvelimen URL-osoite",
|
||||
"language": "Kieli",
|
||||
"autofillEnabled": "Ota automaattitäyttö käyttöön",
|
||||
"autofillEnabled": "Ota automaattinen täyttö käyttöön",
|
||||
"version": "Versio",
|
||||
"openInNewWindow": "Avaa uudessa ikkunassa",
|
||||
"openWebApp": "Avaa verkkosovellus",
|
||||
"loggedIn": "Kirjautuneena",
|
||||
"logout": "Kirjaudu ulos",
|
||||
"globalSettings": "Yleiset asetukset",
|
||||
"loggedIn": "Sisäänkirjautuneena",
|
||||
"logout": "Uloskirjautuminen",
|
||||
"globalSettings": "Yleisesti pätevät 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ä",
|
||||
"activeOnAllSites": "Aktiivisena kaikilla sivustoilla (ellei sitä ole poistettu käytöstä alla)",
|
||||
"disabledOnAllSites": "Pois käytöstä kaikilla sivustoilla",
|
||||
"enabled": "Otettu käyttöön",
|
||||
"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",
|
||||
"rightClickContextMenu": "Oikea-napsauta kontekstivalikkoa",
|
||||
"autofillMatching": "Automaattisen täytön täsmäytys",
|
||||
"autofillMatchingMode": "Automaattisen täytön täsmäytystila",
|
||||
"autofillMatchingModeDescription": "Määrittää, mitkä tunnistetiedot katsotaan osumiksi ja näytetään ehdotuksina tietyn verkkosivuston automaattisen täytön ponnahdusikkunassa.",
|
||||
"autofillMatchingDefault": "URL + aliverkkotunnus + nimi jokerimerkki",
|
||||
"autofillMatchingUrlSubdomain": "URL + aliverkkotunnus",
|
||||
"autofillMatchingUrlExact": "Tarkka URL-verkkotunnus vain",
|
||||
"siteSpecificSettings": "Sivukohtaiset asetukset",
|
||||
"siteSpecificSettings": "Sivustokohtaiset 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ä ",
|
||||
"enabledForThisSite": "Otettu käyttöön tällä sivustolla",
|
||||
"disabledForThisSite": "Poistettu käytöstä tällä sivustolla",
|
||||
"temporarilyDisabledUntil": "Väliaikaisesti pois käytöstä, kunnes ",
|
||||
"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",
|
||||
"keyboardShortcuts": "Näppäimistön pikanppäimet",
|
||||
"configureKeyboardShortcuts": "Määritä näppäimistön 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",
|
||||
"clipboardClearTimeout": "Tyhjennä leikepöytä kopioimisen jälkeen",
|
||||
"clipboardClearTimeoutDescription": "Tyhjennä leikepöytä automaattisesti arkaluonteisten tietojen kopioimisen jälkeen",
|
||||
"clipboardClearDisabled": "Älä koskaan tyhjennä",
|
||||
"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.",
|
||||
"autoLockTimeoutDescription": "Lukitse holvi automaattisesti käyttämättömyysjakson jälkeen",
|
||||
"autoLockTimeoutHelp": "Holvi lukittuu vasta määritetyn käyttämättömyysjakson jälkeen (ei automaattista täyttöä tai laajennusten ponnahdusikkunoita ole avattu). Holvi lukittuu aina, kun selain suljetaan, tästä asetuksesta riippumatta.",
|
||||
"autoLockNever": "Ei koskaan",
|
||||
"autoLock15Seconds": "15 sekuntia",
|
||||
"autoLock1Minute": "1 minuutti",
|
||||
@@ -344,50 +355,101 @@
|
||||
"preferences": "Määritykset",
|
||||
"autofillSettings": "Automaatisen täytön asetukset",
|
||||
"clipboardSettings": "Leikepöydän asetukset",
|
||||
"contextMenuSettings": "Sisältövalikon asetukset",
|
||||
"contextMenuSettings": "Kontekstivalikon asetukset",
|
||||
"passkeySettings": "Todennusavainten 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",
|
||||
"contextMenuEnabled": "Kontekstivalikko on otettu käyttöön",
|
||||
"contextMenuDisabled": "Kontekstivalikko on poistettu käytöstä",
|
||||
"contextMenuDescription": "Oikea-napsauta syöttökenttiä päästäksesi AliasVaultin vaihtoehtoihin",
|
||||
"selectLanguage": "Valitse kieli",
|
||||
"serverConfiguration": "Palvelimen asetukset",
|
||||
"serverConfigurationDescription": "Määritä AliasVault-palvelimen URL-osoite itse isännöityille instanssille",
|
||||
"customApiUrl": "API-URL-osoite",
|
||||
"customClientUrl": "Asiakas-URL-osoite",
|
||||
"apiUrlHint": "API päätepisteen URL (yleensä asiakkaan URL + /api)",
|
||||
"clientUrlHint": "Web-käyttöliittymän URL-osoite itse isännöidyssä instanssissa",
|
||||
"autofillSettingsDescription": "Ota käyttöön tai poista käytöstä automaattisen täytön ponnahdusikkuna verkkosivuilta",
|
||||
"autofillEnabledDescription": "Automaattisen täytön ehdotukset näkyvät kirjautumislomakkeissa",
|
||||
"autofillDisabledDescription": "Automaattitäyttöehdotukset on poistettu käytöstä kaikkialla",
|
||||
"languageSettings": "Keili",
|
||||
"languageSettingsDescription": "Valitse ensisijainen kieli",
|
||||
"validation": {
|
||||
"apiUrlRequired": "API URL-osoite vaaditaan",
|
||||
"apiUrlInvalid": "Anna kelvollinen API URL-osoite",
|
||||
"apiUrlRequired": "API-URL-osoite vaaditaan",
|
||||
"apiUrlInvalid": "Syötä kelvollinen API-URL-osoite",
|
||||
"clientUrlRequired": "Asiakkaan URL-osoite vaaditaan",
|
||||
"clientUrlInvalid": "Anna kelvollinen asiakkaan URL-osoite"
|
||||
"clientUrlInvalid": "Syötä kelvollinen asiakas-URL-osoite"
|
||||
}
|
||||
},
|
||||
"passkeys": {
|
||||
"passkey": "Sala-avain",
|
||||
"site": "Sivusto",
|
||||
"displayName": "Nimi",
|
||||
"helpText": "Todennusavaimet, Passkeys, luodaan sivustolle pyydettäessä. Niitä ei voi manuaalisesti muokata. Voit poistaa tämän salasanan, voit poistaa sen tästä käyttäjätunnuksesta. Voit korvata tämän salasanan tai luoda uuden, käy verkkosivuilla ja seuraa sen kehotuksia.",
|
||||
"passkeyMarkedForDeletion": "Todennusavain merkitty poistettavaksi",
|
||||
"passkeyWillBeDeleted": "Tämä todennusavain poistetaan, kun tallennat tämän käyttäjätiedon.",
|
||||
"bypass": {
|
||||
"title": "Käytä selaimen sala-avainta",
|
||||
"description": "Kuinka kauan haluat käyttää selaimen todennusavaimen tarjoajaa {{origin}}?",
|
||||
"thisTimeOnly": "Vain tällä kertaa",
|
||||
"alwaysForSite": "Aina tällä sivustolla"
|
||||
},
|
||||
"authenticate": {
|
||||
"title": "Kirjaudu sisään todennusavaimella",
|
||||
"signInFor": "Kirjaudu sisään todennusavaimella saadaksesi",
|
||||
"selectPasskey": "Valitse todennusavain jolla kirjaudutaan",
|
||||
"noPasskeysFound": "Tällä sivustolla ei löytynyt todennusavaimia",
|
||||
"useBrowserPasskey": "Käytä selaimen todennusavainta"
|
||||
},
|
||||
"create": {
|
||||
"title": "Luo Passkey, todennusavain",
|
||||
"createFor": "Luo uusi Passkey, todennusavain",
|
||||
"titleLabel": "Otsikko",
|
||||
"titlePlaceholder": "Anna nimi tälle todennusavaimelle",
|
||||
"createButton": "Luo todennusavain, Passkey",
|
||||
"creatingButton": "Luodaan...",
|
||||
"useBrowserPasskey": "Käytä selaimen sala-avainta",
|
||||
"selectPasskeyToReplace": "Valitse todennusavain, johon korvataan",
|
||||
"createNewPasskey": "Luo uusi sala-avain",
|
||||
"replacingPasskey": "Korvaava todennusavain: {{displayName}}",
|
||||
"confirmReplace": "Vahvista korvaaminen"
|
||||
},
|
||||
"settings": {
|
||||
"passkeyProvider": "Todennusavaimen toimittaja",
|
||||
"passkeyProviderOn": "Todennusavaimen toimittaja käytössä ",
|
||||
"enable": "Ota AliasVault käyttöön todennusavainten tarjoajana",
|
||||
"description": "Kun AliasVault on käytössä, se käsittelee todennusavaimia verkkosivustoilta. Kun sivusto pyytää todennusavainta, AliasVaultin ponnahdusikkuna näytetään natiivin selaimen tai käyttöjärjestelmän todennusavaimen sijaan."
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"yourVault": "Holvisi versio:",
|
||||
"newVersion": "Uusi saatavilla oleva versio:",
|
||||
"upgrade": "Päivitä holvi",
|
||||
"upgrading": "Päivitetään...",
|
||||
"logout": "Kirjaudu ulos",
|
||||
"logout": "Uloskirjautuminen",
|
||||
"whatsNew": "Mitä uutta?",
|
||||
"whatsNewDescription": "Päivitys on tarpeen, jotta voidaan tukea seuraavia muutoksia:",
|
||||
"noDescriptionAvailable": "Kuvausta ei ole saatavilla tälle versiolle.",
|
||||
"okay": "Ok",
|
||||
"whatsNewDescription": "Päivitys vaaditaan seuraavien muutosten tukemiseksi:",
|
||||
"noDescriptionAvailable": "Tälle versiolle ei ole saatavilla kuvausta.",
|
||||
"okay": "Hyvä on",
|
||||
"status": {
|
||||
"preparingUpgrade": "Valmistellaan päivityksiä...",
|
||||
"preparingUpgrade": "Valmistellaan päivitystä...",
|
||||
"vaultAlreadyUpToDate": "Holvi on jo ajan tasalla",
|
||||
"startingDatabaseTransaction": "Aloitetaan tietokannan siirtoa...",
|
||||
"startingDatabaseTransaction": "Aloitetaan tietokannan transaktiota...",
|
||||
"applyingDatabaseMigrations": "Toteutetaan tietokannan siirtoja...",
|
||||
"applyingMigration": "Siirretään tietoja: {{current}} / {{total}}...",
|
||||
"committingChanges": "Suoritetaan muutoksia..."
|
||||
"applyingMigration": "Otetaan siirto käyttöön {{current}} / {{total}}...",
|
||||
"committingChanges": "Otetaan muutokset käyttöön..."
|
||||
},
|
||||
"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.",
|
||||
"unableToGetVersionInfo": "Versiotietojen hakeminen epäonnistui. Yritä uudelleen.",
|
||||
"selfHostedServer": "Itseisännöity palvelin",
|
||||
"selfHostedWarning": "Jos käytät itseisännöityä palvelinta, muista päivittää myös itseisännöity instanssisi, koska muuten verkkoasiakassovellukseen kirjautuminen 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."
|
||||
"failedToApplyMigration": "Siirron käyttöönotto epäonnistui ({{current}} / {{total}})"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
"logout": "Se déconnecter",
|
||||
"logoutConfirm": "Êtes-vous sûr de vouloir vous déconnecter ?",
|
||||
"sessionExpired": "Votre session a expiré. Veuillez vous reconnecter.",
|
||||
"unlockSuccess": "Parcourir le contenu du coffre",
|
||||
"unlockSuccess": "Parcourir le contenu du coffre !",
|
||||
"unlockSuccessTitle": "Votre coffre a été déverrouillé avec succès",
|
||||
"unlockSuccessDescription": "Vous pouvez maintenant utiliser le remplissage automatique des formulaires de connexion dans votre navigateur.",
|
||||
"closePopup": "Fermer cette popup",
|
||||
@@ -38,7 +38,7 @@
|
||||
"wrongPassword": "Mot de passe incorrect, veuillez réessayer.",
|
||||
"accountLocked": "Compte temporairement verrouillé en raison d'un trop grand nombre de tentatives échouées.",
|
||||
"networkError": "Erreur réseau. Vérifiez votre connexion et réessayez.",
|
||||
"loginDataMissing": "La session a expiré. Veuillez réessayer."
|
||||
"sessionExpired": "Votre session a expiré. Veuillez vous reconnecter."
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
@@ -52,8 +52,10 @@
|
||||
"error": "Erreur",
|
||||
"success": "Succès",
|
||||
"cancel": "Annuler",
|
||||
"back": "Back",
|
||||
"use": "Utiliser",
|
||||
"delete": "Supprimer",
|
||||
"or": "Or",
|
||||
"close": "Fermer",
|
||||
"copied": "Copié !",
|
||||
"openInNewWindow": "Ouvrir dans une nouvelle fenêtre",
|
||||
@@ -89,12 +91,11 @@
|
||||
"executingOperation": "Exécution de l'opération...",
|
||||
"loadMore": "Voir plus",
|
||||
"errors": {
|
||||
"VaultOutdated": "Votre coffre est obsolète. Veuillez vous connecter sur le site AliasVault et suivre les étapes.",
|
||||
"serverNotAvailable": "Le serveur d'AliasVault n'est pas disponible. Veuillez réessayer plus tard ou contacter le support si le problème persiste.",
|
||||
"clientVersionNotSupported": "Cette version de l'extension de navigateur AliasVault n'est plus prise en charge par le serveur. Veuillez mettre à jour votre extension de navigateur à la dernière version.",
|
||||
"browserExtensionOutdated": "Cette extension de navigateur est obsolète et ne peut pas être utilisée pour accéder à ce coffre-fort. Veuillez la mettre à jour pour continuer.",
|
||||
"serverVersionNotSupported": "Le serveur d'AliasVault doit être mis à jour vers une version plus récente afin d'utiliser cette extension de navigateur. Veuillez contacter le support si vous avez besoin d'aide.",
|
||||
"unknownError": "Une erreur inconnue s'est produite",
|
||||
"failedToStoreVault": "Échec du stockage du coffre",
|
||||
"vaultNotAvailable": "Coffre non disponible",
|
||||
"failedToRetrieveData": "Échec de la récupération des données",
|
||||
"vaultIsLocked": "Le coffre est verrouillé",
|
||||
@@ -198,6 +199,9 @@
|
||||
"searchPlaceholder": "Rechercher des identifiants...",
|
||||
"welcomeTitle": "Bienvenue dans AliasVault !",
|
||||
"welcomeDescription": "Pour utiliser l'extension de navigateur AliasVault : accédez à un site web et utilisez la fenêtre de saisie automatique AliasVault pour créer un nouvel identifiant.",
|
||||
"noPasskeysFound": "No passkeys have been created yet. Passkeys are created by visiting a website that offers passkeys as an authentication method.",
|
||||
"noAttachmentsFound": "No credentials with attachments found",
|
||||
"noMatchingCredentials": "No matching credentials found",
|
||||
"createdAt": "Créé",
|
||||
"updatedAt": "Dernière mise à jour",
|
||||
"autofill": "Remplissage automatique",
|
||||
@@ -213,6 +217,13 @@
|
||||
"saveCredential": "Enregistrer les identifiants",
|
||||
"deleteCredentialTitle": "Supprimer les identifiants",
|
||||
"deleteCredentialConfirm": "Êtes-vous sûr de vouloir supprimer ces identifiants ? Cette action est irréversible.",
|
||||
"filters": {
|
||||
"all": "(All) Credentials",
|
||||
"passkeys": "Passkeys",
|
||||
"aliases": "Aliases",
|
||||
"userpass": "Passwords",
|
||||
"attachments": "Attachments"
|
||||
},
|
||||
"randomAlias": "Alias aléatoire",
|
||||
"manual": "Manuel",
|
||||
"service": "Service",
|
||||
@@ -229,7 +240,7 @@
|
||||
"avoidAmbiguousChars": "Éviter les caractères ambigus (o, 0, etc.)",
|
||||
"generateNewPreview": "Générer un nouvel aperçu",
|
||||
"generateRandomAlias": "Créer un alias aléatoire",
|
||||
"clearAliasFields": "Clear Alias Fields",
|
||||
"clearAliasFields": "Effacer les champs d'alias",
|
||||
"alias": "Alias",
|
||||
"firstName": "Prénom",
|
||||
"lastName": "Nom",
|
||||
@@ -345,11 +356,23 @@
|
||||
"autofillSettings": "Paramètres du remplissage automatique",
|
||||
"clipboardSettings": "Paramètres du presse-papiers",
|
||||
"contextMenuSettings": "Paramètres du menu contextuel",
|
||||
"passkeySettings": "Passkey Settings",
|
||||
"contextMenu": "Menu contextuel",
|
||||
"contextMenuEnabled": "Le menu contextuel est activé",
|
||||
"contextMenuDisabled": "Le menu contextuel est désactivé",
|
||||
"contextMenuDescription": "Faites un clic droit sur les champs de saisie pour accéder aux options d'AliasVault",
|
||||
"selectLanguage": "Sélectionner une langue",
|
||||
"serverConfiguration": "Server Configuration",
|
||||
"serverConfigurationDescription": "Configure the AliasVault server URL for self-hosted instances",
|
||||
"customApiUrl": "API URL",
|
||||
"customClientUrl": "Client URL",
|
||||
"apiUrlHint": "The API endpoint URL (usually client URL + /api)",
|
||||
"clientUrlHint": "The web interface URL of your self-hosted instance",
|
||||
"autofillSettingsDescription": "Enable or disable the autofill popup on web pages",
|
||||
"autofillEnabledDescription": "Autofill suggestions will appear on login forms",
|
||||
"autofillDisabledDescription": "Autofill suggestions are disabled globally",
|
||||
"languageSettings": "Language",
|
||||
"languageSettingsDescription": "Choose your preferred language",
|
||||
"validation": {
|
||||
"apiUrlRequired": "L'URL de l'API est requise",
|
||||
"apiUrlInvalid": "Veuillez entrer une URL d'API valide",
|
||||
@@ -357,12 +380,52 @@
|
||||
"clientUrlInvalid": "Veuillez entrer une URL de client valide"
|
||||
}
|
||||
},
|
||||
"passkeys": {
|
||||
"passkey": "Passkey",
|
||||
"site": "Site",
|
||||
"displayName": "Name",
|
||||
"helpText": "Passkeys are created on the website when prompted. They cannot be manually edited. To remove this passkey, you can delete it from this credential. To replace this passkey or create a new one, visit the website and follow its prompts.",
|
||||
"passkeyMarkedForDeletion": "Passkey marked for deletion",
|
||||
"passkeyWillBeDeleted": "This passkey will be deleted when you save this credential.",
|
||||
"bypass": {
|
||||
"title": "Use Browser Passkey",
|
||||
"description": "How long would you like to use the browser's passkey provider for {{origin}}?",
|
||||
"thisTimeOnly": "This time only",
|
||||
"alwaysForSite": "Always for this site"
|
||||
},
|
||||
"authenticate": {
|
||||
"title": "Sign in with Passkey",
|
||||
"signInFor": "Sign in with passkey for",
|
||||
"selectPasskey": "Select a passkey to sign in:",
|
||||
"noPasskeysFound": "No passkeys found for this site",
|
||||
"useBrowserPasskey": "Use Browser Passkey"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Passkey",
|
||||
"createFor": "Create a new passkey for",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Enter a name for this passkey",
|
||||
"createButton": "Create Passkey",
|
||||
"creatingButton": "Creating...",
|
||||
"useBrowserPasskey": "Use Browser Passkey",
|
||||
"selectPasskeyToReplace": "Select a passkey to replace:",
|
||||
"createNewPasskey": "Create New Passkey",
|
||||
"replacingPasskey": "Replacing passkey: {{displayName}}",
|
||||
"confirmReplace": "Confirm Replace"
|
||||
},
|
||||
"settings": {
|
||||
"passkeyProvider": "Passkey Provider",
|
||||
"passkeyProviderOn": "Passkey Provider on ",
|
||||
"enable": "Enable AliasVault as passkey provider",
|
||||
"description": "When enabled, AliasVault will handle passkey requests from websites. When a website requests a passkey, the AliasVault popup will be shown instead of the native browser or OS passkey interface."
|
||||
}
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "Mettre à niveau le coffre",
|
||||
"subtitle": "AliasVault a mis à jour et votre coffre doit être mis à niveau. Cela ne devrait prendre que quelques secondes.",
|
||||
"versionInformation": "Informations de version",
|
||||
"yourVault": "Votre coffre :",
|
||||
"newVersion": "Nouvelle version :",
|
||||
"yourVault": "Votre version de coffre-fort :",
|
||||
"newVersion": "Nouvelle version valable :",
|
||||
"upgrade": "Mettre le coffre à niveau",
|
||||
"upgrading": "Mise à niveau...",
|
||||
"logout": "Se déconnecter",
|
||||
@@ -386,8 +449,7 @@
|
||||
"cancel": "Annuler",
|
||||
"continueUpgrade": "Continuer la mise à jour",
|
||||
"upgradeFailed": "Échec de la mise à niveau",
|
||||
"failedToApplyMigration": "Impossible d'appliquer la migration ({{current}} sur {{total}})",
|
||||
"unknownErrorDuringUpgrade": "Une erreur inconnue s'est produite pendant la mise à niveau. Veuillez réessayer."
|
||||
"failedToApplyMigration": "Impossible d'appliquer la migration ({{current}} sur {{total}})"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
"wrongPassword": "סיסמה שגויה. נא לנסות שוב.",
|
||||
"accountLocked": "החשבון נעול זמנית עקב ריבוי ניסיונות כושלים.",
|
||||
"networkError": "שגיאת רשת. נא לבדוק את החיבור ולנסות שוב.",
|
||||
"loginDataMissing": "תוקף ההפעלה שלך פג. נא לנסות שוב."
|
||||
"sessionExpired": "תוקף ההפעלה שלך פג. נא להיכנס מחדש."
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
@@ -52,8 +52,10 @@
|
||||
"error": "שגיאה",
|
||||
"success": "הצליח",
|
||||
"cancel": "ביטול",
|
||||
"back": "חזרה",
|
||||
"use": "להשתמש",
|
||||
"delete": "מחיקה",
|
||||
"or": "או",
|
||||
"close": "סגירה",
|
||||
"copied": "הועתק!",
|
||||
"openInNewWindow": "פתיחה בחלון חדש",
|
||||
@@ -89,12 +91,11 @@
|
||||
"executingOperation": "הפעולה רצה…",
|
||||
"loadMore": "לטעון עוד",
|
||||
"errors": {
|
||||
"VaultOutdated": "הכספת שלך לא עדכנית. נא להיכנס לאתר AliasVault ולעקוב אחר ההנחיות.",
|
||||
"serverNotAvailable": "שרת ה־AliasVault לא זמין. נא לנסות שוב מאוחר יותר או ליצור קשר עם התמיכה אם הבעיה נשנית.",
|
||||
"clientVersionNotSupported": "הגרסה הזאת של הרחבת הדפדפן של AliasVault לא נתמכת עוד על ידי השרת. נא לעדכן את הרחבת הדפדפן שלך לגרסה העדכנית ביותר.",
|
||||
"browserExtensionOutdated": "הרחבת הדפדפן הזאת לא עדכנית ואי אפשר להשתמש בה כדי לגשת לכספת הזאת. נא לעדכן את הרחבת הדפדפן הזאת כדי להמשיך.",
|
||||
"serverVersionNotSupported": "יש לעדכן את שרת AliasVault לגרסה חדשה יותר כדי להשתמש בהרחבת הדפדפן הזאת. נא ליצור קשר עם התמיכה לקבלת עזרה.",
|
||||
"unknownError": "אירעה שגיאה לא ידועה",
|
||||
"failedToStoreVault": "אחסון הכספת נכשל",
|
||||
"vaultNotAvailable": "הכספת לא זמינה",
|
||||
"failedToRetrieveData": "משיכת הנתונים נכשלה",
|
||||
"vaultIsLocked": "הכספת נעולה",
|
||||
@@ -198,6 +199,9 @@
|
||||
"searchPlaceholder": "חיפוש פרטי גישה…",
|
||||
"welcomeTitle": "ברוך בואך ל־AliasVault!",
|
||||
"welcomeDescription": "כדי להשתמש בהרחבת הדפדפן של AliasVault: יש לנווט לאתר ולהשתמש בחלונית ההשלמה האוטומטית של AliasVault כדי ליצור פרטי גישה חדשים.",
|
||||
"noPasskeysFound": "No passkeys have been created yet. Passkeys are created by visiting a website that offers passkeys as an authentication method.",
|
||||
"noAttachmentsFound": "No credentials with attachments found",
|
||||
"noMatchingCredentials": "No matching credentials found",
|
||||
"createdAt": "יצירה",
|
||||
"updatedAt": "עדכון אחרון",
|
||||
"autofill": "השלמה אוטומטית",
|
||||
@@ -213,6 +217,13 @@
|
||||
"saveCredential": "שמירת פרטי גישה",
|
||||
"deleteCredentialTitle": "מחיקת פרטי גישה",
|
||||
"deleteCredentialConfirm": "למחוק את פרטי הגישה? זאת פעולה בלתי הפיכה.",
|
||||
"filters": {
|
||||
"all": "(כל) פרטי הגישה",
|
||||
"passkeys": "Passkeys",
|
||||
"aliases": "כינויים",
|
||||
"userpass": "סיסמאות",
|
||||
"attachments": "Attachments"
|
||||
},
|
||||
"randomAlias": "כינוי אקראי",
|
||||
"manual": "ידני",
|
||||
"service": "שירות",
|
||||
@@ -229,7 +240,7 @@
|
||||
"avoidAmbiguousChars": "עדיף להימנע מאותיות וספרות שדומים זה לזה (o, 0 וכו׳)",
|
||||
"generateNewPreview": "יצירת תצוגה מקדימה חדשה",
|
||||
"generateRandomAlias": "יצירת כינוי אקראי",
|
||||
"clearAliasFields": "Clear Alias Fields",
|
||||
"clearAliasFields": "לפנות שדות כינויים",
|
||||
"alias": "כינוי",
|
||||
"firstName": "שם פרטי",
|
||||
"lastName": "שם משפחה",
|
||||
@@ -345,11 +356,23 @@
|
||||
"autofillSettings": "הגדרות השלמה אוטומטית",
|
||||
"clipboardSettings": "הגדרות לוח הגזירים",
|
||||
"contextMenuSettings": "הגדרות תפריט הקשר",
|
||||
"passkeySettings": "Passkey Settings",
|
||||
"contextMenu": "תפריט הקשר",
|
||||
"contextMenuEnabled": "תפריט הקשר פעיל",
|
||||
"contextMenuDisabled": "תפריט הקשר כבוי",
|
||||
"contextMenuDescription": "ניתן ללחוץ על שדה עם הלחצן הימני כדי לגשת לאפשרויות AliasVault",
|
||||
"selectLanguage": "בחירת שפה",
|
||||
"serverConfiguration": "Server Configuration",
|
||||
"serverConfigurationDescription": "Configure the AliasVault server URL for self-hosted instances",
|
||||
"customApiUrl": "כתובת API",
|
||||
"customClientUrl": "כתובת לקוח",
|
||||
"apiUrlHint": "The API endpoint URL (usually client URL + /api)",
|
||||
"clientUrlHint": "The web interface URL of your self-hosted instance",
|
||||
"autofillSettingsDescription": "Enable or disable the autofill popup on web pages",
|
||||
"autofillEnabledDescription": "Autofill suggestions will appear on login forms",
|
||||
"autofillDisabledDescription": "Autofill suggestions are disabled globally",
|
||||
"languageSettings": "Language",
|
||||
"languageSettingsDescription": "Choose your preferred language",
|
||||
"validation": {
|
||||
"apiUrlRequired": "כתובת API חובה",
|
||||
"apiUrlInvalid": "נא למלא כתובת API תקפה",
|
||||
@@ -357,12 +380,52 @@
|
||||
"clientUrlInvalid": "נא למלא כתובת לקוח תקפה"
|
||||
}
|
||||
},
|
||||
"passkeys": {
|
||||
"passkey": "Passkey",
|
||||
"site": "אתר",
|
||||
"displayName": "שם",
|
||||
"helpText": "Passkeys are created on the website when prompted. They cannot be manually edited. To remove this passkey, you can delete it from this credential. To replace this passkey or create a new one, visit the website and follow its prompts.",
|
||||
"passkeyMarkedForDeletion": "Passkey marked for deletion",
|
||||
"passkeyWillBeDeleted": "This passkey will be deleted when you save this credential.",
|
||||
"bypass": {
|
||||
"title": "Use Browser Passkey",
|
||||
"description": "How long would you like to use the browser's passkey provider for {{origin}}?",
|
||||
"thisTimeOnly": "בפעם הזאת בלבד",
|
||||
"alwaysForSite": "תמיד לאתר הזה"
|
||||
},
|
||||
"authenticate": {
|
||||
"title": "Sign in with Passkey",
|
||||
"signInFor": "Sign in with passkey for",
|
||||
"selectPasskey": "Select a passkey to sign in:",
|
||||
"noPasskeysFound": "No passkeys found for this site",
|
||||
"useBrowserPasskey": "Use Browser Passkey"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Passkey",
|
||||
"createFor": "Create a new passkey for",
|
||||
"titleLabel": "כותרת",
|
||||
"titlePlaceholder": "Enter a name for this passkey",
|
||||
"createButton": "Create Passkey",
|
||||
"creatingButton": "נוצר…",
|
||||
"useBrowserPasskey": "Use Browser Passkey",
|
||||
"selectPasskeyToReplace": "Select a passkey to replace:",
|
||||
"createNewPasskey": "Create New Passkey",
|
||||
"replacingPasskey": "Replacing passkey: {{displayName}}",
|
||||
"confirmReplace": "אישור החלפה"
|
||||
},
|
||||
"settings": {
|
||||
"passkeyProvider": "Passkey Provider",
|
||||
"passkeyProviderOn": "Passkey Provider on ",
|
||||
"enable": "Enable AliasVault as passkey provider",
|
||||
"description": "When enabled, AliasVault will handle passkey requests from websites. When a website requests a passkey, the AliasVault popup will be shown instead of the native browser or OS passkey interface."
|
||||
}
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "שדרוג כספת",
|
||||
"subtitle": "AliasVault התעדכן וצריך לשדרג את הכספת שלך. הפעולה הזאת אמורה לארוך מספר שניות.",
|
||||
"versionInformation": "פרטי גרסה",
|
||||
"yourVault": "הכספת שלך:",
|
||||
"newVersion": "גרסה חדשה:",
|
||||
"yourVault": "גרסת הכספת שלך:",
|
||||
"newVersion": "הגרסה החדשה הזמינה:",
|
||||
"upgrade": "שדרוג כספת",
|
||||
"upgrading": "משתדרגת…",
|
||||
"logout": "יציאה",
|
||||
@@ -386,8 +449,7 @@
|
||||
"cancel": "ביטול",
|
||||
"continueUpgrade": "להמשיך בשדרוג",
|
||||
"upgradeFailed": "השדרוג נכשל",
|
||||
"failedToApplyMigration": "החלת ההסבה נכשלה ({{current}} מתוך {{total}})",
|
||||
"unknownErrorDuringUpgrade": "אירעה שגיאה בלתי ידועה במהלך השדרוג. נא לנסות שוב."
|
||||
"failedToApplyMigration": "החלת ההסבה נכשלה ({{current}} מתוך {{total}})"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
"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."
|
||||
"sessionExpired": "La tua sessione è scaduta. Effettua di nuovo il login."
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
@@ -52,8 +52,10 @@
|
||||
"error": "Errore",
|
||||
"success": "Riuscito",
|
||||
"cancel": "Annulla",
|
||||
"back": "Indietro",
|
||||
"use": "Usa",
|
||||
"delete": "Elimina",
|
||||
"or": "O",
|
||||
"close": "Chiudi",
|
||||
"copied": "Copiato!",
|
||||
"openInNewWindow": "Apri in una nuova finestra",
|
||||
@@ -89,12 +91,11 @@
|
||||
"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.",
|
||||
"browserExtensionOutdated": "Questa estensione del browser è obsoleta e non può essere utilizzata per accedere a questa cassaforte. Si prega di aggiornare questa estensione per continuare.",
|
||||
"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",
|
||||
@@ -198,6 +199,9 @@
|
||||
"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.",
|
||||
"noPasskeysFound": "Non sono state ancora create chiavi di accesso. Le passkey vengono create visitando un sito web che offre le chiavi di accesso come metodo di autenticazione.",
|
||||
"noAttachmentsFound": "No credentials with attachments found",
|
||||
"noMatchingCredentials": "Nessuna credenziale corrispondente trovata",
|
||||
"createdAt": "Creato",
|
||||
"updatedAt": "Ultimo aggiornamento",
|
||||
"autofill": "Compilazione automatica",
|
||||
@@ -213,6 +217,13 @@
|
||||
"saveCredential": "Salva credenziale",
|
||||
"deleteCredentialTitle": "Elimina credenziale",
|
||||
"deleteCredentialConfirm": "Sei sicuro di voler eliminare queste credenziali? Questa azione non può essere annullata.",
|
||||
"filters": {
|
||||
"all": "(Tutte) Credenziali",
|
||||
"passkeys": "Passkey",
|
||||
"aliases": "Alias",
|
||||
"userpass": "Password",
|
||||
"attachments": "Attachments"
|
||||
},
|
||||
"randomAlias": "Alias casuale",
|
||||
"manual": "Manuale",
|
||||
"service": "Servizio",
|
||||
@@ -345,11 +356,23 @@
|
||||
"autofillSettings": "Impostazioni di riempimento automatico",
|
||||
"clipboardSettings": "Impostazioni appunti",
|
||||
"contextMenuSettings": "Preferenze menu contestuale",
|
||||
"passkeySettings": "Impostazioni Passkey",
|
||||
"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",
|
||||
"serverConfiguration": "Server Configuration",
|
||||
"serverConfigurationDescription": "Configure the AliasVault server URL for self-hosted instances",
|
||||
"customApiUrl": "API URL",
|
||||
"customClientUrl": "Client URL",
|
||||
"apiUrlHint": "The API endpoint URL (usually client URL + /api)",
|
||||
"clientUrlHint": "The web interface URL of your self-hosted instance",
|
||||
"autofillSettingsDescription": "Enable or disable the autofill popup on web pages",
|
||||
"autofillEnabledDescription": "Autofill suggestions will appear on login forms",
|
||||
"autofillDisabledDescription": "Autofill suggestions are disabled globally",
|
||||
"languageSettings": "Language",
|
||||
"languageSettingsDescription": "Choose your preferred language",
|
||||
"validation": {
|
||||
"apiUrlRequired": "L'URL API è obbligatorio",
|
||||
"apiUrlInvalid": "Inserisci un URL API valido",
|
||||
@@ -357,12 +380,52 @@
|
||||
"clientUrlInvalid": "Inserisci un URL del client valido"
|
||||
}
|
||||
},
|
||||
"passkeys": {
|
||||
"passkey": "Passkey",
|
||||
"site": "Sito",
|
||||
"displayName": "Nome",
|
||||
"helpText": "Le chiavi di accesso vengono create sul sito web quando richiesto. Non possono essere modificate manualmente. Per rimuovere questa chiave di accesso, è possibile eliminarla da questa credenziale. Per sostituire questa passkey o crearne una nuova, visitare il sito web e seguire i relativi suggerimenti.",
|
||||
"passkeyMarkedForDeletion": "Passkey contrassegnata per l'eliminazione",
|
||||
"passkeyWillBeDeleted": "Questa passkey verrà eliminata quando si salva questa credenziale.",
|
||||
"bypass": {
|
||||
"title": "Usa Browser Passkey",
|
||||
"description": "Per quanto tempo vorresti usare il provider di chiavi di accesso del browser per {{origin}}?",
|
||||
"thisTimeOnly": "Solo questa volta",
|
||||
"alwaysForSite": "Sempre per questo sito"
|
||||
},
|
||||
"authenticate": {
|
||||
"title": "Accedi con Passkey",
|
||||
"signInFor": "Accedi con passkey per",
|
||||
"selectPasskey": "Selezionare una passkey per accedere:",
|
||||
"noPasskeysFound": "Nessuna passkey trovata per questo sito",
|
||||
"useBrowserPasskey": "Usa Browser Passkey"
|
||||
},
|
||||
"create": {
|
||||
"title": "Crea Passkey",
|
||||
"createFor": "Crea una nuova passkey per",
|
||||
"titleLabel": "Titolo",
|
||||
"titlePlaceholder": "Inserisci un nome per questa passkey",
|
||||
"createButton": "Crea Passkey",
|
||||
"creatingButton": "Creazione in corso...",
|
||||
"useBrowserPasskey": "Usa Browser Passkey",
|
||||
"selectPasskeyToReplace": "Selezionare una chiave di accesso da sostituire:",
|
||||
"createNewPasskey": "Crea Nuova Passkey",
|
||||
"replacingPasskey": "Sostituzione passkey: {{displayName}}",
|
||||
"confirmReplace": "Conferma la sostituzione"
|
||||
},
|
||||
"settings": {
|
||||
"passkeyProvider": "Provider Passkey",
|
||||
"passkeyProviderOn": "Passkey Provider attivo",
|
||||
"enable": "Abilita AliasVault come provider di passkey",
|
||||
"description": "Quando abilitato, AliasVault gestirà le richieste di passkey dai siti web. Quando un sito web richiede una passkey, verrà mostrato il popup di AliasVault invece dell'interfaccia nativa del browser o dell'interfaccia di password del sistema operativo."
|
||||
}
|
||||
},
|
||||
"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:",
|
||||
"yourVault": "Versione della tua cassaforte:",
|
||||
"newVersion": "Nuova versione disponibile:",
|
||||
"upgrade": "Aggiorna cassaforte",
|
||||
"upgrading": "Aggiornamento in corso...",
|
||||
"logout": "Disconnetti",
|
||||
@@ -386,8 +449,7 @@
|
||||
"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."
|
||||
"failedToApplyMigration": "Impossibile eseguire la migrazione ({{current}} di {{total}})"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
"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."
|
||||
"sessionExpired": "Je sessie is verlopen. Log opnieuw in."
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
@@ -52,8 +52,10 @@
|
||||
"error": "Fout",
|
||||
"success": "Succes",
|
||||
"cancel": "Annuleren",
|
||||
"back": "Terug",
|
||||
"use": "Gebruik",
|
||||
"delete": "Verwijderen",
|
||||
"or": "Of",
|
||||
"close": "Sluiten",
|
||||
"copied": "Gekopieerd!",
|
||||
"openInNewWindow": "Openen in nieuw venster",
|
||||
@@ -89,12 +91,11 @@
|
||||
"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.",
|
||||
"browserExtensionOutdated": "Deze browserextensie is verouderd en kan niet worden gebruikt om toegang te krijgen tot deze vault. Update deze browserextensie om door te gaan.",
|
||||
"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",
|
||||
@@ -198,6 +199,9 @@
|
||||
"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.",
|
||||
"noPasskeysFound": "Er zijn nog geen passkeys aangemaakt. Passkeys worden gemaakt door een website te bezoeken die passkeys als een authenticatiemethode biedt.",
|
||||
"noAttachmentsFound": "Geen credentials gevonden met bijlagen",
|
||||
"noMatchingCredentials": "Geen credentials gevonden",
|
||||
"createdAt": "Aangemaakt",
|
||||
"updatedAt": "Laatst bijgewerkt",
|
||||
"autofill": "Autofill",
|
||||
@@ -213,6 +217,13 @@
|
||||
"saveCredential": "Credential opslaan",
|
||||
"deleteCredentialTitle": "Credential verwijderen",
|
||||
"deleteCredentialConfirm": "Weet je zeker dat je deze credential wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"filters": {
|
||||
"all": "(Alle) Credentials",
|
||||
"passkeys": "Passkeys",
|
||||
"aliases": "Aliassen",
|
||||
"userpass": "Wachtwoorden",
|
||||
"attachments": "Bijlagen"
|
||||
},
|
||||
"randomAlias": "Alias",
|
||||
"manual": "Handmatig",
|
||||
"service": "Naam",
|
||||
@@ -345,11 +356,23 @@
|
||||
"autofillSettings": "Autofill instellingen",
|
||||
"clipboardSettings": "Klembord instellingen",
|
||||
"contextMenuSettings": "Context menu instellingen",
|
||||
"passkeySettings": "Passkey 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",
|
||||
"serverConfiguration": "Serverconfiguratie",
|
||||
"serverConfigurationDescription": "Configureer de AliasVault server URL voor self-hosted omgevingen",
|
||||
"customApiUrl": "API URL",
|
||||
"customClientUrl": "Client URL",
|
||||
"apiUrlHint": "De API endpoint URL (meestal client URL + /api)",
|
||||
"clientUrlHint": "De webinterface URL van je self-hosted omgeving",
|
||||
"autofillSettingsDescription": "Schakel de autofill pop-up in of uit op webpagina's",
|
||||
"autofillEnabledDescription": "Autofill suggesties verschijnen op login formulieren",
|
||||
"autofillDisabledDescription": "Autofill suggesties zijn uitgeschakeld",
|
||||
"languageSettings": "Taal",
|
||||
"languageSettingsDescription": "Kies je voorkeurstaal",
|
||||
"validation": {
|
||||
"apiUrlRequired": "API URL is vereist",
|
||||
"apiUrlInvalid": "Voer een geldige API URL in",
|
||||
@@ -357,12 +380,52 @@
|
||||
"clientUrlInvalid": "Voer een geldige client URL in"
|
||||
}
|
||||
},
|
||||
"passkeys": {
|
||||
"passkey": "Passkey",
|
||||
"site": "Website",
|
||||
"displayName": "Naam",
|
||||
"helpText": "Passkeys worden aangemaakt op de website wanneer er om wordt gevraagd. Ze kunnen niet handmatig worden bewerkt. Om deze toegangssleutel te verwijderen, kun je deze verwijderen uit deze credential. Om deze passkey te vervangen of een nieuwe te maken, bezoek de website in kwestie en volg de instructies.",
|
||||
"passkeyMarkedForDeletion": "Passkey gemarkeerd om te verwijderen",
|
||||
"passkeyWillBeDeleted": "Deze passkey zal worden verwijderd wanneer je deze credential opslaat.",
|
||||
"bypass": {
|
||||
"title": "Gebruik browser passkey",
|
||||
"description": "Hoe lang wilt je de browser passkey voor {{origin}} gebruiken?",
|
||||
"thisTimeOnly": "Alleen deze keer",
|
||||
"alwaysForSite": "Altijd voor deze site"
|
||||
},
|
||||
"authenticate": {
|
||||
"title": "Inloggen met passkey",
|
||||
"signInFor": "Inloggen met passkey voor",
|
||||
"selectPasskey": "Selecteer een passkey om in te loggen:",
|
||||
"noPasskeysFound": "Geen passkeys gevonden voor deze site",
|
||||
"useBrowserPasskey": "Gebruik browser passkey"
|
||||
},
|
||||
"create": {
|
||||
"title": "Passkey aanmaken",
|
||||
"createFor": "Maak een nieuwe passkey voor",
|
||||
"titleLabel": "Titel",
|
||||
"titlePlaceholder": "Voer een naam in voor deze passkey",
|
||||
"createButton": "Passkey aanmaken",
|
||||
"creatingButton": "Aanmaken...",
|
||||
"useBrowserPasskey": "Gebruik browser passkey",
|
||||
"selectPasskeyToReplace": "Selecteer een passkey om te vervangen:",
|
||||
"createNewPasskey": "Passkey aanmaken",
|
||||
"replacingPasskey": "Passkey vervangen: {{displayName}}",
|
||||
"confirmReplace": "Bevestig vervanging"
|
||||
},
|
||||
"settings": {
|
||||
"passkeyProvider": "Passkey provider",
|
||||
"passkeyProviderOn": "Passkey provider ingeschakeld",
|
||||
"enable": "AliasVault als passkey provider inschakelen",
|
||||
"description": "Wanneer ingeschakeld, behandelt AliasVault passkey verzoeken van websites. Wanneer een website een passkey aanvraagt, wordt de AliasVault pop-up getoond in plaats van de browser of OS pop-up."
|
||||
}
|
||||
},
|
||||
"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:",
|
||||
"yourVault": "Jouw vault versie:",
|
||||
"newVersion": "Nieuwe beschikbare versie:",
|
||||
"upgrade": "Vault upgraden",
|
||||
"upgrading": "Aan het upgraden...",
|
||||
"logout": "Uitloggen",
|
||||
@@ -386,8 +449,7 @@
|
||||
"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."
|
||||
"failedToApplyMigration": "Kon migratie niet toepassen ({{current}} van {{total}})"
|
||||
}
|
||||
}
|
||||
}
|
||||