Compare commits
488 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 | ||
|
|
b4d883dbf0 | ||
|
|
86f8f4ebdf | ||
|
|
b5df1ed8dd | ||
|
|
b2c25db5d9 | ||
|
|
c0c876c694 | ||
|
|
b832d19e0e | ||
|
|
68214becad | ||
|
|
0971922518 | ||
|
|
1e9767b0bb | ||
|
|
3f12bdad9d | ||
|
|
0ee17cc0ee | ||
|
|
c7448f7e99 | ||
|
|
835b350d53 | ||
|
|
b7cbecc61d | ||
|
|
5e2f950b7e | ||
|
|
9a97a904fb | ||
|
|
56b6753320 | ||
|
|
f7675c0279 | ||
|
|
961d237d42 | ||
|
|
47c2ae1e56 | ||
|
|
9658a40c76 | ||
|
|
752ddaea9c | ||
|
|
5efc277316 | ||
|
|
88b32efa97 | ||
|
|
03f692a62f | ||
|
|
bca8ffe676 | ||
|
|
d2590f4222 | ||
|
|
ef245b2566 | ||
|
|
9ae92962d3 | ||
|
|
e52cd927a5 | ||
|
|
582f7c2ebc | ||
|
|
ce5e5df644 | ||
|
|
6a2e663c57 | ||
|
|
f6adb93518 | ||
|
|
077a4fb3ee | ||
|
|
dc4fa1b487 | ||
|
|
949b51defd | ||
|
|
c2b824c31e | ||
|
|
cc846830fe | ||
|
|
f6ab23fa03 | ||
|
|
44d84187c8 | ||
|
|
fe78524e41 | ||
|
|
adc0e8227f | ||
|
|
55cb24be68 | ||
|
|
8efc021bd7 | ||
|
|
b649bdeb2e | ||
|
|
af4ca2e018 | ||
|
|
1fa9606491 | ||
|
|
7620fa8186 | ||
|
|
4a5d42d65b | ||
|
|
af0f582090 | ||
|
|
4f91ae7f1c | ||
|
|
67c4b55cbb | ||
|
|
7ff608b08c | ||
|
|
4ebbea7825 | ||
|
|
1260e94199 | ||
|
|
3b8d0d3a8a | ||
|
|
2725646a6a | ||
|
|
89cddcc626 | ||
|
|
f7d9d2a47c | ||
|
|
60833efcda | ||
|
|
70208eb81a | ||
|
|
ae6e734dc9 | ||
|
|
f1fc2a5f96 | ||
|
|
b62621c9c6 | ||
|
|
a372348dbf | ||
|
|
779d2a6b43 | ||
|
|
9510c0232f | ||
|
|
1e97960eab | ||
|
|
c756156e0d | ||
|
|
af98a252c8 | ||
|
|
a7f016d73f | ||
|
|
3a287ebc77 | ||
|
|
65c1a60447 | ||
|
|
c6906c8caf | ||
|
|
ace1bd7b0f | ||
|
|
56e82cd046 | ||
|
|
58d6b4c67c | ||
|
|
7e4a0f6e07 | ||
|
|
b543696fa9 | ||
|
|
e669738e38 | ||
|
|
961977c9e2 | ||
|
|
e3d2bec203 | ||
|
|
75d9249577 | ||
|
|
016a7e7559 | ||
|
|
b6e7a2e77a | ||
|
|
fd9e62591e | ||
|
|
fd485b979c | ||
|
|
410e845811 | ||
|
|
b5207d97fb | ||
|
|
3122dc4807 | ||
|
|
e010f0f57b | ||
|
|
864a7630d5 | ||
|
|
b603a177e2 | ||
|
|
ee2fd9f9ae | ||
|
|
a14066c43f | ||
|
|
1bcd088782 | ||
|
|
4ff937feec | ||
|
|
77d49c52f0 | ||
|
|
f09cfecb13 | ||
|
|
8655f15731 | ||
|
|
d629ffb6e5 | ||
|
|
21e0ad5017 | ||
|
|
279a1f2ab2 | ||
|
|
957be55927 | ||
|
|
63a8be657c | ||
|
|
7559f0aff4 | ||
|
|
c89afa613f | ||
|
|
7f449694c8 | ||
|
|
8797b3b360 | ||
|
|
4af333e22d | ||
|
|
17e8b6c16c | ||
|
|
694f1d5e8f | ||
|
|
6f32692342 | ||
|
|
358d838f3b | ||
|
|
2e47486195 | ||
|
|
6936d4da3b | ||
|
|
17a7a57136 | ||
|
|
a3552471af | ||
|
|
886208460b | ||
|
|
a6fea3a60a | ||
|
|
fb9c2e1494 | ||
|
|
2b259eee0c | ||
|
|
d9a8e671a1 | ||
|
|
f9a9cb83c4 | ||
|
|
3eae4b478f | ||
|
|
06dc2eadae | ||
|
|
2fa11dab67 | ||
|
|
c73e3a489c |
@@ -26,6 +26,9 @@ HTTPS_PORT=443
|
||||
SMTP_PORT=25
|
||||
SMTP_TLS_PORT=587
|
||||
|
||||
# Whether to force redirect all HTTP traffic (80) to HTTPS (443). Defaults to true.
|
||||
FORCE_HTTPS_REDIRECT=true
|
||||
|
||||
# ===========================================
|
||||
# EMAIL SERVER CONFIGURATION
|
||||
# ===========================================
|
||||
|
||||
1
.github/FUNDING.yml
vendored
@@ -1,2 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
buy_me_a_coffee: lanedirt
|
||||
open_collective: aliasvault
|
||||
|
||||
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
|
||||
|
||||
8
.github/workflows/docker-build.yml
vendored
@@ -109,8 +109,8 @@ jobs:
|
||||
echo "🔧 Testing admin password reset flow..."
|
||||
|
||||
# Run the reset password script with auto-confirm
|
||||
echo "Running reset-admin-password.sh script..."
|
||||
password_output=$(docker exec aliasvault-test reset-admin-password.sh -y 2>&1)
|
||||
echo "Running reset-admin-password command..."
|
||||
password_output=$(docker exec aliasvault-test aliasvault reset-admin-password -y 2>&1)
|
||||
echo "Script output:"
|
||||
echo "$password_output"
|
||||
|
||||
@@ -174,10 +174,10 @@ jobs:
|
||||
- name: Check local docker-compose.yml for :latest tags
|
||||
run: |
|
||||
# Check for explicit version tags instead of :latest
|
||||
if grep -E "ghcr\.io/lanedirt/aliasvault-[^:]+:[0-9]+\.[0-9]+\.[0-9]+" docker-compose.yml; then
|
||||
if grep -E "ghcr\.io/aliasvault/[^:]+:[0-9]+\.[0-9]+\.[0-9]+" docker-compose.yml; then
|
||||
echo "❌ Error: docker-compose.yml contains explicit version tags instead of :latest"
|
||||
echo "Found the following explicit versions:"
|
||||
grep -E "ghcr\.io/lanedirt/aliasvault-[^:]+:[0-9]+\.[0-9]+\.[0-9]+" docker-compose.yml
|
||||
grep -E "ghcr\.io/aliasvault/[^:]+:[0-9]+\.[0-9]+\.[0-9]+" docker-compose.yml
|
||||
echo ""
|
||||
echo "All AliasVault images in docker-compose.yml must use ':latest' tags, not explicit versions."
|
||||
echo "Please update docker-compose.yml to use ':latest' for all AliasVault images."
|
||||
|
||||
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
|
||||
|
||||
252
.github/workflows/release.yml
vendored
@@ -26,10 +26,6 @@ on:
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
upload-install-script:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -127,123 +123,253 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Convert repository name to lowercase
|
||||
run: |
|
||||
echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV}
|
||||
|
||||
- name: Log in to the Container registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for multi-container images
|
||||
id: meta
|
||||
- name: Extract metadata for Postgres image
|
||||
id: postgres-meta
|
||||
uses: docker/metadata-action@v5
|
||||
env:
|
||||
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}
|
||||
images: ghcr.io/aliasvault/postgres
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' || github.ref_type == 'tag' }}
|
||||
type=ref,event=tag,enable=${{ github.ref_type == 'tag' }}
|
||||
type=ref,event=branch,enable=${{ github.ref_type == 'branch' }}
|
||||
type=raw,value={{branch}}-{{sha}},enable=${{ github.ref_type == 'branch' }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=AliasVault PostgreSQL
|
||||
org.opencontainers.image.description=PostgreSQL database for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.net)
|
||||
annotations: |
|
||||
org.opencontainers.image.description=PostgreSQL database for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.net)
|
||||
|
||||
- name: Generate tags for containers
|
||||
id: tags
|
||||
run: |
|
||||
# Transform base tags to include suffixes for each container
|
||||
TAGS="${{ steps.meta.outputs.tags }}"
|
||||
- name: Extract metadata for API image
|
||||
id: api-meta
|
||||
uses: docker/metadata-action@v5
|
||||
env:
|
||||
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
|
||||
with:
|
||||
images: ghcr.io/aliasvault/api
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' || github.ref_type == 'tag' }}
|
||||
type=ref,event=tag,enable=${{ github.ref_type == 'tag' }}
|
||||
type=ref,event=branch,enable=${{ github.ref_type == 'branch' }}
|
||||
type=raw,value={{branch}}-{{sha}},enable=${{ github.ref_type == 'branch' }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=AliasVault API
|
||||
org.opencontainers.image.description=REST API backend for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.net)
|
||||
annotations: |
|
||||
org.opencontainers.image.description=REST API backend for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.net)
|
||||
|
||||
- name: Extract metadata for Client image
|
||||
id: client-meta
|
||||
uses: docker/metadata-action@v5
|
||||
env:
|
||||
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
|
||||
with:
|
||||
images: ghcr.io/aliasvault/client
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' || github.ref_type == 'tag' }}
|
||||
type=ref,event=tag,enable=${{ github.ref_type == 'tag' }}
|
||||
type=ref,event=branch,enable=${{ github.ref_type == 'branch' }}
|
||||
type=raw,value={{branch}}-{{sha}},enable=${{ github.ref_type == 'branch' }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=AliasVault Client
|
||||
org.opencontainers.image.description=Blazor WebAssembly client UI for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.net)
|
||||
annotations: |
|
||||
org.opencontainers.image.description=Blazor WebAssembly client UI for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.net)
|
||||
|
||||
- name: Extract metadata for Admin image
|
||||
id: admin-meta
|
||||
uses: docker/metadata-action@v5
|
||||
env:
|
||||
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
|
||||
with:
|
||||
images: ghcr.io/aliasvault/admin
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' || github.ref_type == 'tag' }}
|
||||
type=ref,event=tag,enable=${{ github.ref_type == 'tag' }}
|
||||
type=ref,event=branch,enable=${{ github.ref_type == 'branch' }}
|
||||
type=raw,value={{branch}}-{{sha}},enable=${{ github.ref_type == 'branch' }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=AliasVault Admin
|
||||
org.opencontainers.image.description=Admin portal for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.net)
|
||||
annotations: |
|
||||
org.opencontainers.image.description=Admin portal for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.net)
|
||||
|
||||
- name: Extract metadata for Reverse Proxy image
|
||||
id: reverse-proxy-meta
|
||||
uses: docker/metadata-action@v5
|
||||
env:
|
||||
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
|
||||
with:
|
||||
images: ghcr.io/aliasvault/reverse-proxy
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' || github.ref_type == 'tag' }}
|
||||
type=ref,event=tag,enable=${{ github.ref_type == 'tag' }}
|
||||
type=ref,event=branch,enable=${{ github.ref_type == 'branch' }}
|
||||
type=raw,value={{branch}}-{{sha}},enable=${{ github.ref_type == 'branch' }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=AliasVault Reverse Proxy
|
||||
org.opencontainers.image.description=Nginx reverse proxy for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.net)
|
||||
annotations: |
|
||||
org.opencontainers.image.description=Nginx reverse proxy for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.net)
|
||||
|
||||
- name: Extract metadata for SMTP image
|
||||
id: smtp-meta
|
||||
uses: docker/metadata-action@v5
|
||||
env:
|
||||
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
|
||||
with:
|
||||
images: ghcr.io/aliasvault/smtp
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' || github.ref_type == 'tag' }}
|
||||
type=ref,event=tag,enable=${{ github.ref_type == 'tag' }}
|
||||
type=ref,event=branch,enable=${{ github.ref_type == 'branch' }}
|
||||
type=raw,value={{branch}}-{{sha}},enable=${{ github.ref_type == 'branch' }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=AliasVault SMTP Service
|
||||
org.opencontainers.image.description=SMTP service for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.net)
|
||||
annotations: |
|
||||
org.opencontainers.image.description=SMTP service for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.net)
|
||||
|
||||
- name: Extract metadata for TaskRunner image
|
||||
id: task-runner-meta
|
||||
uses: docker/metadata-action@v5
|
||||
env:
|
||||
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
|
||||
with:
|
||||
images: ghcr.io/aliasvault/task-runner
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' || github.ref_type == 'tag' }}
|
||||
type=ref,event=tag,enable=${{ github.ref_type == 'tag' }}
|
||||
type=ref,event=branch,enable=${{ github.ref_type == 'branch' }}
|
||||
type=raw,value={{branch}}-{{sha}},enable=${{ github.ref_type == 'branch' }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=AliasVault TaskRunner
|
||||
org.opencontainers.image.description=Background task runner for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.net)
|
||||
annotations: |
|
||||
org.opencontainers.image.description=Background task runner for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.net)
|
||||
|
||||
- name: Extract metadata for InstallCLI image
|
||||
id: installcli-meta
|
||||
uses: docker/metadata-action@v5
|
||||
env:
|
||||
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
|
||||
with:
|
||||
images: ghcr.io/aliasvault/installcli
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' || github.ref_type == 'tag' }}
|
||||
type=ref,event=tag,enable=${{ github.ref_type == 'tag' }}
|
||||
type=ref,event=branch,enable=${{ github.ref_type == 'branch' }}
|
||||
type=raw,value={{branch}}-{{sha}},enable=${{ github.ref_type == 'branch' }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=AliasVault Install CLI
|
||||
org.opencontainers.image.description=Installation and configuration CLI for AliasVault. Used by install.sh for setup and configuration, not deployed as part of the application stack
|
||||
annotations: |
|
||||
org.opencontainers.image.description=Installation and configuration CLI for AliasVault. Used by install.sh for setup and configuration, not deployed as part of the application stack
|
||||
|
||||
# Generate tags for each container by replacing the base image name with suffixed versions
|
||||
echo "postgres=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-postgres|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
|
||||
echo "api=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-api|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
|
||||
echo "client=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-client|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
|
||||
echo "admin=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-admin|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
|
||||
echo "reverse-proxy=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-reverse-proxy|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
|
||||
echo "smtp=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-smtp|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
|
||||
echo "task-runner=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-task-runner|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
|
||||
echo "installcli=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-installcli|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push Postgres image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: apps/server/Databases/AliasServerDb/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.postgres }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.postgres-meta.outputs.tags }}
|
||||
labels: ${{ steps.postgres-meta.outputs.labels }}
|
||||
annotations: ${{ steps.postgres-meta.outputs.annotations }}
|
||||
|
||||
- name: Build and push API image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: apps/server/AliasVault.Api/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.api }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.api-meta.outputs.tags }}
|
||||
labels: ${{ steps.api-meta.outputs.labels }}
|
||||
annotations: ${{ steps.api-meta.outputs.annotations }}
|
||||
|
||||
- name: Build and push Client image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: apps/server/AliasVault.Client/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.client }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.client-meta.outputs.tags }}
|
||||
labels: ${{ steps.client-meta.outputs.labels }}
|
||||
annotations: ${{ steps.client-meta.outputs.annotations }}
|
||||
|
||||
- name: Build and push Admin image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: apps/server/AliasVault.Admin/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.admin }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.admin-meta.outputs.tags }}
|
||||
labels: ${{ steps.admin-meta.outputs.labels }}
|
||||
annotations: ${{ steps.admin-meta.outputs.annotations }}
|
||||
|
||||
- name: Build and push Reverse Proxy image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: apps/server/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.reverse-proxy }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.reverse-proxy-meta.outputs.tags }}
|
||||
labels: ${{ steps.reverse-proxy-meta.outputs.labels }}
|
||||
annotations: ${{ steps.reverse-proxy-meta.outputs.annotations }}
|
||||
|
||||
- name: Build and push SMTP image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: apps/server/Services/AliasVault.SmtpService/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.smtp }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.smtp-meta.outputs.tags }}
|
||||
labels: ${{ steps.smtp-meta.outputs.labels }}
|
||||
annotations: ${{ steps.smtp-meta.outputs.annotations }}
|
||||
|
||||
- name: Build and push TaskRunner image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: apps/server/Services/AliasVault.TaskRunner/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.task-runner }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.task-runner-meta.outputs.tags }}
|
||||
labels: ${{ steps.task-runner-meta.outputs.labels }}
|
||||
annotations: ${{ steps.task-runner-meta.outputs.annotations }}
|
||||
|
||||
- name: Build and push InstallCli image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: apps/server/Utilities/AliasVault.InstallCli/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.installcli }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.installcli-meta.outputs.tags }}
|
||||
labels: ${{ steps.installcli-meta.outputs.labels }}
|
||||
annotations: ${{ steps.installcli-meta.outputs.annotations }}
|
||||
|
||||
build-and-push-docker-all-in-one:
|
||||
if: github.event_name == 'release' || inputs.build_all_in_one
|
||||
@@ -262,10 +388,6 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Convert repository name to lowercase
|
||||
run: |
|
||||
echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV}
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -282,25 +404,35 @@ jobs:
|
||||
- name: Extract metadata for all-in-one image
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
env:
|
||||
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ env.REPO_LOWER }}
|
||||
ghcr.io/aliasvault/aliasvault
|
||||
aliasvault/aliasvault
|
||||
tags: |
|
||||
# For release events with latest tag (only for non-prerelease)
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
|
||||
# semver tags for releases (works for prerelease and normal release)
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' || github.ref_type == 'tag' }}
|
||||
# For tags, use tag name
|
||||
type=ref,event=tag,enable=${{ github.ref_type == 'tag' }}
|
||||
# For branches, use branch name and branch name + short SHA for uniqueness
|
||||
type=ref,event=branch,enable=${{ github.ref_type == 'branch' }}
|
||||
type=raw,value={{branch}}-{{sha}},enable=${{ github.ref_type == 'branch' }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=AliasVault All-in-One
|
||||
org.opencontainers.image.description=Self-contained AliasVault server including web app, with all services bundled using s6-overlay. Single container solution for easy deployment (see docs.aliasvault.net).
|
||||
annotations: |
|
||||
org.opencontainers.image.description=Self-contained AliasVault server including web app, with all services bundled using s6-overlay. Single container solution for easy deployment (see docs.aliasvault.net).
|
||||
|
||||
- name: Build and push all-in-one image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: dockerfiles/all-in-one/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
|
||||
80
.github/workflows/sonarcloud-code-analysis.yml
vendored
@@ -1,80 +0,0 @@
|
||||
# This workflow will perform a SonarCloud code analysis on every push to the main branch or
|
||||
# when a pull request is opened, synchronized, or reopened. The "pull_request_target" event is
|
||||
# used to ensure that the analysis is done on the source branch of the pull request which has
|
||||
# access to the SonarCloud token secret.
|
||||
name: SonarCloud code analysis
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
# Cancel in-progress jobs when new commits are pushed
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and analyze
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Install WASM workload
|
||||
run: dotnet workload install wasm-tools
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: 'zulu'
|
||||
|
||||
- name: Checkout code of PR branch
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache SonarCloud packages
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~\sonar\cache
|
||||
key: ${{ runner.os }}-sonar
|
||||
restore-keys: ${{ runner.os }}-sonar
|
||||
|
||||
- name: Cache SonarCloud scanner
|
||||
id: cache-sonar-scanner
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .\.sonar\scanner
|
||||
key: ${{ runner.os }}-sonar-scanner
|
||||
restore-keys: ${{ runner.os }}-sonar-scanner
|
||||
|
||||
- name: Install SonarCloud scanner
|
||||
if: steps.cache-sonar-scanner.outputs.cache-hit != 'true'
|
||||
shell: powershell
|
||||
run: |
|
||||
New-Item -Path .\.sonar\scanner -ItemType Directory
|
||||
dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner
|
||||
|
||||
- name: Build and analyze
|
||||
working-directory: apps/server
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
shell: powershell
|
||||
run: |
|
||||
$scanner = "${{ github.workspace }}\.sonar\scanner\dotnet-sonarscanner"
|
||||
if ('${{ github.event_name }}' -eq 'pull_request_target') {
|
||||
& $scanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.pullrequest.key=${{ github.event.pull_request.number }} /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs" /d:sonar.exclusions="**/__tests__/test-forms/*.html,**/dist/shared/**,**/*.sql"
|
||||
} else {
|
||||
& $scanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs" /d:sonar.exclusions="**/__tests__/test-forms/*.html,**/dist/shared/**,**/*.sql"
|
||||
}
|
||||
dotnet build
|
||||
dotnet test -c Release /p:CollectCoverage=true /p:CoverletOutput=coverage /p:CoverletOutputFormat=opencover --filter 'FullyQualifiedName!~AliasVault.E2ETests'
|
||||
& $scanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
|
||||
4
.gitignore
vendored
@@ -404,6 +404,7 @@ certificates/**/*.crt
|
||||
certificates/**/*.key
|
||||
certificates/**/*.pfx
|
||||
certificates/**/*.pem
|
||||
certificates/**/.hostname_marker
|
||||
certificates/letsencrypt/**
|
||||
|
||||
# Secrets
|
||||
@@ -430,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 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.
|
||||
39
README.md
@@ -1,9 +1,8 @@
|
||||
# <img src="https://github.com/user-attachments/assets/933c8b45-a190-4df6-913e-b7c64ad9938b" width="35" alt="AliasVault"> AliasVault
|
||||
AliasVault is a privacy-first password and email alias manager. Create unique identities, strong passwords, and random email aliases for every website you use. Fully end-to-end encrypted, with a built-in email server and zero third-party dependencies.
|
||||
|
||||
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github&label=Release">](https://github.com/lanedirt/AliasVault/releases)
|
||||
[](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-e2e-tests.yml)
|
||||
[<img src="https://img.shields.io/sonar/quality_gate/lanedirt_AliasVault?server=https%3A%2F%2Fsonarcloud.io&label=Sonarcloud&logo=sonarcloud">](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault)
|
||||
[<img src="https://img.shields.io/github/v/release/aliasvault/aliasvault?include_prereleases&logo=github&label=Release">](https://github.com/aliasvault/aliasvault/releases)
|
||||
[](https://github.com/aliasvault/aliasvault/actions/workflows/dotnet-e2e-tests.yml)
|
||||
[<img src="https://badges.crowdin.net/aliasvault/localized.svg">](https://crowdin.com/project/aliasvault)
|
||||
[<img alt="Discord" src="https://img.shields.io/discord/1309300619026235422?logo=discord&logoColor=%237289da&label=Discord&color=%237289da">](https://discord.gg/DsaXMTEtpF)
|
||||
|
||||
@@ -66,33 +65,31 @@ AliasVault is available on:
|
||||
[<img width="700" alt="Screenshot of AliasVault" src="docs/assets/img/screenshot.png">](https://app.aliasvault.net)
|
||||
|
||||
## Self-hosting
|
||||
For full control over your own data you can self-host and install AliasVault on your own servers.
|
||||
> [!NOTE]
|
||||
> **Requirements:** 1 vCPU, 1GB RAM, 16GB disk, Docker ≥ 20.10, 64-bit Linux
|
||||
|
||||
### Install using install script
|
||||
AliasVault can be self-hosted on your own servers using two different installation methods. Both use Docker, but they differ in how much is automated versus how much you manage yourself.
|
||||
|
||||
This method uses pre-built Docker images and works on minimal hardware specifications:
|
||||
- **Option 1: Install Script** - Managed solution with automatic SSL (recommended for VPS/cloud)
|
||||
|
||||
- 64-bit Linux VM (Ubuntu/AlmaLinux) or Raspberry Pi, with root access
|
||||
- Minimum: 1 vCPU, 1GB RAM, 16GB disk
|
||||
- Docker ≥ 20.10 and Docker Compose ≥ 2.0
|
||||
- **Option 2: Docker Compose** - Single container with manual setup for use with existing SSL infrastructure (NAS, homelab)
|
||||
|
||||
### Quick Start (Install Script)
|
||||
```bash
|
||||
# Download install script from latest stable release
|
||||
curl -L -o install.sh https://github.com/lanedirt/AliasVault/releases/latest/download/install.sh
|
||||
# Download and run install script
|
||||
curl -L -o install.sh https://github.com/aliasvault/aliasvault/releases/latest/download/install.sh
|
||||
|
||||
# Make install script executable and run it. This will create the .env file, pull the Docker images, and start the AliasVault containers.
|
||||
chmod +x install.sh
|
||||
|
||||
./install.sh install
|
||||
```
|
||||
|
||||
The install script will output the URL where the app is available. By default this is:
|
||||
- Client: https://localhost
|
||||
- Admin portal: https://localhost/admin
|
||||
For other installation methods and more detailed steps, please read the [full installation guide](https://docs.aliasvault.net/installation) in the official docs.
|
||||
|
||||
> Note: If you want to change the default AliasVault ports you can do so in the `.env` file.
|
||||
|
||||
## Technical documentation
|
||||
## Documentation
|
||||
For more information about the installation process, manual setup instructions and other topics, please see the official documentation website:
|
||||
|
||||
- [Documentation website (docs.aliasvault.net) 📚](https://docs.aliasvault.net)
|
||||
|
||||
## Security Architecture
|
||||
@@ -105,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
|
||||
@@ -133,12 +130,12 @@ Core features that are being worked on:
|
||||
- [ ] Support for FIDO2/WebAuthn hardware keys and passkeys
|
||||
- [ ] Adding support for family/team sharing (organization features)
|
||||
|
||||
👉 [View the full AliasVault roadmap here](https://github.com/lanedirt/AliasVault/issues/731)
|
||||
👉 [View the full AliasVault roadmap here](https://github.com/aliasvault/aliasvault/issues/731)
|
||||
|
||||
### Got feedback or ideas?
|
||||
Feel free to open an issue or join our [Discord](https://discord.gg/DsaXMTEtpF)! Contributions are warmly welcomed—whether in feature development, testing, or spreading the word. Get in touch on Discord if you're interested in contributing.
|
||||
Feel free to open an issue or discussion on GitHub. We warmly welcome all contributions: whether it’s translating, testing, helping to build features, sharing feedback - or helping spread the word about AliasVault. Every bit of support helps the project grow, so don’t hesitate to jump in and [say hi to us on Discord](https://discord.gg/DsaXMTEtpF)!
|
||||
|
||||
### Support the mission
|
||||
Your donation helps me dedicate more time and resources to improving AliasVault, making the internet safer for everyone!
|
||||
AliasVault is open-source and community-driven. If you like what we’re building, consider supporting us through [Open Collective](https://opencollective.com/aliasvault) or through:
|
||||
|
||||
<a href="https://www.buymeacoffee.com/lanedirt" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 50px !important;" ></a>
|
||||
|
||||
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,13 +0,0 @@
|
||||
<SonarLint>
|
||||
<Rules>
|
||||
<Rule>
|
||||
<Key>S1135</Key>
|
||||
<Parameters>
|
||||
<Parameter>
|
||||
<Name>sonarlint.rule.enabled</Name>
|
||||
<Value>false</Value>
|
||||
</Parameter>
|
||||
</Parameters>
|
||||
</Rule>
|
||||
</Rules>
|
||||
</SonarLint>
|
||||
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.22.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"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 174 KiB |
@@ -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 = 220001;
|
||||
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.22.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 = 220001;
|
||||
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.22.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 = 220001;
|
||||
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.22.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 = 220001;
|
||||
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.22.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) {
|
||||
|
||||
@@ -108,6 +108,4 @@ async function extendAutoLockTimer(): Promise<void> {
|
||||
console.error('[AUTO_LOCK] Error locking vault:', error);
|
||||
}
|
||||
}, timeout * 1000);
|
||||
|
||||
console.info(`[AUTO_LOCK] Timer extended (popup heartbeat)`);
|
||||
}
|
||||
|
||||
@@ -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,39 +1,51 @@
|
||||
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/AuthSettings';
|
||||
import CredentialAddEdit from '@/entrypoints/popup/pages/CredentialAddEdit';
|
||||
import CredentialDetails from '@/entrypoints/popup/pages/CredentialDetails';
|
||||
import CredentialsList from '@/entrypoints/popup/pages/CredentialsList';
|
||||
import EmailDetails from '@/entrypoints/popup/pages/EmailDetails';
|
||||
import EmailsList from '@/entrypoints/popup/pages/EmailsList';
|
||||
import AuthSettings from '@/entrypoints/popup/pages/auth/AuthSettings';
|
||||
import Login from '@/entrypoints/popup/pages/auth/Login';
|
||||
import Unlock from '@/entrypoints/popup/pages/auth/Unlock';
|
||||
import UnlockSuccess from '@/entrypoints/popup/pages/auth/UnlockSuccess';
|
||||
import Upgrade from '@/entrypoints/popup/pages/auth/Upgrade';
|
||||
import CredentialAddEdit from '@/entrypoints/popup/pages/credentials/CredentialAddEdit';
|
||||
import CredentialDetails from '@/entrypoints/popup/pages/credentials/CredentialDetails';
|
||||
import CredentialsList from '@/entrypoints/popup/pages/credentials/CredentialsList';
|
||||
import EmailDetails from '@/entrypoints/popup/pages/emails/EmailDetails';
|
||||
import EmailsList from '@/entrypoints/popup/pages/emails/EmailsList';
|
||||
import Index from '@/entrypoints/popup/pages/Index';
|
||||
import Login from '@/entrypoints/popup/pages/Login';
|
||||
import Logout from '@/entrypoints/popup/pages/Logout';
|
||||
import PasskeyAuthenticate from '@/entrypoints/popup/pages/passkeys/PasskeyAuthenticate';
|
||||
import PasskeyCreate from '@/entrypoints/popup/pages/passkeys/PasskeyCreate';
|
||||
import Reinitialize from '@/entrypoints/popup/pages/Reinitialize';
|
||||
import Settings from '@/entrypoints/popup/pages/Settings';
|
||||
import AutofillSettings from '@/entrypoints/popup/pages/settings/AutofillSettings';
|
||||
import AutoLockSettings from '@/entrypoints/popup/pages/settings/AutoLockSettings';
|
||||
import ClipboardSettings from '@/entrypoints/popup/pages/settings/ClipboardSettings';
|
||||
import ContextMenuSettings from '@/entrypoints/popup/pages/settings/ContextMenuSettings';
|
||||
import LanguageSettings from '@/entrypoints/popup/pages/settings/LanguageSettings';
|
||||
import Unlock from '@/entrypoints/popup/pages/Unlock';
|
||||
import UnlockSuccess from '@/entrypoints/popup/pages/UnlockSuccess';
|
||||
import Upgrade from '@/entrypoints/popup/pages/Upgrade';
|
||||
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(() => {
|
||||
@@ -213,7 +213,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
}
|
||||
if (emails.length === 0) {
|
||||
return (
|
||||
<div className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
<div className="text-gray-500 dark:text-gray-400 mb-4 text-sm">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('common.recentEmails')}</h2>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -167,7 +198,7 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={id} className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label htmlFor={id} className="block font-medium text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
@@ -177,7 +208,7 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
|
||||
<input
|
||||
type="text"
|
||||
id={id}
|
||||
className={`flex-1 min-w-0 px-3 py-2 border ${
|
||||
className={`flex-1 min-w-0 px-3 py-2 border text-sm ${
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
} ${
|
||||
!isCustomDomain ? 'rounded-l-md' : 'rounded-md'
|
||||
@@ -209,9 +240,9 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
|
||||
{showPrivateDomains && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('credentials.privateEmailTitle')} <span className="text-xs text-gray-500 dark:text-gray-400">({t('credentials.privateEmailAliasVaultServer')})</span>
|
||||
{t('credentials.privateEmailTitle')} <span className="text-gray-500 dark:text-gray-400">({t('credentials.privateEmailAliasVaultServer')})</span>
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-3">
|
||||
{t('credentials.privateEmailDescription')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -109,7 +109,7 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(({
|
||||
}
|
||||
};
|
||||
|
||||
const inputClasses = `mt-1 block w-full rounded-md ${
|
||||
const inputClasses = `mt-1 block text-sm w-full rounded-md ${
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
} text-gray-900 sm:text-sm rounded-lg shadow-sm border focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400 py-2 px-3`;
|
||||
|
||||
@@ -82,7 +82,7 @@ export const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> =
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
clipboardService.setCopied(id);
|
||||
|
||||
|
||||
// Notify background script that clipboard was copied
|
||||
await sendMessage('CLIPBOARD_COPIED', { value }, 'background');
|
||||
|
||||
@@ -111,7 +111,7 @@ export const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> =
|
||||
onClick={copyToClipboard}
|
||||
className={`w-full px-3 py-2.5 bg-white border ${
|
||||
copied ? 'border-green-500 border-2' : 'border-gray-300'
|
||||
} text-gray-900 sm:text-sm rounded-lg shadow-sm focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400`}
|
||||
} text-gray-900 text-sm rounded-lg shadow-sm focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400`}
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||
{copied ? (
|
||||
@@ -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 */}
|
||||
@@ -114,7 +146,7 @@ const PasswordField: React.FC<IPasswordFieldProps> = ({
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="outline-0 shadow-sm border border-gray-300 bg-gray-50 text-gray-900 sm:text-sm rounded-l-lg block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||
className="outline-0 text-sm shadow-sm border border-gray-300 bg-gray-50 text-gray-900 sm:text-sm rounded-l-lg block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex">
|
||||
@@ -49,7 +49,7 @@ const UsernameField: React.FC<IUsernameFieldProps> = ({
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
className="outline-0 shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-l-lg block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||
className="outline-0 text-sm shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-l-lg block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex">
|
||||
@@ -9,7 +9,9 @@ export enum HeaderIconType {
|
||||
EXTERNAL_LINK = 'external_link',
|
||||
SAVE = 'save',
|
||||
PLUS = 'plus',
|
||||
TAB = 'tab'
|
||||
TAB = 'tab',
|
||||
EYE = 'eye',
|
||||
EYE_OFF = 'eye_off'
|
||||
}
|
||||
|
||||
type HeaderIconProps = {
|
||||
@@ -131,19 +133,7 @@ export const HeaderIcon: React.FC<HeaderIconProps> = ({ type, className = 'w-5 h
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V7l-4-4z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 3v5h10"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 12a2 2 0 100 4 2 2 0 000-4z"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
@@ -179,6 +169,44 @@ export const HeaderIcon: React.FC<HeaderIconProps> = ({ type, className = 'w-5 h
|
||||
d="M8 5a2 2 0 012-2h4a2 2 0 012 2v2H8V5z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
[HeaderIconType.EYE]: (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
[HeaderIconType.EYE_OFF]: (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -62,7 +62,7 @@ const BottomNav: React.FC = () => {
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<span className="text-xs mt-1">{t('menu.credentials')}</span>
|
||||
<span className="text-sm mt-1">{t('menu.credentials')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('emails')}
|
||||
@@ -73,7 +73,7 @@ const BottomNav: React.FC = () => {
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="text-xs mt-1">{t('menu.emails')}</span>
|
||||
<span className="text-sm mt-1">{t('menu.emails')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('settings')}
|
||||
@@ -85,7 +85,7 @@ const BottomNav: React.FC = () => {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span className="text-xs mt-1">{t('menu.settings')}</span>
|
||||
<span className="text-sm mt-1">{t('menu.settings')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
@@ -2,7 +2,8 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import Logo from '@/entrypoints/popup/components/Logo';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
|
||||
/**
|
||||
* Header props.
|
||||
@@ -24,7 +25,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
rightButtons
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const authContext = useAuth();
|
||||
const app = useApp();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -53,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.
|
||||
@@ -87,11 +88,15 @@ const Header: React.FC<HeaderProps> = ({
|
||||
onClick={() => logoClick()}
|
||||
className="flex items-center hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img src="/assets/images/logo.svg" alt="AliasVault" className="h-8 w-8 mr-2" />
|
||||
<h1 className="text-gray-900 dark:text-white text-xl font-bold">{t('common.appName')}</h1>
|
||||
<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] ml-1 font-normal">BETA</span>
|
||||
<span className="text-primary-500 text-[10px] font-normal">BETA</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -100,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;
|
||||
@@ -27,7 +27,7 @@ const LoginServerInfo: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mb-4">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
({t('auth.connectingTo')}{' '}
|
||||
<button
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
|
||||
type LogoProps = {
|
||||
className?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
showText?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logo component.
|
||||
*/
|
||||
const Logo: React.FC<LogoProps> = ({
|
||||
className = '',
|
||||
width = 200,
|
||||
height = 50,
|
||||
showText = true,
|
||||
color = 'currentColor'
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
version="1.1"
|
||||
viewBox="0 0 2000 500"
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
>
|
||||
{/* Logo mark */}
|
||||
<path
|
||||
d="m459.87 294.95c0.016205 5.4005 0.03241 10.801-0.35022 16.873-1.111 6.3392-1.1941 12.173-2.6351 17.649-10.922 41.508-36.731 69.481-77.351 83.408-7.2157 2.4739-14.972 3.3702-22.479 4.995-23.629 0.042205-47.257 0.11453-70.886 0.12027-46.762 0.011322-93.523-0.01416-140.95-0.43411-8.59-2.0024-16.766-2.8352-24.398-5.3326-21.595-7.0666-39.523-19.656-53.708-37.552-10.227-12.903-17.579-27.17-21.28-43.221-1.475-6.3967-2.4711-12.904-3.6852-19.361-0.051849-5.747-0.1037-11.494 0.26915-17.886 4.159-42.973 27.68-71.638 63.562-92.153 0-0.70761-0.001961-1.6988 3.12e-4 -2.69 0.022484-9.8293-1.3071-19.894 0.35664-29.438 3.2391-18.579 11.08-35.272 23.763-49.773 12.098-13.832 26.457-23.989 43.609-30.029 7.813-2.7512 16.14-4.0417 24.234-5.9948 7.392-0.025734 14.784-0.05146 22.835 0.32253 4.1959 0.95392 7.7946 1.2538 11.258 2.1053 17.16 4.2192 32.287 12.176 45.469 24.104 2.2558 2.0411 4.372 6.6241 9.621 3.868 16.839-8.8419 34.718-11.597 53.603-8.594 16.791 2.6699 31.602 9.4308 44.236 20.636 11.531 10.227 19.84 22.841 25.393 37.236 6.3436 16.445 10.389 33.163 6.0798 49.389 7.9587 8.9321 15.807 16.704 22.421 25.414 9.162 12.065 15.33 25.746 18.144 40.776 0.97046 5.1848 1.9111 10.375 2.8654 15.563m-71.597 71.012c5.5615-5.2284 12.002-9.7986 16.508-15.817 10.474-13.992 14.333-29.916 11.288-47.446-2.2496-12.95-8.1973-24.076-17.243-33.063-12.746-12.663-28.865-18.614-46.786-18.569-69.912 0.17712-139.82 0.56831-209.74 0.96176-15.922 0.089599-29.168 7.4209-39.685 18.296-14.45 14.944-20.408 33.343-16.655 54.368 2.2763 12.754 8.2167 23.748 17.158 32.66 13.299 13.255 30.097 18.653 48.728 18.651 59.321-0.005188 118.64 0.042358 177.96-0.046601 9.5912-0.014374 19.181-0.86588 28.773-0.88855 10.649-0.025146 19.978-3.825 29.687-9.1074z"
|
||||
fill="#EEC170"
|
||||
/>
|
||||
<path
|
||||
d="m162.77 293c15.654 4.3883 20.627 22.967 10.304 34.98-5.3104 6.1795-14.817 8.3208-24.278 5.0472-7.0723-2.4471-12.332-10.362-12.876-17.933-1.0451-14.542 11.089-23.176 21.705-23.046 1.5794 0.019287 3.1517 0.61566 5.1461 0.95184z"
|
||||
fill="#EEC170"
|
||||
/>
|
||||
<path
|
||||
d="m227.18 293.64c7.8499 2.3973 11.938 8.2143 13.524 15.077 1.8591 8.0439-0.44817 15.706-7.1588 21.121-6.7633 5.4572-14.417 6.8794-22.578 3.1483-8.2972-3.7933-12.836-10.849-12.736-19.438 0.1687-14.497 14.13-25.368 28.948-19.908z"
|
||||
fill="#EEC170"
|
||||
/>
|
||||
<path
|
||||
d="m261.57 319.07c-2.495-14.418 4.6853-22.603 14.596-26.108 9.8945-3.4995 23.181 3.4303 26.267 13.779 4.6504 15.591-7.1651 29.064-21.665 28.161-8.5254-0.53088-17.202-6.5094-19.198-15.831z"
|
||||
fill="#EEC170"
|
||||
/>
|
||||
<path
|
||||
d="m336.91 333.41c-9.0175-4.2491-15.337-14.349-13.829-21.682 3.0825-14.989 13.341-20.304 23.018-19.585 10.653 0.79141 17.93 7.407 19.765 17.547 1.9588 10.824-4.1171 19.939-13.494 23.703-5.272 2.1162-10.091 1.5086-15.46 0.017883z"
|
||||
fill="#EEC170"
|
||||
/>
|
||||
|
||||
{/* Wordmark - only show if showText is true */}
|
||||
{showText && (
|
||||
<text
|
||||
x="550"
|
||||
y="355"
|
||||
fontFamily="Arial, Helvetica, sans-serif"
|
||||
fontWeight="700"
|
||||
fontSize="290"
|
||||
letterSpacing="-7"
|
||||
fill={color}
|
||||
>
|
||||
AliasVault
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
@@ -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)) {
|
||||
@@ -51,9 +62,15 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
const historyEntries: NavigationHistoryEntry[] = [];
|
||||
|
||||
// Build history entries for each segment
|
||||
let currentPath = '';
|
||||
for (const segment of segments) {
|
||||
currentPath += '/' + segment;
|
||||
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
|
||||
*/
|
||||
historyEntries.push({
|
||||
pathname: currentPath,
|
||||
search: location.search,
|
||||
@@ -76,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
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 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 { 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';
|
||||
|
||||
import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
|
||||
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
|
||||
|
||||
/**
|
||||
* Credentials list page.
|
||||
*/
|
||||
const CredentialsList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
const navigate = useNavigate();
|
||||
const { syncVault } = useVaultSync();
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
|
||||
/**
|
||||
* Loading state with minimum duration for more fluid UX.
|
||||
*/
|
||||
const [isLoading, setIsLoading] = useMinDurationLoading(true, 100);
|
||||
|
||||
/**
|
||||
* Handle add new credential.
|
||||
*/
|
||||
const handleAddCredential = useCallback(() : void => {
|
||||
navigate('/credentials/add');
|
||||
}, [navigate]);
|
||||
|
||||
/**
|
||||
* Retrieve latest vault and refresh the credentials list.
|
||||
*/
|
||||
const onRefresh = useCallback(async () : Promise<void> => {
|
||||
if (!dbContext?.sqliteClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Sync vault and load credentials
|
||||
await syncVault({
|
||||
/**
|
||||
* On success.
|
||||
*/
|
||||
onSuccess: async (_hasNewVault) => {
|
||||
// Credentials list is refreshed automatically when the (new) sqlite client is available via useEffect hook below.
|
||||
},
|
||||
/**
|
||||
* On offline.
|
||||
*/
|
||||
_onOffline: () => {
|
||||
// Not implemented for browser extension yet.
|
||||
},
|
||||
/**
|
||||
* On error.
|
||||
*/
|
||||
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');
|
||||
}
|
||||
}, [dbContext, webApi, syncVault, navigate]);
|
||||
|
||||
/**
|
||||
* Get latest vault from server and refresh the credentials list.
|
||||
*/
|
||||
const syncVaultAndRefresh = useCallback(async () : Promise<void> => {
|
||||
setIsLoading(true);
|
||||
await onRefresh();
|
||||
setIsLoading(false);
|
||||
}, [onRefresh, setIsLoading]);
|
||||
|
||||
// Set header buttons on mount and clear on unmount
|
||||
useEffect((): (() => void) => {
|
||||
const headerButtonsJSX = (
|
||||
<div className="flex items-center gap-2">
|
||||
{!PopoutUtility.isPopup() && (
|
||||
<HeaderButton
|
||||
onClick={() => PopoutUtility.openInNewPopup()}
|
||||
title="Open in new window"
|
||||
iconType={HeaderIconType.EXPAND}
|
||||
/>
|
||||
)}
|
||||
<HeaderButton
|
||||
onClick={handleAddCredential}
|
||||
title="Add new credential"
|
||||
iconType={HeaderIconType.PLUS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
setHeaderButtons(headerButtonsJSX);
|
||||
return () => setHeaderButtons(null);
|
||||
}, [setHeaderButtons, handleAddCredential]);
|
||||
|
||||
/**
|
||||
* Load credentials list on mount and on sqlite client change.
|
||||
*/
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Refresh credentials list when a (new) sqlite client is available.
|
||||
*/
|
||||
const refreshCredentials = async () : Promise<void> => {
|
||||
if (dbContext?.sqliteClient) {
|
||||
setIsLoading(true);
|
||||
const results = dbContext.sqliteClient?.getAllCredentials() ?? [];
|
||||
setCredentials(results);
|
||||
setIsLoading(false);
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
refreshCredentials();
|
||||
}, [dbContext?.sqliteClient, setIsLoading, setIsInitialLoading]);
|
||||
|
||||
const filteredCredentials = credentials.filter(credential => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
|
||||
/**
|
||||
* We filter credentials by searching in the following fields:
|
||||
* - Service name
|
||||
* - Username
|
||||
* - Alias email
|
||||
* - Service URL
|
||||
* - Notes
|
||||
*/
|
||||
const searchableFields = [
|
||||
credential.ServiceName?.toLowerCase(),
|
||||
credential.Username?.toLowerCase(),
|
||||
credential.Alias?.Email?.toLowerCase(),
|
||||
credential.ServiceUrl?.toLowerCase(),
|
||||
credential.Notes?.toLowerCase(),
|
||||
];
|
||||
return searchableFields.some(field => field?.includes(searchLower));
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<ReloadButton onClick={syncVaultAndRefresh} />
|
||||
</div>
|
||||
|
||||
{credentials.length > 0 ? (
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={`${t('content.searchVault')}`}
|
||||
autoFocus
|
||||
className="w-full p-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{credentials.length === 0 ? (
|
||||
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
|
||||
<p className="text-sm">
|
||||
{t('credentials.welcomeTitle')}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t('credentials.welcomeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{filteredCredentials.map(cred => (
|
||||
<CredentialCard key={cred.Id} credential={cred} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredentialsList;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -51,21 +52,16 @@ const Reinitialize: React.FC = () => {
|
||||
if (lastPage && lastVisitTime) {
|
||||
const timeSinceLastVisit = Date.now() - lastVisitTime;
|
||||
if (timeSinceLastVisit <= PAGE_MEMORY_DURATION) {
|
||||
// Restore the navigation history
|
||||
if (savedHistory?.length) {
|
||||
// First navigate to credentials page as the base
|
||||
navigate('/credentials', { replace: true });
|
||||
|
||||
// Then restore the history stack
|
||||
for (const entry of savedHistory) {
|
||||
navigate(entry.pathname + entry.search + entry.hash);
|
||||
}
|
||||
return;
|
||||
// For nested routes, build up the navigation history properly
|
||||
if (savedHistory?.length > 1) {
|
||||
// Navigate to the base route first
|
||||
navigate(savedHistory[0].pathname, { replace: true });
|
||||
// Then navigate to the final destination
|
||||
navigate(lastPage, { replace: false });
|
||||
} else {
|
||||
// Simple navigation for non-nested routes
|
||||
navigate(lastPage, { replace: true });
|
||||
}
|
||||
|
||||
// Fallback to simple navigation if no history
|
||||
navigate('/credentials', { replace: true });
|
||||
navigate(lastPage, { replace: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -83,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;
|
||||
|
||||
@@ -115,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();
|
||||
}
|
||||
@@ -143,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="text-sm 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 text-sm 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 text-sm 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 text-sm 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="text-sm font-medium text-gray-900 dark:text-white">{t('settings.autofillEnabled')}</p>
|
||||
<button
|
||||
onClick={toggleGlobalPopup}
|
||||
className={`px-4 py-2 rounded-md transition-colors ${
|
||||
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>
|
||||
@@ -6,13 +6,14 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import Button from '@/entrypoints/popup/components/Button';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
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';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
import ConversionUtility from '@/entrypoints/popup/utils/ConversionUtility';
|
||||
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
|
||||
import SrpUtility from '@/entrypoints/popup/utils/SrpUtility';
|
||||
|
||||
@@ -21,8 +22,6 @@ import type { VaultResponse, LoginResponse } from '@/utils/dist/shared/models/we
|
||||
import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
import { ApiAuthError } from '@/utils/types/errors/ApiAuthError';
|
||||
|
||||
import ConversionUtility from '../utils/ConversionUtility';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
/**
|
||||
@@ -31,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({
|
||||
@@ -40,6 +39,7 @@ const Login: React.FC = () => {
|
||||
});
|
||||
const { showLoading, hideLoading, setIsInitialLoading } = useLoading();
|
||||
const [rememberMe, setRememberMe] = useState(true);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loginResponse, setLoginResponse] = useState<LoginResponse | null>(null);
|
||||
const [passwordHashString, setPasswordHashString] = useState<string | null>(null);
|
||||
const [passwordHashBase64, setPasswordHashBase64] = useState<string | null>(null);
|
||||
@@ -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
|
||||
@@ -362,11 +352,11 @@ const Login: React.FC = () => {
|
||||
<h2 className="text-xl font-bold dark:text-gray-200">{t('auth.loginTitle')}</h2>
|
||||
<LoginServerInfo />
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-200 text-sm font-bold mb-2" htmlFor="username">
|
||||
<label className="block text-gray-700 dark:text-gray-200 font-bold mb-2" htmlFor="username">
|
||||
{t('auth.username')}
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 leading-tight focus:outline-none focus:shadow-outline"
|
||||
className="shadow text-sm appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="username"
|
||||
type="text"
|
||||
name="username"
|
||||
@@ -377,19 +367,29 @@ const Login: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-200 text-sm font-bold mb-2" htmlFor="password">
|
||||
<label className="block text-gray-700 dark:text-gray-200 font-bold mb-2" htmlFor="password">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 mb-3 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
value={credentials.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="shadow text-sm appearance-none border rounded w-full py-2 px-3 pr-10 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 mb-3 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
value={credentials.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<HeaderIcon type={showPassword ? HeaderIconType.EYE_OFF : HeaderIconType.EYE} className="w-5 h-5 text-gray-400 dark:text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="flex items-center">
|
||||
@@ -408,7 +408,7 @@ const Login: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="text-center text-gray-600 dark:text-gray-400">
|
||||
{t('auth.noAccount')}{' '}
|
||||
<a
|
||||
href={clientUrl ?? ''}
|
||||
@@ -6,7 +6,8 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import Button from '@/entrypoints/popup/components/Button';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
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();
|
||||
@@ -35,25 +38,36 @@ const Unlock: React.FC = () => {
|
||||
const srpUtil = new SrpUtility(webApi);
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
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) => {
|
||||
@@ -80,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!);
|
||||
@@ -95,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');
|
||||
|
||||
@@ -109,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();
|
||||
@@ -128,7 +152,7 @@ const Unlock: React.FC = () => {
|
||||
* Handle logout
|
||||
*/
|
||||
const handleLogout = () : void => {
|
||||
navigate('/logout', { replace: true });
|
||||
app.logout();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -144,10 +168,10 @@ const Unlock: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{authContext.username}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('auth.loggedIn')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -159,32 +183,42 @@ const Unlock: React.FC = () => {
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 text-red-500 dark:text-red-400 text-sm">
|
||||
<div className="mb-4 text-red-500 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-2">
|
||||
<label className="block text-gray-700 dark:text-gray-200 text-sm font-bold mb-2" htmlFor="password">
|
||||
<label className="block text-gray-700 dark:text-gray-200 font-bold mb-2" htmlFor="password">
|
||||
{t('auth.masterPassword')}
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 mb-3 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 pr-10 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 mb-3 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<HeaderIcon type={showPassword ? HeaderIconType.EYE_OFF : HeaderIconType.EYE} className="w-5 h-5 text-gray-400 dark:text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit">
|
||||
{t('auth.unlockVault')}
|
||||
</Button>
|
||||
|
||||
<div className="text-sm font-medium text-gray-500 dark:text-gray-200 mt-6">
|
||||
<div className="font-medium text-gray-500 dark:text-gray-200 mt-6">
|
||||
{t('auth.switchAccounts')} <button onClick={handleLogout} className="text-primary-700 hover:underline dark:text-primary-500">{t('auth.logout')}</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -239,7 +239,7 @@ const Upgrade: React.FC = () => {
|
||||
title={t('upgrade.alerts.selfHostedServer')}
|
||||
message={t('upgrade.alerts.selfHostedWarning')}
|
||||
confirmText={t('upgrade.alerts.continueUpgrade')}
|
||||
cancelText={t('upgrade.alerts.cancel')}
|
||||
cancelText={t('common.cancel')}
|
||||
/>
|
||||
|
||||
{/* Version info modal */}
|
||||
@@ -253,7 +253,7 @@ const Upgrade: React.FC = () => {
|
||||
|
||||
<form className="w-full px-2 pt-2 pb-2 mb-4">
|
||||
{error && (
|
||||
<div className="mb-4 text-red-500 dark:text-red-400 text-sm">
|
||||
<div className="mb-4 text-red-500 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -268,7 +268,7 @@ const Upgrade: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{username}
|
||||
</p>
|
||||
</div>
|
||||
@@ -277,12 +277,12 @@ const Upgrade: React.FC = () => {
|
||||
<h2 className="text-xl font-bold dark:text-gray-200 mb-4">{t('upgrade.title')}</h2>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700 dark:text-gray-200 text-sm mb-4">
|
||||
<p className="text-gray-700 dark:text-gray-200 mb-4">
|
||||
{t('upgrade.subtitle')}
|
||||
</p>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">{t('upgrade.versionInformation')}</span>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-200">{t('upgrade.versionInformation')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={showVersionDialog}
|
||||
@@ -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) => {
|
||||
@@ -536,8 +643,8 @@ const CredentialAddEdit: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('random')}
|
||||
className={`flex-1 py-2 px-4 rounded flex items-center justify-center gap-2 ${
|
||||
mode === 'random' ? 'bg-primary-500 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
className={`flex-1 py-2 text-sm px-4 rounded flex items-center justify-center gap-2 ${
|
||||
mode === 'random' ? 'bg-primary-500 text-white font-medium' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<svg className='w-5 h-5' viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -553,8 +660,8 @@ const CredentialAddEdit: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('manual')}
|
||||
className={`flex-1 py-2 px-4 rounded flex items-center justify-center gap-2 ${
|
||||
mode === 'manual' ? 'bg-primary-500 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
className={`flex-1 py-2 text-sm px-4 rounded flex items-center justify-center gap-2 ${
|
||||
mode === 'manual' ? 'bg-primary-500 text-white font-medium' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -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 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';
|
||||
@@ -0,0 +1,428 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
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 { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync';
|
||||
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
|
||||
|
||||
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 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();
|
||||
|
||||
/**
|
||||
* Loading state with minimum duration for more fluid UX.
|
||||
*/
|
||||
const [isLoading, setIsLoading] = useMinDurationLoading(true, 100);
|
||||
|
||||
/**
|
||||
* Handle add new credential.
|
||||
*/
|
||||
const handleAddCredential = useCallback(() : void => {
|
||||
navigate('/credentials/add');
|
||||
}, [navigate]);
|
||||
|
||||
/**
|
||||
* Retrieve latest vault and refresh the credentials list.
|
||||
*/
|
||||
const onRefresh = useCallback(async () : Promise<void> => {
|
||||
if (!dbContext?.sqliteClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Sync vault and load credentials
|
||||
await syncVault({
|
||||
/**
|
||||
* On success.
|
||||
*/
|
||||
onSuccess: async (_hasNewVault) => {
|
||||
// Credentials list is refreshed automatically when the (new) sqlite client is available via useEffect hook below.
|
||||
},
|
||||
/**
|
||||
* On offline.
|
||||
*/
|
||||
_onOffline: () => {
|
||||
// Not implemented for browser extension yet.
|
||||
},
|
||||
/**
|
||||
* On error.
|
||||
*/
|
||||
onError: async (error) => {
|
||||
console.error('Error syncing vault:', error);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error refreshing credentials:', err);
|
||||
await app.logout('Error while syncing vault, please re-authenticate.');
|
||||
}
|
||||
}, [dbContext, app, syncVault]);
|
||||
|
||||
/**
|
||||
* Get latest vault from server and refresh the credentials list.
|
||||
*/
|
||||
const syncVaultAndRefresh = useCallback(async () : Promise<void> => {
|
||||
setIsLoading(true);
|
||||
await onRefresh();
|
||||
setIsLoading(false);
|
||||
}, [onRefresh, setIsLoading]);
|
||||
|
||||
// Set header buttons on mount and clear on unmount
|
||||
useEffect((): (() => void) => {
|
||||
const headerButtonsJSX = (
|
||||
<div className="flex items-center gap-2">
|
||||
{!PopoutUtility.isPopup() && (
|
||||
<HeaderButton
|
||||
onClick={() => PopoutUtility.openInNewPopup()}
|
||||
title="Open in new window"
|
||||
iconType={HeaderIconType.EXPAND}
|
||||
/>
|
||||
)}
|
||||
<HeaderButton
|
||||
onClick={handleAddCredential}
|
||||
title="Add new credential"
|
||||
iconType={HeaderIconType.PLUS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
setHeaderButtons(headerButtonsJSX);
|
||||
return () => setHeaderButtons(null);
|
||||
}, [setHeaderButtons, handleAddCredential]);
|
||||
|
||||
/**
|
||||
* Load credentials list on mount and on sqlite client change.
|
||||
*/
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Refresh credentials list when a (new) sqlite client is available.
|
||||
*/
|
||||
const refreshCredentials = async () : Promise<void> => {
|
||||
if (dbContext?.sqliteClient) {
|
||||
setIsLoading(true);
|
||||
const results = dbContext.sqliteClient?.getAllCredentials() ?? [];
|
||||
setCredentials(results);
|
||||
setIsLoading(false);
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
refreshCredentials();
|
||||
}, [dbContext?.sqliteClient, setIsLoading, setIsInitialLoading]);
|
||||
|
||||
/**
|
||||
* 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:
|
||||
* - Service name
|
||||
* - Username
|
||||
* - Alias email
|
||||
* - Service URL
|
||||
* - Notes
|
||||
*/
|
||||
const searchableFields = [
|
||||
credential.ServiceName?.toLowerCase() || '',
|
||||
credential.Username?.toLowerCase() || '',
|
||||
credential.Alias?.Email?.toLowerCase() || '',
|
||||
credential.ServiceUrl?.toLowerCase() || '',
|
||||
credential.Notes?.toLowerCase() || '',
|
||||
];
|
||||
|
||||
// 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) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<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>
|
||||
|
||||
{credentials.length > 0 ? (
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={`${t('content.searchVault')}`}
|
||||
autoFocus
|
||||
className="w-full p-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{credentials.length === 0 ? (
|
||||
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
|
||||
<p>
|
||||
{t('credentials.welcomeTitle')}
|
||||
</p>
|
||||
<p>
|
||||
{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 => (
|
||||
<CredentialCard key={cred.Id} credential={cred} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredentialsList;
|
||||
@@ -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';
|
||||
@@ -16,8 +16,8 @@ import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
|
||||
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
|
||||
|
||||
import HeaderButton from '../components/HeaderButton';
|
||||
import { HeaderIconType } from '../components/Icons/HeaderIcons';
|
||||
import HeaderButton from '../../components/HeaderButton';
|
||||
import { HeaderIconType } from '../../components/Icons/HeaderIcons';
|
||||
|
||||
/**
|
||||
* Email details page.
|
||||
@@ -32,6 +32,7 @@ const EmailDetails: React.FC = (): React.ReactElement => {
|
||||
const [email, setEmail] = useState<Email | null>(null);
|
||||
const [isLoading, setIsLoading] = useMinDurationLoading(true, 150);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showMetadata, setShowMetadata] = useState(false);
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
const [headerButtonsConfigured, setHeaderButtonsConfigured] = useState(false);
|
||||
@@ -207,21 +208,44 @@ const EmailDetails: React.FC = (): React.ReactElement => {
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{email.subject}</h1>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>{t('emails.from')} {email.fromDisplay} ({email.fromLocal}@{email.fromDomain})</p>
|
||||
<p>{t('emails.to')} {email.toLocal}@{email.toDomain}</p>
|
||||
<p>{t('emails.date')} {new Date(email.dateSystem).toLocaleString()}</p>
|
||||
<div>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-bold text-gray-900 dark:text-white">{email.subject}</h1>
|
||||
<button
|
||||
onClick={() => setShowMetadata(!showMetadata)}
|
||||
className="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title={showMetadata ? t('common.hideDetails') : t('common.showDetails')}
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${showMetadata ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showMetadata && (
|
||||
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||
<p><span className="font-bold">{t('emails.from')}</span> <span title={email.fromLocal + "@" + email.fromDomain}>{email.fromDisplay}</span></p>
|
||||
<p><span className="font-bold">{t('emails.to')}</span> <span title={email.toLocal + "@" + email.toDomain}>{email.toLocal}@{email.toDomain}</span></p>
|
||||
<p><span className="font-bold">{t('emails.date')}</span> {new Date(email.dateSystem).toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Body */}
|
||||
<div className="bg-white">
|
||||
<div className="bg-white mt-4">
|
||||
{email.messageHtml ? (
|
||||
<iframe
|
||||
srcDoc={ConversionUtility.convertAnchorTagsToOpenInNewTab(email.messageHtml)}
|
||||