Compare commits
359 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 | ||
|
|
48414dcae4 | ||
|
|
151548f6f7 | ||
|
|
fd5c8096ad | ||
|
|
09cfee2888 | ||
|
|
74cb2eae7d | ||
|
|
35b8f0abae | ||
|
|
08517e3469 | ||
|
|
f3dabc3a39 | ||
|
|
d98f047963 | ||
|
|
599966996e | ||
|
|
952cfd9a28 | ||
|
|
81a5155734 | ||
|
|
3a953ec7c8 | ||
|
|
392dbd626c | ||
|
|
b6d3f9e70f | ||
|
|
c2f2511f6a | ||
|
|
ce2e21900f | ||
|
|
660b286ee9 | ||
|
|
133037dcd8 | ||
|
|
03b65a63ba | ||
|
|
f7a8189b86 | ||
|
|
38973de6f1 | ||
|
|
9ddd00bfa4 | ||
|
|
88013161d1 | ||
|
|
b0da0d8590 | ||
|
|
7dcfd6bfd1 | ||
|
|
586b0a3495 | ||
|
|
30a009c5c4 | ||
|
|
7d73222ee1 | ||
|
|
6d191a1bd5 | ||
|
|
e5c68c6c6e | ||
|
|
58c39815e4 | ||
|
|
4b706f466f | ||
|
|
19f72b1386 |
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"
|
||||
}
|
||||
}
|
||||
|
||||
2
.vscode/tasks.json
vendored
@@ -199,7 +199,7 @@
|
||||
{
|
||||
"label": "Build and watch Docs",
|
||||
"type": "shell",
|
||||
"command": "docker compose build && docker compose up",
|
||||
"command": "docker compose -f docker-compose.dev.yml build && docker compose -f docker-compose.dev.yml up",
|
||||
"problemMatcher": [],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
|
||||
@@ -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
|
||||
|
||||
17
SECURITY.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
Contact: mailto:security@support.aliasvault.net
|
||||
Expires: 2026-09-16T12:00:00.000Z
|
||||
Preferred-Languages: en
|
||||
Canonical: https://raw.githubusercontent.com/aliasvault/aliasvault/main/SECURITY.txt
|
||||
|
||||
# Security Policy for AliasVault
|
||||
#
|
||||
# We take security seriously and appreciate responsible disclosure of vulnerabilities.
|
||||
# Please report security issues to the email above rather than opening public issues.
|
||||
#
|
||||
# Include the following information in your report:
|
||||
# - Description of the vulnerability
|
||||
# - Steps to reproduce
|
||||
# - Potential impact
|
||||
# - Suggested remediation (if any)
|
||||
#
|
||||
# We will acknowledge receipt within 48 hours and provide updates as we investigate.
|
||||
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.0",
|
||||
"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 = 230000;
|
||||
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.0;
|
||||
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 = 230000;
|
||||
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.0;
|
||||
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 = 230000;
|
||||
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.0;
|
||||
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 = 230000;
|
||||
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.0;
|
||||
MARKETING_VERSION = 0.24.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB 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 { handleOpenPopup, handlePopupWithCredential, handleToggleContextMenu } from '@/entrypoints/background/PopupMessageHandler';
|
||||
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';
|
||||
|
||||
@@ -40,6 +45,7 @@ export default defineBackground({
|
||||
|
||||
onMessage('OPEN_POPUP', () => handleOpenPopup());
|
||||
onMessage('OPEN_POPUP_WITH_CREDENTIAL', ({ data }) => handlePopupWithCredential(data));
|
||||
onMessage('OPEN_POPUP_CREATE_CREDENTIAL', ({ data }) => handleOpenPopupCreateCredential(data));
|
||||
onMessage('TOGGLE_CONTEXT_MENU', ({ data }) => handleToggleContextMenu(data));
|
||||
|
||||
onMessage('PERSIST_FORM_VALUES', ({ data }) => handlePersistFormValues(data));
|
||||
@@ -61,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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { setupContextMenus } from '@/entrypoints/background/ContextMenu';
|
||||
|
||||
import { SKIP_FORM_RESTORE_KEY } from '@/utils/Constants';
|
||||
import { BoolResponse } from '@/utils/types/messaging/BoolResponse';
|
||||
|
||||
import { browser } from '#imports';
|
||||
@@ -37,6 +38,53 @@ export function handlePopupWithCredential(message: any) : Promise<BoolResponse>
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle opening the popup on create credential page with prefilled service name.
|
||||
*/
|
||||
export function handleOpenPopupCreateCredential(message: any) : Promise<BoolResponse> {
|
||||
return (async () : Promise<BoolResponse> => {
|
||||
const serviceName = encodeURIComponent(message.serviceName || '');
|
||||
|
||||
// Use the URL passed from the content script (current page URL)
|
||||
let serviceUrl = '';
|
||||
if (message.currentUrl) {
|
||||
try {
|
||||
const url = new URL(message.currentUrl);
|
||||
// Only include http/https URLs
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
||||
serviceUrl = encodeURIComponent(url.origin + url.pathname);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing current URL:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set a localStorage flag to skip restoring previously persisted form values as we want to start fresh with this explicit create credential request.
|
||||
await browser.storage.local.set({ [SKIP_FORM_RESTORE_KEY]: true });
|
||||
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set('expanded', 'true');
|
||||
if (serviceName) {
|
||||
urlParams.set('serviceName', serviceName);
|
||||
}
|
||||
if (serviceUrl) {
|
||||
urlParams.set('serviceUrl', serviceUrl);
|
||||
}
|
||||
if (message.currentUrl) {
|
||||
urlParams.set('currentUrl', message.currentUrl);
|
||||
}
|
||||
|
||||
browser.windows.create({
|
||||
url: browser.runtime.getURL(`/popup.html?${urlParams.toString()}#/credentials/add`),
|
||||
type: 'popup',
|
||||
width: 400,
|
||||
height: 600,
|
||||
focused: true
|
||||
});
|
||||
return { success: true };
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle toggling the context menu.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import { sendMessage } from 'webext-bridge/content-script';
|
||||
import { filterCredentials, AutofillMatchingMode } from '@/entrypoints/contentScript/Filter';
|
||||
import { fillCredential } from '@/entrypoints/contentScript/Form';
|
||||
|
||||
import { DISABLED_SITES_KEY, TEMPORARY_DISABLED_SITES_KEY, GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, VAULT_LOCKED_DISMISS_UNTIL_KEY, LAST_CUSTOM_EMAIL_KEY, LAST_CUSTOM_USERNAME_KEY, AUTOFILL_MATCHING_MODE_KEY } from '@/utils/Constants';
|
||||
import { DISABLED_SITES_KEY, TEMPORARY_DISABLED_SITES_KEY, GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, VAULT_LOCKED_DISMISS_UNTIL_KEY, AUTOFILL_MATCHING_MODE_KEY, CUSTOM_EMAIL_HISTORY_KEY, CUSTOM_USERNAME_HISTORY_KEY } from '@/utils/Constants';
|
||||
import { CreateIdentityGenerator } from '@/utils/dist/shared/identity-generator';
|
||||
import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
import { CreatePasswordGenerator, PasswordGenerator, PasswordSettings } from '@/utils/dist/shared/password-generator';
|
||||
import { FormDetector } from '@/utils/formDetector/FormDetector';
|
||||
import { ClickValidator } from '@/utils/security/ClickValidator';
|
||||
import { ServiceDetectionUtility } from '@/utils/serviceDetection/ServiceDetectionUtility';
|
||||
import { SqliteClient } from '@/utils/SqliteClient';
|
||||
import { CredentialsResponse } from '@/utils/types/messaging/CredentialsResponse';
|
||||
import { IdentitySettingsResponse } from '@/utils/types/messaging/IdentitySettingsResponse';
|
||||
@@ -227,8 +227,8 @@ export async function createAutofillPopup(input: HTMLInputElement, credentials:
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
const suggestedNames = FormDetector.getSuggestedServiceName(document, window.location);
|
||||
const result = await createAliasCreationPopup(suggestedNames, rootContainer);
|
||||
const serviceInfo = ServiceDetectionUtility.getServiceInfo(document, window.location);
|
||||
const result = await createAliasCreationPopup(serviceInfo.suggestedNames, rootContainer);
|
||||
|
||||
if (!result) {
|
||||
// User cancelled
|
||||
@@ -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');
|
||||
@@ -762,9 +796,9 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
|
||||
// Close existing popup
|
||||
removeExistingPopup(rootContainer);
|
||||
|
||||
// Load last used values
|
||||
const lastEmail = await storage.getItem(LAST_CUSTOM_EMAIL_KEY) as string ?? '';
|
||||
const lastUsername = await storage.getItem(LAST_CUSTOM_USERNAME_KEY) as string ?? '';
|
||||
// Load history
|
||||
const emailHistory = await storage.getItem(CUSTOM_EMAIL_HISTORY_KEY) as string[] ?? [];
|
||||
const usernameHistory = await storage.getItem(CUSTOM_USERNAME_HISTORY_KEY) as string[] ?? [];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
(async (): Promise<void> => {
|
||||
@@ -829,11 +863,20 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
|
||||
${randomIdentityIcon}
|
||||
<h3 class="av-create-popup-title">${randomIdentityTitle}</h3>
|
||||
</div>
|
||||
<button class="av-create-popup-mode-dropdown">
|
||||
<svg class="av-icon" viewBox="0 0 24 24">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="av-create-popup-header-buttons">
|
||||
<button class="av-create-popup-mode-dropdown">
|
||||
<svg class="av-icon" viewBox="0 0 24 24">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="av-create-popup-popout" title="Open in main popup">
|
||||
<svg class="av-icon" viewBox="0 0 24 24">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -888,8 +931,8 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
|
||||
id="custom-email"
|
||||
class="av-create-popup-input"
|
||||
placeholder="${enterEmailAddressText}"
|
||||
data-default-value="${lastEmail}"
|
||||
>
|
||||
<div class="av-field-suggestions" id="email-suggestions"></div>
|
||||
</div>
|
||||
<div class="av-create-popup-field-group">
|
||||
<label for="custom-username">${usernameText}</label>
|
||||
@@ -898,8 +941,8 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
|
||||
id="custom-username"
|
||||
class="av-create-popup-input"
|
||||
placeholder="${enterUsernameText}"
|
||||
data-default-value="${lastUsername}"
|
||||
>
|
||||
<div class="av-field-suggestions" id="username-suggestions"></div>
|
||||
</div>
|
||||
<div class="av-create-popup-field-group">
|
||||
<label>${passwordText}</label>
|
||||
@@ -960,6 +1003,7 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
|
||||
const customMode = popup.querySelector('.av-create-popup-custom-mode') as HTMLElement;
|
||||
const dropdownMenu = popup.querySelector('.av-create-popup-mode-dropdown-menu') as HTMLElement;
|
||||
const titleContainer = popup.querySelector('.av-create-popup-title-container') as HTMLElement;
|
||||
const popoutBtn = popup.querySelector('.av-create-popup-popout') as HTMLButtonElement;
|
||||
const cancelBtn = popup.querySelector('#cancel-btn') as HTMLButtonElement;
|
||||
const customCancelBtn = popup.querySelector('#custom-cancel-btn') as HTMLButtonElement;
|
||||
const saveBtn = popup.querySelector('#save-btn') as HTMLButtonElement;
|
||||
@@ -970,41 +1014,154 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
|
||||
const passwordPreview = popup.querySelector('#password-preview') as HTMLInputElement;
|
||||
const regenerateBtn = popup.querySelector('#regenerate-password') as HTMLButtonElement;
|
||||
const toggleVisibilityBtn = popup.querySelector('#toggle-password-visibility') as HTMLButtonElement;
|
||||
const emailSuggestions = popup.querySelector('#email-suggestions') as HTMLElement;
|
||||
const usernameSuggestions = popup.querySelector('#username-suggestions') as HTMLElement;
|
||||
|
||||
/**
|
||||
* Setup default value for input with placeholder styling.
|
||||
* Update history with new value (max 2 unique entries)
|
||||
*/
|
||||
const setupDefaultValue = (input: HTMLInputElement) : void => {
|
||||
const defaultValue = input.dataset.defaultValue;
|
||||
if (defaultValue) {
|
||||
input.value = defaultValue;
|
||||
input.classList.add('av-create-popup-input-default');
|
||||
const updateHistory = async (value: string, historyKey: typeof CUSTOM_EMAIL_HISTORY_KEY | typeof CUSTOM_USERNAME_HISTORY_KEY, maxItems: number = 2): Promise<string[]> => {
|
||||
const history = await storage.getItem(historyKey) as string[] ?? [];
|
||||
|
||||
// Remove the value if it already exists
|
||||
const filteredHistory = history.filter((item: string) => item !== value);
|
||||
|
||||
// Add the new value at the beginning
|
||||
if (value.trim()) {
|
||||
filteredHistory.unshift(value);
|
||||
}
|
||||
|
||||
// Keep only the first maxItems
|
||||
const updatedHistory = filteredHistory.slice(0, maxItems);
|
||||
|
||||
// Save the updated history
|
||||
await storage.setItem(historyKey, updatedHistory);
|
||||
|
||||
return updatedHistory;
|
||||
};
|
||||
|
||||
setupDefaultValue(customEmail);
|
||||
setupDefaultValue(customUsername);
|
||||
/**
|
||||
* Remove item from history
|
||||
*/
|
||||
const removeFromHistory = async (value: string, historyKey: typeof CUSTOM_EMAIL_HISTORY_KEY | typeof CUSTOM_USERNAME_HISTORY_KEY): Promise<string[]> => {
|
||||
const history = await storage.getItem(historyKey) as string[] ?? [];
|
||||
const updatedHistory = history.filter((item: string) => item !== value);
|
||||
await storage.setItem(historyKey, updatedHistory);
|
||||
return updatedHistory;
|
||||
};
|
||||
|
||||
// Handle input changes
|
||||
customEmail.addEventListener('input', () => {
|
||||
const value = customEmail.value.trim();
|
||||
if (value || value === '') {
|
||||
customEmail.classList.remove('av-create-popup-input-default');
|
||||
storage.setItem(LAST_CUSTOM_EMAIL_KEY, value);
|
||||
/**
|
||||
* Format suggestions HTML as pill-style buttons
|
||||
*/
|
||||
const formatSuggestionsHtml = async (history: string[], currentValue: string): Promise<string> => {
|
||||
// Filter out the current value from history and limit to 2 items
|
||||
const filteredHistory = history
|
||||
.filter(item => item.toLowerCase() !== currentValue.toLowerCase())
|
||||
.slice(0, 2);
|
||||
|
||||
if (filteredHistory.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Build HTML with pill-style buttons
|
||||
return filteredHistory.map(item =>
|
||||
`<span class="av-suggestion-pill">
|
||||
<span class="av-suggestion-pill-text" data-value="${item}">${item}</span>
|
||||
<span class="av-suggestion-pill-delete" data-value="${item}" title="Remove">×</span>
|
||||
</span>`
|
||||
).join(' ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Update suggestions display
|
||||
*/
|
||||
const updateSuggestions = async (input: HTMLInputElement, suggestionsContainer: HTMLElement, history: string[]): Promise<void> => {
|
||||
const currentValue = input.value.trim();
|
||||
const html = await formatSuggestionsHtml(history, currentValue);
|
||||
suggestionsContainer.innerHTML = html;
|
||||
suggestionsContainer.style.display = html ? 'flex' : 'none';
|
||||
};
|
||||
|
||||
// Initial display of suggestions
|
||||
await updateSuggestions(customEmail, emailSuggestions, emailHistory);
|
||||
await updateSuggestions(customUsername, usernameSuggestions, usernameHistory);
|
||||
|
||||
// Handle popout button click
|
||||
popoutBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const serviceName = inputServiceName.value.trim();
|
||||
const encodedServiceInfo = ServiceDetectionUtility.getEncodedServiceInfo(document, window.location);
|
||||
sendMessage('OPEN_POPUP_CREATE_CREDENTIAL', {
|
||||
serviceName: serviceName || encodedServiceInfo.serviceName,
|
||||
currentUrl: encodedServiceInfo.currentUrl
|
||||
}, 'background');
|
||||
closePopup(null);
|
||||
});
|
||||
|
||||
// Handle email input
|
||||
customEmail.addEventListener('input', async () => {
|
||||
await updateSuggestions(customEmail, emailSuggestions, emailHistory);
|
||||
});
|
||||
|
||||
// Handle username input
|
||||
customUsername.addEventListener('input', async () => {
|
||||
await updateSuggestions(customUsername, usernameSuggestions, usernameHistory);
|
||||
});
|
||||
|
||||
// Handle suggestion clicks for email
|
||||
emailSuggestions.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Check if delete button was clicked
|
||||
if (target.classList.contains('av-suggestion-pill-delete')) {
|
||||
const value = target.dataset.value;
|
||||
if (value) {
|
||||
const updatedHistory = await removeFromHistory(value, CUSTOM_EMAIL_HISTORY_KEY);
|
||||
emailHistory.splice(0, emailHistory.length, ...updatedHistory);
|
||||
await updateSuggestions(customEmail, emailSuggestions, emailHistory);
|
||||
}
|
||||
} else {
|
||||
customEmail.classList.add('av-create-popup-input-default');
|
||||
storage.setItem(LAST_CUSTOM_EMAIL_KEY, '');
|
||||
// Check if pill or pill text was clicked
|
||||
let pillElement = target.closest('.av-suggestion-pill') as HTMLElement;
|
||||
if (pillElement) {
|
||||
const textElement = pillElement.querySelector('.av-suggestion-pill-text') as HTMLElement;
|
||||
const value = textElement?.dataset.value;
|
||||
if (value) {
|
||||
customEmail.value = value;
|
||||
await updateSuggestions(customEmail, emailSuggestions, emailHistory);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
customUsername.addEventListener('input', () => {
|
||||
const value = customUsername.value.trim();
|
||||
if (value || value === '') {
|
||||
customUsername.classList.remove('av-create-popup-input-default');
|
||||
storage.setItem(LAST_CUSTOM_USERNAME_KEY, value);
|
||||
// Handle suggestion clicks for username
|
||||
usernameSuggestions.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Check if delete button was clicked
|
||||
if (target.classList.contains('av-suggestion-pill-delete')) {
|
||||
const value = target.dataset.value;
|
||||
if (value) {
|
||||
const updatedHistory = await removeFromHistory(value, CUSTOM_USERNAME_HISTORY_KEY);
|
||||
usernameHistory.splice(0, usernameHistory.length, ...updatedHistory);
|
||||
await updateSuggestions(customUsername, usernameSuggestions, usernameHistory);
|
||||
}
|
||||
} else {
|
||||
customUsername.classList.add('av-create-popup-input-default');
|
||||
storage.setItem(LAST_CUSTOM_USERNAME_KEY, '');
|
||||
// Check if pill or pill text was clicked
|
||||
let pillElement = target.closest('.av-suggestion-pill') as HTMLElement;
|
||||
if (pillElement) {
|
||||
const textElement = pillElement.querySelector('.av-suggestion-pill-text') as HTMLElement;
|
||||
const value = textElement?.dataset.value;
|
||||
if (value) {
|
||||
customUsername.value = value;
|
||||
await updateSuggestions(customUsername, usernameSuggestions, usernameHistory);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1372,12 +1529,8 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
|
||||
if (serviceName) {
|
||||
const email = customEmail.value.trim();
|
||||
const username = customUsername.value.trim();
|
||||
const hasDefaultEmail = customEmail.classList.contains('av-create-popup-input-default');
|
||||
const hasDefaultUsername = customUsername.classList.contains('av-create-popup-input-default');
|
||||
|
||||
// If using default values, use the dataset values
|
||||
const finalEmail = hasDefaultEmail ? customEmail.dataset.defaultValue : email;
|
||||
const finalUsername = hasDefaultUsername ? customUsername.dataset.defaultValue : username;
|
||||
const finalEmail = email;
|
||||
const finalUsername = username;
|
||||
|
||||
if (!finalEmail && !finalUsername) {
|
||||
// Add error styling to fields
|
||||
@@ -1424,6 +1577,14 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
|
||||
return;
|
||||
}
|
||||
|
||||
// Update history when saving
|
||||
if (finalEmail) {
|
||||
await updateHistory(finalEmail, CUSTOM_EMAIL_HISTORY_KEY);
|
||||
}
|
||||
if (finalUsername) {
|
||||
await updateHistory(finalUsername, CUSTOM_USERNAME_HISTORY_KEY);
|
||||
}
|
||||
|
||||
closePopup({
|
||||
serviceName,
|
||||
isCustomCredential: true,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -539,6 +539,62 @@ body {
|
||||
box-shadow: 0 0 0 1px #ef4444 !important;
|
||||
}
|
||||
|
||||
/* Field Suggestions - Pill Style */
|
||||
.av-field-suggestions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.av-suggestion-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #4b5563;
|
||||
border: 1px solid #6b7280;
|
||||
border-radius: 16px;
|
||||
padding: 4px 8px 4px 12px;
|
||||
font-size: 13px;
|
||||
color: #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.av-suggestion-pill:hover {
|
||||
background: #6b7280;
|
||||
border-color: #9ca3af;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.av-suggestion-pill-text {
|
||||
display: inline-block;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.av-suggestion-pill-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 6px;
|
||||
padding: 0 2px;
|
||||
color: #9ca3af;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
border-left: 1px solid #6b7280;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.av-suggestion-pill-delete:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.av-create-popup-error-text {
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
@@ -728,28 +784,41 @@ body {
|
||||
.av-create-popup-title-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.av-create-popup-title-wrapper {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #d68338;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.av-create-popup-header-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
||||
.av-create-popup-title-wrapper .av-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.av-create-popup-title-wrapper .av-create-popup-title {
|
||||
@@ -757,6 +826,7 @@ body {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #f8f9fa;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.av-create-popup-title-container:hover {
|
||||
@@ -785,6 +855,34 @@ body {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.av-create-popup-popout {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.av-create-popup-popout:hover {
|
||||
background-color: #4b5563;
|
||||
color: #d68338;
|
||||
}
|
||||
|
||||
.av-create-popup-popout .av-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.av-create-popup-mode-dropdown-menu {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -90,7 +90,7 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
|
||||
setIsCustomDomain(!isKnownDomain);
|
||||
} else {
|
||||
setLocalPart(value);
|
||||
setIsCustomDomain(false);
|
||||
// Don't reset isCustomDomain here - preserve the current mode
|
||||
|
||||
// Set default domain if not already set
|
||||
if (!selectedDomain && !value.includes('@')) {
|
||||
@@ -101,12 +101,20 @@ 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>) => {
|
||||
const newLocalPart = e.target.value;
|
||||
|
||||
// If in custom domain mode, always pass through the full value
|
||||
if (isCustomDomain) {
|
||||
onChange(newLocalPart);
|
||||
// Stay in custom domain mode - don't auto-switch back
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if new value contains '@' symbol, if so, switch to custom domain mode
|
||||
if (newLocalPart.includes('@')) {
|
||||
setIsCustomDomain(true);
|
||||
@@ -115,10 +123,11 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
|
||||
}
|
||||
|
||||
setLocalPart(newLocalPart);
|
||||
if (!isCustomDomain && selectedDomain) {
|
||||
// If the local part is empty, treat the whole field as empty
|
||||
if (!newLocalPart || newLocalPart.trim() === '') {
|
||||
onChange('');
|
||||
} else if (selectedDomain) {
|
||||
onChange(`${newLocalPart}@${selectedDomain}`);
|
||||
} else {
|
||||
onChange(newLocalPart);
|
||||
}
|
||||
}, [isCustomDomain, selectedDomain, onChange]);
|
||||
|
||||
@@ -126,7 +135,12 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
|
||||
const selectDomain = useCallback((domain: string) => {
|
||||
setSelectedDomain(domain);
|
||||
const cleanLocalPart = localPart.includes('@') ? localPart.split('@')[0] : localPart;
|
||||
onChange(`${cleanLocalPart}@${domain}`);
|
||||
// If the local part is empty, treat the whole field as empty
|
||||
if (!cleanLocalPart || cleanLocalPart.trim() === '') {
|
||||
onChange('');
|
||||
} else {
|
||||
onChange(`${cleanLocalPart}@${domain}`);
|
||||
}
|
||||
setIsCustomDomain(false);
|
||||
setIsPopupVisible(false);
|
||||
}, [localPart, onChange]);
|
||||
@@ -136,13 +150,30 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
|
||||
const newIsCustom = !isCustomDomain;
|
||||
setIsCustomDomain(newIsCustom);
|
||||
|
||||
if (!newIsCustom && !value.includes('@')) {
|
||||
// Switching to domain chooser mode, add default domain
|
||||
if (newIsCustom) {
|
||||
/*
|
||||
* Switching to custom domain mode
|
||||
* If we have a domain-based value, extract just the local part
|
||||
*/
|
||||
if (value && value.includes('@')) {
|
||||
const [local] = value.split('@');
|
||||
onChange(local);
|
||||
setLocalPart(local);
|
||||
}
|
||||
} else {
|
||||
// Switching to domain chooser mode
|
||||
const defaultDomain = showPrivateDomains && privateEmailDomains[0]
|
||||
? privateEmailDomains[0]
|
||||
: PUBLIC_EMAIL_DOMAINS[0];
|
||||
onChange(`${localPart}@${defaultDomain}`);
|
||||
setSelectedDomain(defaultDomain);
|
||||
|
||||
// Only add domain if we have a local part
|
||||
if (localPart && localPart.trim()) {
|
||||
onChange(`${localPart}@${defaultDomain}`);
|
||||
} else if (value && !value.includes('@')) {
|
||||
// If we have a value without @, add the domain
|
||||
onChange(`${value}@${defaultDomain}`);
|
||||
}
|
||||
}
|
||||
}, [isCustomDomain, value, localPart, showPrivateDomains, privateEmailDomains, onChange]);
|
||||
|
||||
@@ -1,11 +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;
|
||||
@@ -15,7 +16,6 @@ interface IPasswordFieldProps {
|
||||
error?: string;
|
||||
showPassword?: boolean;
|
||||
onShowPasswordChange?: (show: boolean) => void;
|
||||
initialSettings: PasswordSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,13 +29,14 @@ const PasswordField: React.FC<IPasswordFieldProps> = ({
|
||||
placeholder,
|
||||
error,
|
||||
showPassword: controlledShowPassword,
|
||||
onShowPasswordChange,
|
||||
initialSettings
|
||||
onShowPasswordChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const dbContext = useDb();
|
||||
const [internalShowPassword, setInternalShowPassword] = useState(false);
|
||||
const [showConfigDialog, setShowConfigDialog] = useState(false);
|
||||
const [currentSettings, setCurrentSettings] = useState<PasswordSettings>(initialSettings);
|
||||
const [currentSettings, setCurrentSettings] = useState<PasswordSettings | null>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
// Use controlled or uncontrolled showPassword state
|
||||
const showPassword = controlledShowPassword !== undefined ? controlledShowPassword : internalShowPassword;
|
||||
@@ -51,11 +52,24 @@ const PasswordField: React.FC<IPasswordFieldProps> = ({
|
||||
}
|
||||
}, [controlledShowPassword, onShowPasswordChange]);
|
||||
|
||||
// Initialize settings only once when component mounts
|
||||
// Load password settings from database
|
||||
useEffect(() => {
|
||||
setCurrentSettings({ ...initialSettings });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Only run on mount to avoid resetting user changes
|
||||
/**
|
||||
* Load password settings from the database.
|
||||
*/
|
||||
const loadSettings = async (): Promise<void> => {
|
||||
try {
|
||||
if (dbContext.sqliteClient) {
|
||||
const settings = dbContext.sqliteClient.getPasswordSettings();
|
||||
setCurrentSettings(settings);
|
||||
setIsLoaded(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading password settings:', error);
|
||||
}
|
||||
};
|
||||
void loadSettings();
|
||||
}, [dbContext.sqliteClient]);
|
||||
|
||||
const generatePassword = useCallback((settings: PasswordSettings) => {
|
||||
try {
|
||||
@@ -69,6 +83,9 @@ const PasswordField: React.FC<IPasswordFieldProps> = ({
|
||||
}, [onChange, setShowPassword]);
|
||||
|
||||
const handleLengthChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!currentSettings) {
|
||||
return;
|
||||
}
|
||||
const length = parseInt(e.target.value, 10);
|
||||
const newSettings = { ...currentSettings, Length: length };
|
||||
setCurrentSettings(newSettings);
|
||||
@@ -78,6 +95,9 @@ const PasswordField: React.FC<IPasswordFieldProps> = ({
|
||||
}, [currentSettings, generatePassword]);
|
||||
|
||||
const handleRegeneratePassword = useCallback(() => {
|
||||
if (!currentSettings) {
|
||||
return;
|
||||
}
|
||||
generatePassword(currentSettings);
|
||||
}, [generatePassword, currentSettings]);
|
||||
|
||||
@@ -98,6 +118,18 @@ const PasswordField: React.FC<IPasswordFieldProps> = ({
|
||||
setShowConfigDialog(true);
|
||||
}, []);
|
||||
|
||||
// Don't render until settings are loaded
|
||||
if (!currentSettings || !isLoaded) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={id} className="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
{label}
|
||||
</label>
|
||||
<div className="animate-pulse bg-gray-200 dark:bg-gray-700 h-10 rounded-lg"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Label */}
|
||||
@@ -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,24 +8,28 @@ 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';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
|
||||
|
||||
import { SKIP_FORM_RESTORE_KEY } from '@/utils/Constants';
|
||||
import { IdentityHelperUtils, CreateIdentityGenerator, CreateUsernameEmailGenerator, Identity, Gender } from '@/utils/dist/shared/identity-generator';
|
||||
import type { Attachment, Credential } from '@/utils/dist/shared/models/vault';
|
||||
import { CreatePasswordGenerator } from '@/utils/dist/shared/password-generator';
|
||||
import { ServiceDetectionUtility } from '@/utils/serviceDetection/ServiceDetectionUtility';
|
||||
|
||||
import { browser } from '#imports';
|
||||
|
||||
type CredentialMode = 'random' | 'manual';
|
||||
|
||||
@@ -88,8 +92,16 @@ 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
|
||||
const [lastGeneratedValues, setLastGeneratedValues] = useState<{
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
email: string | null;
|
||||
}>({ username: null, password: null, email: null });
|
||||
|
||||
const serviceNameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { handleSubmit, setValue, watch, formState: { errors } } = useForm<Credential>({
|
||||
@@ -223,20 +235,80 @@ const CredentialAddEdit: React.FC = () => {
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
// On create mode, focus the service name field after a short delay to ensure the component is mounted.
|
||||
// On create mode, check for URL parameters first, then fallback to tab detection
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const serviceName = urlParams.get('serviceName');
|
||||
const serviceUrl = urlParams.get('serviceUrl');
|
||||
const currentUrl = urlParams.get('currentUrl');
|
||||
|
||||
/**
|
||||
* Initialize service detection from URL parameters or current tab
|
||||
*/
|
||||
const initializeServiceDetection = async (): Promise<void> => {
|
||||
try {
|
||||
// If URL parameters are present (e.g., from content script popout), use them
|
||||
if (serviceName || serviceUrl || currentUrl) {
|
||||
if (serviceName) {
|
||||
setValue('ServiceName', decodeURIComponent(serviceName));
|
||||
}
|
||||
if (serviceUrl) {
|
||||
setValue('ServiceUrl', decodeURIComponent(serviceUrl));
|
||||
}
|
||||
|
||||
// If we have currentUrl but missing serviceName or serviceUrl, derive them
|
||||
if (currentUrl && (!serviceName || !serviceUrl)) {
|
||||
const decodedCurrentUrl = decodeURIComponent(currentUrl);
|
||||
const serviceInfo = ServiceDetectionUtility.getServiceInfoFromTab(decodedCurrentUrl);
|
||||
|
||||
if (!serviceName && serviceInfo.suggestedNames.length > 0) {
|
||||
setValue('ServiceName', serviceInfo.suggestedNames[0]);
|
||||
}
|
||||
if (!serviceUrl && serviceInfo.serviceUrl) {
|
||||
setValue('ServiceUrl', serviceInfo.serviceUrl);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, detect from current active tab (for dashboard case)
|
||||
const [activeTab] = await browser.tabs.query({ active: true, currentWindow: true });
|
||||
|
||||
if (activeTab?.url) {
|
||||
const serviceInfo = ServiceDetectionUtility.getServiceInfoFromTab(
|
||||
activeTab.url,
|
||||
activeTab.title
|
||||
);
|
||||
|
||||
if (serviceInfo.suggestedNames.length > 0) {
|
||||
setValue('ServiceName', serviceInfo.suggestedNames[0]);
|
||||
}
|
||||
if (serviceInfo.serviceUrl) {
|
||||
setValue('ServiceUrl', serviceInfo.serviceUrl);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error detecting service information:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeServiceDetection();
|
||||
|
||||
// Focus the service name field after a short delay to ensure the component is mounted.
|
||||
setTimeout(() => {
|
||||
serviceNameRef.current?.focus();
|
||||
}, 100);
|
||||
setIsInitialLoading(false);
|
||||
|
||||
// Load persisted form values if they exist.
|
||||
loadPersistedValues().then(() => {
|
||||
// Generate default password if no persisted password exists
|
||||
if (!watch('Password')) {
|
||||
const passwordSettings = dbContext.sqliteClient!.getPasswordSettings();
|
||||
const passwordGenerator = CreatePasswordGenerator(passwordSettings);
|
||||
const defaultPassword = passwordGenerator.generateRandomPassword();
|
||||
setValue('Password', defaultPassword);
|
||||
// Check if we should skip form restoration (e.g., when opened from popout button)
|
||||
browser.storage.local.get([SKIP_FORM_RESTORE_KEY]).then((result) => {
|
||||
if (result[SKIP_FORM_RESTORE_KEY]) {
|
||||
// Clear the flag after using it
|
||||
browser.storage.local.remove([SKIP_FORM_RESTORE_KEY]);
|
||||
// Don't load persisted values, but set local loading to false
|
||||
setLocalLoading(false);
|
||||
} else {
|
||||
// Load persisted form values normally
|
||||
loadPersistedValues();
|
||||
}
|
||||
});
|
||||
return;
|
||||
@@ -271,7 +343,7 @@ const CredentialAddEdit: React.FC = () => {
|
||||
console.error('Error loading credential:', err);
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
}, [dbContext.sqliteClient, id, navigate, setIsInitialLoading, setValue, loadPersistedValues, watch]);
|
||||
}, [dbContext.sqliteClient, id, navigate, setIsInitialLoading, setValue, loadPersistedValues, clearPersistedValues]);
|
||||
|
||||
/**
|
||||
* Handle the delete button click.
|
||||
@@ -331,35 +403,63 @@ const CredentialAddEdit: React.FC = () => {
|
||||
const defaultEmailDomain = dbContext.sqliteClient!.getDefaultEmailDomain(privateEmailDomains, publicEmailDomains);
|
||||
const email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix;
|
||||
|
||||
setValue('Alias.Email', email);
|
||||
// Check current values
|
||||
const currentUsername = watch('Username') ?? '';
|
||||
const currentPassword = watch('Password') ?? '';
|
||||
const currentEmail = watch('Alias.Email') ?? '';
|
||||
|
||||
// Only overwrite email if it's empty or matches the last generated value
|
||||
if (!currentEmail || currentEmail === lastGeneratedValues.email) {
|
||||
setValue('Alias.Email', email);
|
||||
}
|
||||
setValue('Alias.FirstName', identity.firstName);
|
||||
setValue('Alias.LastName', identity.lastName);
|
||||
setValue('Alias.NickName', identity.nickName);
|
||||
setValue('Alias.Gender', identity.gender);
|
||||
setValue('Alias.BirthDate', IdentityHelperUtils.normalizeBirthDateForDisplay(identity.birthDate.toISOString()));
|
||||
|
||||
// In edit mode, preserve existing username and password if they exist
|
||||
if (isEditMode && watch('Username')) {
|
||||
// Keep the existing username in edit mode, so don't do anything here.
|
||||
} else {
|
||||
// Use the newly generated username
|
||||
// Only overwrite username if it's empty or matches the last generated value
|
||||
if (!currentUsername || currentUsername === lastGeneratedValues.username) {
|
||||
setValue('Username', identity.nickName);
|
||||
}
|
||||
|
||||
if (isEditMode && watch('Password')) {
|
||||
// Keep the existing password in edit mode, so don't do anything here.
|
||||
} else {
|
||||
// Use the newly generated password
|
||||
// Only overwrite password if it's empty or matches the last generated value
|
||||
if (!currentPassword || currentPassword === lastGeneratedValues.password) {
|
||||
setValue('Password', password);
|
||||
}
|
||||
}, [isEditMode, watch, setValue, initializeGenerators, dbContext]);
|
||||
|
||||
// Update tracking with new generated values
|
||||
setLastGeneratedValues({
|
||||
username: identity.nickName,
|
||||
password: password,
|
||||
email: email
|
||||
});
|
||||
}, [watch, setValue, initializeGenerators, dbContext, lastGeneratedValues, setLastGeneratedValues]);
|
||||
|
||||
/**
|
||||
* Clear all alias fields.
|
||||
*/
|
||||
const clearAliasFields = useCallback(() => {
|
||||
setValue('Alias.FirstName', '');
|
||||
setValue('Alias.LastName', '');
|
||||
setValue('Alias.NickName', '');
|
||||
setValue('Alias.Gender', '');
|
||||
setValue('Alias.BirthDate', '');
|
||||
}, [setValue]);
|
||||
|
||||
// Check if any alias fields have values.
|
||||
const hasAliasValues = !!(watch('Alias.FirstName') || watch('Alias.LastName') || watch('Alias.NickName') || watch('Alias.Gender') || watch('Alias.BirthDate'));
|
||||
|
||||
/**
|
||||
* Handle the generate random alias button press.
|
||||
*/
|
||||
const handleGenerateRandomAlias = useCallback(() => {
|
||||
void generateRandomAlias();
|
||||
}, [generateRandomAlias]);
|
||||
if (hasAliasValues) {
|
||||
clearAliasFields();
|
||||
} else {
|
||||
void generateRandomAlias();
|
||||
}
|
||||
}, [generateRandomAlias, clearAliasFields, hasAliasValues]);
|
||||
|
||||
const generateRandomUsername = useCallback(async () => {
|
||||
try {
|
||||
@@ -382,15 +482,17 @@ const CredentialAddEdit: React.FC = () => {
|
||||
};
|
||||
|
||||
const username = usernameEmailGenerator.generateUsername(identity);
|
||||
setValue('Username', username);
|
||||
const currentUsername = watch('Username') ?? '';
|
||||
// Only overwrite username if it's empty or matches the last generated value
|
||||
if (!currentUsername || currentUsername === lastGeneratedValues.username) {
|
||||
setValue('Username', username);
|
||||
// Update the tracking for username
|
||||
setLastGeneratedValues(prev => ({ ...prev, username: username }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating random username:', error);
|
||||
}
|
||||
}, [setValue, watch]);
|
||||
|
||||
const initialPasswordSettings = useMemo(() => {
|
||||
return dbContext.sqliteClient?.getPasswordSettings();
|
||||
}, [dbContext.sqliteClient]);
|
||||
}, [setValue, watch, lastGeneratedValues, setLastGeneratedValues]);
|
||||
|
||||
/**
|
||||
* Handle form submission.
|
||||
@@ -449,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();
|
||||
@@ -469,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) => {
|
||||
@@ -594,32 +701,166 @@ 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}
|
||||
/>
|
||||
{initialPasswordSettings && (
|
||||
<PasswordField
|
||||
id="password"
|
||||
label={t('common.password')}
|
||||
value={watch('Password') ?? ''}
|
||||
onChange={(value) => setValue('Password', value)}
|
||||
error={errors.Password?.message}
|
||||
showPassword={showPassword}
|
||||
onShowPasswordChange={setShowPassword}
|
||||
initialSettings={initialPasswordSettings}
|
||||
/>
|
||||
{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>
|
||||
@@ -630,17 +871,33 @@ const CredentialAddEdit: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerateRandomAlias}
|
||||
className="w-full text-sm bg-primary-500 text-white py-2 px-4 rounded hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 flex items-center justify-center gap-2"
|
||||
className={`w-full text-sm py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-offset-2 flex items-center justify-center gap-2 ${
|
||||
hasAliasValues
|
||||
? 'bg-gray-500 text-white hover:bg-gray-600 focus:ring-gray-500'
|
||||
: 'bg-primary-500 text-white hover:bg-primary-600 focus:ring-primary-500'
|
||||
}`}
|
||||
>
|
||||
<svg className='w-5 h-5 inline-block' viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8" cy="8" r="1"/>
|
||||
<circle cx="16" cy="8" r="1"/>
|
||||
<circle cx="12" cy="12" r="1"/>
|
||||
<circle cx="8" cy="16" r="1"/>
|
||||
<circle cx="16" cy="16" r="1"/>
|
||||
</svg>
|
||||
<span>{t('credentials.generateRandomAlias')}</span>
|
||||
{hasAliasValues ? (
|
||||
<>
|
||||
<svg className='w-5 h-5 inline-block' viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
<span>{t('credentials.clearAliasFields')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className='w-5 h-5 inline-block' viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8" cy="8" r="1"/>
|
||||
<circle cx="16" cy="8" r="1"/>
|
||||
<circle cx="12" cy="12" r="1"/>
|
||||
<circle cx="8" cy="16" r="1"/>
|
||||
<circle cx="16" cy="16" r="1"/>
|
||||
</svg>
|
||||
<span>{t('credentials.generateRandomAlias')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<FormInput
|
||||
id="firstName"
|
||||
|
||||
@@ -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();
|
||||
@@ -6,8 +6,13 @@
|
||||
import deTranslations from './locales/de.json';
|
||||
import enTranslations from './locales/en.json';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -24,12 +29,27 @@ export const LANGUAGE_RESOURCES = {
|
||||
fi: {
|
||||
translation: fiTranslations
|
||||
},
|
||||
he: {
|
||||
translation: heTranslations
|
||||
},
|
||||
it: {
|
||||
translation: itTranslations
|
||||
},
|
||||
nl: {
|
||||
translation: nlTranslations
|
||||
},
|
||||
pl: {
|
||||
translation: plTranslations
|
||||
},
|
||||
pt: {
|
||||
translation: ptTranslations
|
||||
},
|
||||
ru: {
|
||||
translation: ruTranslations
|
||||
},
|
||||
uk: {
|
||||
translation: ukTranslations
|
||||
},
|
||||
zh: {
|
||||
translation: zhTranslations
|
||||
},
|
||||
@@ -58,6 +78,12 @@ export const AVAILABLE_LANGUAGES: ILanguageConfig[] = [
|
||||
nativeName: 'Suomi',
|
||||
flag: '🇫🇮'
|
||||
},
|
||||
{
|
||||
code: 'he',
|
||||
name: 'Hebrew',
|
||||
nativeName: 'עברית',
|
||||
flag: '🇮🇱'
|
||||
},
|
||||
{
|
||||
code: 'it',
|
||||
name: 'Italian',
|
||||
@@ -70,6 +96,30 @@ 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',
|
||||
nativeName: 'Українська',
|
||||
flag: '🇺🇦'
|
||||
},
|
||||
{
|
||||
code: 'zh',
|
||||
name: 'Chinese',
|
||||
@@ -77,12 +127,6 @@ export const AVAILABLE_LANGUAGES: ILanguageConfig[] = [
|
||||
flag: '🇨🇳'
|
||||
},
|
||||
/*
|
||||
* {
|
||||
* code: 'de',
|
||||
* name: 'German',
|
||||
* nativeName: 'Deutsch',
|
||||
* flag: '🇩🇪'
|
||||
* },
|
||||
* {
|
||||
* code: 'es',
|
||||
* name: 'Spanish',
|
||||
@@ -95,12 +139,6 @@ export const AVAILABLE_LANGUAGES: ILanguageConfig[] = [
|
||||
* nativeName: 'Français',
|
||||
* flag: '🇫🇷'
|
||||
* },
|
||||
* {
|
||||
* code: 'uk',
|
||||
* name: 'Ukrainian',
|
||||
* nativeName: 'Українська',
|
||||
* flag: '🇺🇦'
|
||||
* }
|
||||
*/
|
||||
];
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -229,6 +240,7 @@
|
||||
"avoidAmbiguousChars": "Avoid ambiguous characters (o, 0, etc.)",
|
||||
"generateNewPreview": "Generate new preview",
|
||||
"generateRandomAlias": "Generate Random Alias",
|
||||
"clearAliasFields": "Clear Alias Fields",
|
||||
"alias": "Alias",
|
||||
"firstName": "First Name",
|
||||
"lastName": "Last Name",
|
||||
@@ -344,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",
|
||||
@@ -356,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",
|
||||
@@ -385,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",
|
||||
@@ -229,6 +240,7 @@
|
||||
"avoidAmbiguousChars": "Mehrdeutige Zeichen (1, l, I, 0, O, etc.) vermeiden",
|
||||
"generateNewPreview": "Neue Vorschau erstellen",
|
||||
"generateRandomAlias": "Zufälligen Alias generieren",
|
||||
"clearAliasFields": "Alias-Felder löschen",
|
||||
"alias": "Alias",
|
||||
"firstName": "Vorname",
|
||||
"lastName": "Nachname",
|
||||
@@ -344,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",
|
||||
@@ -356,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",
|
||||
@@ -385,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",
|
||||
@@ -229,6 +240,7 @@
|
||||
"avoidAmbiguousChars": "Avoid ambiguous characters (o, 0, etc.)",
|
||||
"generateNewPreview": "Generate new preview",
|
||||
"generateRandomAlias": "Generate Random Alias",
|
||||
"clearAliasFields": "Clear Alias Fields",
|
||||
"alias": "Alias",
|
||||
"firstName": "First Name",
|
||||
"lastName": "Last Name",
|
||||
@@ -344,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",
|
||||
@@ -356,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",
|
||||
@@ -385,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",
|
||||
@@ -229,6 +240,7 @@
|
||||
"avoidAmbiguousChars": "Avoid ambiguous characters (o, 0, etc.)",
|
||||
"generateNewPreview": "Generate new preview",
|
||||
"generateRandomAlias": "Generate Random Alias",
|
||||
"clearAliasFields": "Clear Alias Fields",
|
||||
"alias": "Alias",
|
||||
"firstName": "First Name",
|
||||
"lastName": "Last Name",
|
||||
@@ -344,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",
|
||||
@@ -356,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",
|
||||
@@ -385,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,9 +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ä alias-kentät",
|
||||
"alias": "Alias",
|
||||
"firstName": "Etunimi",
|
||||
"lastName": "Sukunimi",
|
||||
@@ -245,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ä",
|
||||
@@ -269,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."
|
||||
}
|
||||
},
|
||||
@@ -286,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",
|
||||
@@ -343,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}})"
|
||||
}
|
||||
}
|
||||
}
|
||||