Compare commits
497 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa630984e3 | ||
|
|
b894338869 | ||
|
|
d7ec6583f0 | ||
|
|
836fbc1941 | ||
|
|
c531096a98 | ||
|
|
b78a757728 | ||
|
|
f676fba980 | ||
|
|
003e3e4d1d | ||
|
|
637362856a | ||
|
|
b855896108 | ||
|
|
a92bbef41a | ||
|
|
dccbda7515 | ||
|
|
a45a468e35 | ||
|
|
97dc5f3570 | ||
|
|
425a977af9 | ||
|
|
30635d9714 | ||
|
|
cb2aa833bc | ||
|
|
f7b66ed307 | ||
|
|
85e33a9fcd | ||
|
|
51dc4d2844 | ||
|
|
b1d12af7dd | ||
|
|
ae4fc13330 | ||
|
|
e1c5b5f753 | ||
|
|
9ff7c6c23b | ||
|
|
40fdb4e21a | ||
|
|
72254f38ff | ||
|
|
274cb70d4b | ||
|
|
a30e68e0f8 | ||
|
|
fe0678f217 | ||
|
|
aab69ab1b4 | ||
|
|
02575d7366 | ||
|
|
b218ebf407 | ||
|
|
2043e94a91 | ||
|
|
e6bc3ea652 | ||
|
|
92b072868e | ||
|
|
aab7b475cc | ||
|
|
1e75d3806b | ||
|
|
e9bd073bac | ||
|
|
da496b31a1 | ||
|
|
2e34e64c6c | ||
|
|
0da8661d6c | ||
|
|
1797ed9ec6 | ||
|
|
4d613175ed | ||
|
|
a937098315 | ||
|
|
c3be660c1e | ||
|
|
9b622c8fb4 | ||
|
|
986c028d82 | ||
|
|
428c715ec2 | ||
|
|
4ae8839d9b | ||
|
|
a199b9e8da | ||
|
|
ae7eb2ca1a | ||
|
|
06b510c496 | ||
|
|
020e83d40f | ||
|
|
3b14bbcca4 | ||
|
|
e97bf6d168 | ||
|
|
76b829eb3d | ||
|
|
07b6097d31 | ||
|
|
81b6479682 | ||
|
|
9016a4b0b8 | ||
|
|
786bf655d0 | ||
|
|
bdfea51319 | ||
|
|
8ce636a5c1 | ||
|
|
9d4ceff4ba | ||
|
|
d562b183c5 | ||
|
|
3e7848bb3b | ||
|
|
e4614c8034 | ||
|
|
c404fa807f | ||
|
|
fa366cf2e6 | ||
|
|
3653ec3d55 | ||
|
|
4d74504882 | ||
|
|
29c7644b53 | ||
|
|
648fe0598d | ||
|
|
2a3a35f562 | ||
|
|
359f911057 | ||
|
|
267f2d3d17 | ||
|
|
80abfecd2e | ||
|
|
42524d1412 | ||
|
|
81750c4878 | ||
|
|
5c9d9c6933 | ||
|
|
ec8cb7836a | ||
|
|
a64f7d97e5 | ||
|
|
32fe2156f1 | ||
|
|
6aa43bb1a2 | ||
|
|
f9d7918e0a | ||
|
|
076060e7f3 | ||
|
|
4d7d061e07 | ||
|
|
582ab7d20a | ||
|
|
bcd1353cf7 | ||
|
|
eaa348bb23 | ||
|
|
0db3e2dbf4 | ||
|
|
728af0bff6 | ||
|
|
7923c16c51 | ||
|
|
18a5e062a5 | ||
|
|
1097218ee1 | ||
|
|
0a8722226b | ||
|
|
63cc511a9f | ||
|
|
5367c5eb34 | ||
|
|
f7b0084eba | ||
|
|
09d4ba46fa | ||
|
|
fb33e688df | ||
|
|
9017d0b642 | ||
|
|
f50fe913fb | ||
|
|
7b78552651 | ||
|
|
e7d7d9fe54 | ||
|
|
fdfe4b0aa8 | ||
|
|
6b2737eec5 | ||
|
|
79f1bca7a2 | ||
|
|
224e4ee741 | ||
|
|
9a453a1fab | ||
|
|
4cb7966492 | ||
|
|
dbfee0f5b6 | ||
|
|
94bad91411 | ||
|
|
9dc9ed9ba1 | ||
|
|
686ea56556 | ||
|
|
73f95b3a77 | ||
|
|
198fc57d93 | ||
|
|
fd64ea8647 | ||
|
|
4b9e2ba2e3 | ||
|
|
e849762985 | ||
|
|
868e708957 | ||
|
|
49fa36eedb | ||
|
|
f049399d9e | ||
|
|
b00e7c3ac5 | ||
|
|
31c7832745 | ||
|
|
3cc8c9f5de | ||
|
|
ccf923bc98 | ||
|
|
039e63f5c8 | ||
|
|
52b60e07d2 | ||
|
|
95a5391589 | ||
|
|
c8277be56f | ||
|
|
66115496fb | ||
|
|
6f89be6980 | ||
|
|
da36af15ae | ||
|
|
aa218f4f8f | ||
|
|
558d39ec96 | ||
|
|
4b59776b86 | ||
|
|
4a0c6d9499 | ||
|
|
f2bd892a5b | ||
|
|
dd1d6e64e1 | ||
|
|
73ae2a7b62 | ||
|
|
d9c914d09e | ||
|
|
74fd6c1656 | ||
|
|
f4cd3ae87f | ||
|
|
563941f913 | ||
|
|
1751a4c242 | ||
|
|
7b6170e927 | ||
|
|
e5ed8d380f | ||
|
|
30f03884c8 | ||
|
|
0ddd24c40e | ||
|
|
232245fd76 | ||
|
|
bb1549458f | ||
|
|
c63b7ceac4 | ||
|
|
987de6625f | ||
|
|
9efe878397 | ||
|
|
ec90890870 | ||
|
|
bdc405a836 | ||
|
|
27e411f485 | ||
|
|
108ec1869c | ||
|
|
e1b05b611e | ||
|
|
7d2630e197 | ||
|
|
9df5f6c81a | ||
|
|
93adb6d60f | ||
|
|
6abce9e9cf | ||
|
|
534d82990d | ||
|
|
fb28827f15 | ||
|
|
b14f22f9ad | ||
|
|
d5dee592ab | ||
|
|
b0df4c410a | ||
|
|
f09ce7ffcf | ||
|
|
b6609706e8 | ||
|
|
19620bff8e | ||
|
|
9da243fdac | ||
|
|
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 |
10
.env.example
@@ -37,11 +37,19 @@ FORCE_HTTPS_REDIRECT=true
|
||||
# your DNS. Please refer to the full documentation for more instructions on DNS:
|
||||
# https://docs.aliasvault.net/installation/install.html#3-email-server-setup
|
||||
#
|
||||
# Set the private email domains below that are allowed to be used (comma separated values).
|
||||
# Set the private email domains below that the server should accept incoming mail for (comma separated values).
|
||||
# Example: PRIVATE_EMAIL_DOMAINS=example.com,example2.org
|
||||
# To disable the private email domains feature, keep this empty.
|
||||
PRIVATE_EMAIL_DOMAINS=
|
||||
|
||||
# Set private email domains that should be hidden from UI components (comma separated values).
|
||||
# These domains will still function as private email domains for receiving email and claims,
|
||||
# but will not appear in domain selection dropdowns or settings. This is useful for deprecating
|
||||
# legacy domains while maintaining backwards compatibility.
|
||||
# Example: HIDDEN_PRIVATE_EMAIL_DOMAINS=old-domain.com,deprecated.org
|
||||
# Note: Domains listed here should ALSO be included in PRIVATE_EMAIL_DOMAINS above.
|
||||
HIDDEN_PRIVATE_EMAIL_DOMAINS=
|
||||
|
||||
# Enable TLS for SMTP.
|
||||
# ⚠️ Requires valid TLS certificates on your mail server (not provided by the AliasVault installer).
|
||||
# If set to true without proper certificates, the SMTP service will fail to start.
|
||||
|
||||
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
|
||||
|
||||
53
.github/workflows/dotnet-e2e-tests.yml
vendored
@@ -24,6 +24,7 @@ jobs:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/server
|
||||
run: dotnet workload install wasm-tools
|
||||
|
||||
- name: Build
|
||||
@@ -67,6 +68,7 @@ jobs:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/server
|
||||
run: dotnet workload install wasm-tools
|
||||
|
||||
- name: Build
|
||||
@@ -86,3 +88,54 @@ 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
|
||||
working-directory: apps/server
|
||||
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
|
||||
|
||||
@@ -24,6 +24,7 @@ jobs:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/server
|
||||
run: dotnet workload install wasm-tools
|
||||
|
||||
- name: Build
|
||||
|
||||
1
.github/workflows/dotnet-unit-tests.yml
vendored
@@ -23,6 +23,7 @@ jobs:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/server
|
||||
run: dotnet workload install wasm-tools
|
||||
|
||||
- name: Restore dependencies
|
||||
|
||||
3
.gitignore
vendored
@@ -431,3 +431,6 @@ temp
|
||||
|
||||
# Android keystore file (for publishing to Google Play)
|
||||
*.keystore
|
||||
|
||||
# Safari extension build files
|
||||
apps/browser-extension/safari-xcode/AliasVault/build
|
||||
|
||||
5
.vscode/AliasVault.code-workspace
vendored
@@ -23,5 +23,8 @@
|
||||
"path": "../shared"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
"settings": {
|
||||
"java.configuration.updateBuildConfiguration": "disabled",
|
||||
"i18n-ally.keystyle": "nested"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
# 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.
|
||||
AliasVault implements zero-knowledge encryption using a combination of encryption algorithms to protect the privacy of its users.
|
||||
|
||||
The basic premise is that the master password chosen by the user upon registration forms the basis for all encryption
|
||||
and decryption operations. This master password is never transmitted over the network and only resides on the client.
|
||||
All data is encrypted at rest and in transit. This ensures that even if the AliasVault servers are compromised,
|
||||
the user's data remains secure.
|
||||
The basic premise is that the master password chosen by the user upon registration forms the basis for all encryption and decryption operations. This master password is never transmitted over the network and only resides on the client.
|
||||
|
||||
### What is Zero-Knowledge Encrypted
|
||||
- **Vault Data**: Your entire vault (passwords, usernames, notes, passkeys, email addresses, etc.) is fully encrypted client-side before being sent to the server. The server cannot decrypt any vault contents.
|
||||
- **Email Contents**: When emails are received by the server, their contents are immediately encrypted with your public key (from your vault) before being saved. Only you can decrypt and read these emails with your private key.
|
||||
|
||||
This ensures that even if the AliasVault servers are compromised, vault contents and email messages remain secure and unreadable.
|
||||
|
||||
## 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
|
||||
- [Login with Mobile](#login-with-mobile) - Unlock vault in web app / browser extension via mobile app
|
||||
|
||||
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 +101,67 @@ 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.
|
||||
|
||||
### Login with Mobile
|
||||
AliasVault provides a secure "Login with Mobile" feature that allows users to unlock their vault on web browsers or browser extensions by scanning a QR code with their authenticated mobile app. This convenient authentication method maintains zero-knowledge security through hybrid encryption.
|
||||
|
||||
#### Implementation Details
|
||||
The mobile login system combines RSA-2048 asymmetric encryption with AES-256-GCM symmetric encryption:
|
||||
|
||||
1. **Initiation**: Browser/extension client generates an RSA-2048 key pair locally and sends the public key to the server, which returns a unique request ID displayed as a QR code.
|
||||
|
||||
2. **Authorization**: Mobile app scans the QR code, encrypts the user's vault decryption key with the RSA public key, and sends it to the server.
|
||||
|
||||
3. **Retrieval**: Browser client polls the server for completion. When ready, the server:
|
||||
- Generates fresh JWT tokens for the session
|
||||
- Creates a random AES-256 symmetric key
|
||||
- Encrypts tokens and username with the symmetric key
|
||||
- Encrypts the symmetric key with the client's RSA public key
|
||||
- Returns encrypted data and immediately purges it from the database
|
||||
|
||||
4. **Decryption**: Client uses its RSA private key to decrypt the symmetric key, then uses the symmetric key to decrypt tokens and username, and the RSA private key to decrypt the vault decryption key.
|
||||
|
||||
#### Security Properties
|
||||
- **Zero-Knowledge**: Server never accesses the vault decryption key in plaintext
|
||||
- **One-Time Use**: Requests cannot be retrieved twice; data is immediately cleared after retrieval
|
||||
- **Automatic Expiration**: Unfulfilled requests expire after 2 minutes client-side (3 minutes server-side); fulfilled but unretrieved requests auto-delete within 24 hours
|
||||
- **MITM Protection**: Only the client with the RSA private key can decrypt the response
|
||||
- **Limited Attack Surface**: Short timeout window minimizes QR code interception risks
|
||||
|
||||
More information about the mobile login flow can be found in the [Architecture Documentation](https://docs.aliasvault.net/architecture/#6-login-with-mobile).
|
||||
|
||||
@@ -28,11 +28,22 @@ Help grow the AliasVault community by:
|
||||
|
||||
Help make AliasVault accessible to users worldwide by contributing translations! AliasVault is currently available in English and Dutch, but we're looking for volunteers to help translate it into other languages such as German, French, Spanish, Ukrainian, Italian, and more.
|
||||
|
||||
AliasVault translations are managed through [Crowdin](https://crowdin.com/), an online translation platform. If you’d like to help translate AliasVault into your native language, please [request access to the Crowdin project](https://crowdin.com/project/aliasvault).
|
||||
### UI Translations
|
||||
|
||||
If you're willing to help, we also encourage you to get in contact via [Discord](https://discord.gg/DsaXMTEtpF) to chat (quickest), or contact us via email at [contact@support.aliasvault.net](mailto:contact@support.aliasvault.net) to discuss the language(s) you are willing to contribute to, and so we can answer any technical questions you might have.
|
||||
AliasVault UI translations are managed through [Crowdin](https://crowdin.com/), an online translation platform. If you'd like to help translate AliasVault into your native language, please [request access to the Crowdin project](https://crowdin.com/project/aliasvault).
|
||||
|
||||
Your translation contributions will help make AliasVault more accessible to privacy-conscious users around the world!
|
||||
You can also get in contact via [Discord](https://discord.gg/DsaXMTEtpF) to chat, or via email at [contact@support.aliasvault.net](mailto:contact@support.aliasvault.net) to discuss the language(s) you are willing to contribute to, and so we can answer any technical questions you might have.
|
||||
|
||||
### Identity Generator Translations
|
||||
|
||||
In AliasVault, when creating a new credential AliasVault automatically generates realistic alias identities including: first names, last names and birthdates. For this AliasVault uses dictionaries of possible names per language. You can help to enable AliasVault to generate proper identities in your language too.
|
||||
|
||||
**How to help:**
|
||||
- Create lists of common first names (male and female)
|
||||
- Create a list of common last names (surnames)
|
||||
- Optionally: Decade-specific names for more authentic generations
|
||||
|
||||
Read the specific instructions on how to contribute here: [Identity Generator Translations](https://docs.aliasvault.net/contributing/identity-generator.html).
|
||||
|
||||
## 3. Contributing to the Documentation
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ AliasVault takes security seriously and implements various measures to protect y
|
||||
- Zero-knowledge architecture ensures the server never has access to your unencrypted data
|
||||
|
||||
For detailed information about our encryption implementation and security architecture, see the following documents:
|
||||
- [SECURITY.md](SECURITY.md)
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
- [Security Architecture Diagram](https://docs.aliasvault.net/architecture)
|
||||
|
||||
## Features & Roadmap
|
||||
@@ -126,8 +126,9 @@ Core features that are being worked on:
|
||||
- [x] Android native app
|
||||
- [x] Editing in browser extension
|
||||
- [x] Multi-language support across all client applications
|
||||
- [x] Passkeys
|
||||
- [ ] Data model and usability improvements (more flexible aliases and credential types, folder support, bulk selecting etc.)
|
||||
- [ ] Support for FIDO2/WebAuthn hardware keys and passkeys
|
||||
- [ ] Support for FIDO2/WebAuthn hardware keys
|
||||
- [ ] Adding support for family/team sharing (organization features)
|
||||
|
||||
👉 [View the full AliasVault roadmap here](https://github.com/aliasvault/aliasvault/issues/731)
|
||||
|
||||
1
apps/.version/major.txt
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
1
apps/.version/minor.txt
Normal file
@@ -0,0 +1 @@
|
||||
25
|
||||
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.25.0
|
||||
2
apps/browser-extension/.gitignore
vendored
@@ -17,8 +17,6 @@ stats-*.json
|
||||
web-ext.config.ts
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
|
||||
7
apps/browser-extension/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"i18n-ally.localesPaths": [
|
||||
"src/i18n",
|
||||
"src/i18n/locales"
|
||||
],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
}
|
||||
2778
apps/browser-extension/package-lock.json
generated
@@ -2,7 +2,7 @@
|
||||
"name": "aliasvault-browser-extension",
|
||||
"description": "AliasVault Browser Extension",
|
||||
"private": true,
|
||||
"version": "0.23.2",
|
||||
"version": "0.25.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:chrome": "wxt -b chrome",
|
||||
@@ -32,6 +32,7 @@
|
||||
"globals": "^16.0.0",
|
||||
"i18next": "^25.3.1",
|
||||
"otpauth": "^9.3.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.57.0",
|
||||
@@ -47,6 +48,7 @@
|
||||
"@types/chrome": "^0.0.280",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/sql.js": "^1.4.9",
|
||||
@@ -67,6 +69,6 @@
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.6.3",
|
||||
"vite-plugin-static-copy": "^2.3.2",
|
||||
"wxt": "^0.20.6"
|
||||
"wxt": "^0.20.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
CE0CAFE02D81A9F8006174AB /* icon in Resources */ = {isa = PBXBuildFile; fileRef = CE0CAFD82D81A9F8006174AB /* icon */; };
|
||||
CE0CAFE12D81A9F8006174AB /* assets in Resources */ = {isa = PBXBuildFile; fileRef = CE0CAFD92D81A9F8006174AB /* assets */; };
|
||||
CE0CAFE22D81A9F8006174AB /* src in Resources */ = {isa = PBXBuildFile; fileRef = CE0CAFDA2D81A9F8006174AB /* src */; };
|
||||
CE0E5BB02E7D8BA600515C80 /* AliasVault.icon in Resources */ = {isa = PBXBuildFile; fileRef = CE0E5BAF2E7D8BA600515C80 /* AliasVault.icon */; };
|
||||
CEA194E42EB6221B00EAB23B /* webauthn.js in Resources */ = {isa = PBXBuildFile; fileRef = CEA194E32EB6221B00EAB23B /* webauthn.js */; };
|
||||
CEA194E72EB6248D00EAB23B /* offscreen.html in Resources */ = {isa = PBXBuildFile; fileRef = CEA194E52EB6248D00EAB23B /* offscreen.html */; };
|
||||
CEA194E82EB6248D00EAB23B /* offscreen.js in Resources */ = {isa = PBXBuildFile; fileRef = CEA194E62EB6248D00EAB23B /* offscreen.js */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -76,6 +80,10 @@
|
||||
CE0CAFD82D81A9F8006174AB /* icon */ = {isa = PBXFileReference; lastKnownFileType = folder; name = icon; path = "../../../dist/safari-mv2/icon"; sourceTree = "<group>"; };
|
||||
CE0CAFD92D81A9F8006174AB /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; name = assets; path = "../../../dist/safari-mv2/assets"; sourceTree = "<group>"; };
|
||||
CE0CAFDA2D81A9F8006174AB /* src */ = {isa = PBXFileReference; lastKnownFileType = folder; name = src; path = "../../../dist/safari-mv2/src"; sourceTree = "<group>"; };
|
||||
CE0E5BAF2E7D8BA600515C80 /* AliasVault.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AliasVault.icon; sourceTree = "<group>"; };
|
||||
CEA194E32EB6221B00EAB23B /* webauthn.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = webauthn.js; path = "/Users/user/Projects/AliasVault/apps/browser-extension/dist/safari-mv2/webauthn.js"; sourceTree = "<absolute>"; };
|
||||
CEA194E52EB6248D00EAB23B /* offscreen.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = offscreen.html; path = "/Users/user/Projects/AliasVault/apps/browser-extension/dist/safari-mv2/offscreen.html"; sourceTree = "<absolute>"; };
|
||||
CEA194E62EB6248D00EAB23B /* offscreen.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = offscreen.js; path = "/Users/user/Projects/AliasVault/apps/browser-extension/dist/safari-mv2/offscreen.js"; sourceTree = "<absolute>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -117,6 +125,7 @@
|
||||
CE0CAFA52D81A9F7006174AB /* AliasVault */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CE0E5BAF2E7D8BA600515C80 /* AliasVault.icon */,
|
||||
CE0CAFA62D81A9F7006174AB /* AppDelegate.swift */,
|
||||
CE0CAFB22D81A9F7006174AB /* ViewController.swift */,
|
||||
CE0CAFB42D81A9F7006174AB /* Main.storyboard */,
|
||||
@@ -154,6 +163,9 @@
|
||||
CE0CAFD22D81A9F8006174AB /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CEA194E52EB6248D00EAB23B /* offscreen.html */,
|
||||
CEA194E62EB6248D00EAB23B /* offscreen.js */,
|
||||
CEA194E32EB6221B00EAB23B /* webauthn.js */,
|
||||
CE0CAFD32D81A9F8006174AB /* background.js */,
|
||||
CE0CAFD42D81A9F8006174AB /* popup.html */,
|
||||
CE0CAFD52D81A9F8006174AB /* chunks */,
|
||||
@@ -248,6 +260,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CE0E5BB02E7D8BA600515C80 /* AliasVault.icon in Resources */,
|
||||
CE0CAFAD2D81A9F7006174AB /* Icon.png in Resources */,
|
||||
CE0CAFB12D81A9F7006174AB /* Script.js in Resources */,
|
||||
CE0CAFAB2D81A9F7006174AB /* Base in Resources */,
|
||||
@@ -262,8 +275,11 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CE0CAFDD2D81A9F8006174AB /* chunks in Resources */,
|
||||
CEA194E42EB6221B00EAB23B /* webauthn.js in Resources */,
|
||||
CE0CAFE02D81A9F8006174AB /* icon in Resources */,
|
||||
CE0CAFE12D81A9F8006174AB /* assets in Resources */,
|
||||
CEA194E72EB6248D00EAB23B /* offscreen.html in Resources */,
|
||||
CEA194E82EB6248D00EAB23B /* offscreen.js in Resources */,
|
||||
CE0CAFE22D81A9F8006174AB /* src in Resources */,
|
||||
CE0CAFDB2D81A9F8006174AB /* background.js in Resources */,
|
||||
CE0CAFDF2D81A9F8006174AB /* manifest.json in Resources */,
|
||||
@@ -447,7 +463,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 230200;
|
||||
CURRENT_PROJECT_VERSION = 2500900;
|
||||
DEVELOPMENT_TEAM = 8PHW4HN3F7;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -460,7 +476,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 0.23.2;
|
||||
MARKETING_VERSION = 0.25.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -479,7 +495,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = "AliasVault Extension/AliasVault_Extension.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 230200;
|
||||
CURRENT_PROJECT_VERSION = 2500900;
|
||||
DEVELOPMENT_TEAM = 8PHW4HN3F7;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -492,7 +508,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 0.23.2;
|
||||
MARKETING_VERSION = 0.25.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -509,13 +525,14 @@
|
||||
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_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 230200;
|
||||
CURRENT_PROJECT_VERSION = 2500900;
|
||||
DEVELOPMENT_TEAM = 8PHW4HN3F7;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -530,7 +547,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 0.23.2;
|
||||
MARKETING_VERSION = 0.25.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -549,12 +566,12 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AliasVault;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = AliasVault/AliasVault.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 230200;
|
||||
CURRENT_PROJECT_VERSION = 2500900;
|
||||
DEVELOPMENT_TEAM = 8PHW4HN3F7;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -569,7 +586,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 0.23.2;
|
||||
MARKETING_VERSION = 0.25.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
|
||||
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"fill" : "automatic",
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"blend-mode" : "overlay",
|
||||
"fill" : {
|
||||
"automatic-gradient" : "display-p3:0.90471,0.76358,0.48553,1.00000"
|
||||
},
|
||||
"glass" : true,
|
||||
"hidden" : false,
|
||||
"image-name" : "icon-1024.png",
|
||||
"name" : "icon-1024"
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "16x16",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-16@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "16x16",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-16@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "32x32",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-32@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "32x32",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-32@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "128x128",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-128@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "128x128",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-128@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "256x256",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-256@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "256x256",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-256@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "512x512",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-512@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "512x512",
|
||||
"idiom" : "mac",
|
||||
"filename" : "mac-icon-512@2x.png",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 721 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 160 KiB |
192
apps/browser-extension/safari-xcode/AliasVault/build-and-submit.sh
Executable file
@@ -0,0 +1,192 @@
|
||||
#!/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"
|
||||
if ! xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE_PATH" \
|
||||
-exportOptionsPlist "$EXPORT_PLIST" \
|
||||
-exportPath "$EXPORT_DIR" \
|
||||
-allowProvisioningUpdates; then
|
||||
echo "❌ Failed to export archive to PKG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PKG_PATH=$(ls "$EXPORT_DIR"/*.pkg 2>/dev/null)
|
||||
|
||||
if [ -z "$PKG_PATH" ]; then
|
||||
echo "❌ No PKG file found in $EXPORT_DIR after export"
|
||||
echo "Contents of export directory:"
|
||||
ls -la "$EXPORT_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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 " PKG Path: $PKG_PATH"
|
||||
echo " Version: $VERSION"
|
||||
echo " Build: $BUILD"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Validate PKG_PATH is set and file exists
|
||||
if [ -z "$PKG_PATH" ] || [ ! -f "$PKG_PATH" ]; then
|
||||
echo "❌ Error: PKG file not found at: $PKG_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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-connect</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
<key>destination</key>
|
||||
<string>export</string>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>compileBitcode</key>
|
||||
<true/>
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,13 +1,18 @@
|
||||
/**
|
||||
* Background script entry point - handles messages from the content script
|
||||
*/
|
||||
|
||||
import { onMessage, sendMessage } from "webext-bridge/background";
|
||||
|
||||
import { handleResetAutoLockTimer, handlePopupHeartbeat, handleSetAutoLockTimeout } from '@/entrypoints/background/AutolockTimeoutHandler';
|
||||
import { handleClipboardCopied, handleCancelClipboardClear, handleGetClipboardClearTimeout, handleSetClipboardClearTimeout, handleGetClipboardCountdownState } from '@/entrypoints/background/ClipboardClearHandler';
|
||||
import { setupContextMenus } from '@/entrypoints/background/ContextMenu';
|
||||
import { handleGetWebAuthnSettings, handleWebAuthnCreate, handleWebAuthnGet, handlePasskeyPopupResponse, handleGetRequestData } from '@/entrypoints/background/PasskeyHandler';
|
||||
import { handleOpenPopup, handlePopupWithCredential, handleOpenPopupCreateCredential, handleToggleContextMenu } from '@/entrypoints/background/PopupMessageHandler';
|
||||
import { handleCheckAuthStatus, handleClearPersistedFormValues, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDefaultIdentitySettings, handleGetEncryptionKey, handleGetEncryptionKeyDerivationParams, handleGetPasswordSettings, handleGetPersistedFormValues, handleGetVault, handlePersistFormValues, handleStoreEncryptionKey, handleStoreEncryptionKeyDerivationParams, handleStoreVault, handleSyncVault, handleUploadVault } from '@/entrypoints/background/VaultMessageHandler';
|
||||
|
||||
import { GLOBAL_CONTEXT_MENU_ENABLED_KEY } from '@/utils/Constants';
|
||||
import type { EncryptionKeyDerivationParams } from '@/utils/dist/shared/models/metadata';
|
||||
import { EncryptionKeyDerivationParams } from "@/utils/dist/shared/models/metadata";
|
||||
|
||||
import { defineBackground, storage, browser } from '#imports';
|
||||
|
||||
@@ -35,7 +40,6 @@ export default defineBackground({
|
||||
onMessage('CREATE_IDENTITY', ({ data }) => handleCreateIdentity(data));
|
||||
onMessage('UPLOAD_VAULT', ({ data }) => handleUploadVault(data));
|
||||
onMessage('SYNC_VAULT', () => handleSyncVault());
|
||||
|
||||
onMessage('CLEAR_VAULT', () => handleClearVault());
|
||||
|
||||
onMessage('OPEN_POPUP', () => handleOpenPopup());
|
||||
@@ -62,6 +66,13 @@ export default defineBackground({
|
||||
// Handle clipboard copied from context menu
|
||||
onMessage('CLIPBOARD_COPIED_FROM_CONTEXT', () => handleClipboardCopied());
|
||||
|
||||
// Passkey/WebAuthn management messages
|
||||
onMessage('GET_WEBAUTHN_SETTINGS', ({ data }) => handleGetWebAuthnSettings(data));
|
||||
onMessage('WEBAUTHN_CREATE', ({ data }) => handleWebAuthnCreate(data));
|
||||
onMessage('WEBAUTHN_GET', ({ data }) => handleWebAuthnGet(data));
|
||||
onMessage('PASSKEY_POPUP_RESPONSE', ({ data }) => handlePasskeyPopupResponse(data));
|
||||
onMessage('GET_REQUEST_DATA', ({ data }) => handleGetRequestData(data));
|
||||
|
||||
// Setup context menus
|
||||
const isContextMenuEnabled = await storage.getItem(GLOBAL_CONTEXT_MENU_ENABLED_KEY) ?? true;
|
||||
if (isContextMenuEnabled) {
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* PasskeyHandler - Handles passkey popup management in background
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { handleGetEncryptionKey } from '@/entrypoints/background/VaultMessageHandler';
|
||||
import { extractDomain, extractRootDomain } from '@/entrypoints/contentScript/CredentialMatcher';
|
||||
|
||||
import {
|
||||
PASSKEY_PROVIDER_ENABLED_KEY,
|
||||
PASSKEY_DISABLED_SITES_KEY
|
||||
} from '@/utils/Constants';
|
||||
import { EncryptionUtility } from '@/utils/EncryptionUtility';
|
||||
import { PasskeyHelper } from '@/utils/passkey/PasskeyHelper';
|
||||
import type {
|
||||
PasskeyPopupResponse,
|
||||
WebAuthnCreateRequest,
|
||||
WebAuthnGetRequest,
|
||||
PendingPasskeyRequest,
|
||||
PendingPasskeyCreateRequest,
|
||||
PendingPasskeyGetRequest,
|
||||
WebAuthnSettingsResponse,
|
||||
WebAuthnCreationPayload,
|
||||
WebAuthnPublicKeyGetPayload
|
||||
} from '@/utils/passkey/types';
|
||||
import { SqliteClient } from '@/utils/SqliteClient';
|
||||
|
||||
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, isAutomaticRequest } = data as WebAuthnGetRequest;
|
||||
const requestId = Math.random().toString(36).substr(2, 9);
|
||||
|
||||
/*
|
||||
* If this is an automatic request (within 2 seconds of page load), check if we have matching credentials
|
||||
* before opening the popup. This prevents AliasVault from blocking other password managers when we
|
||||
* don't have the passkey they need.
|
||||
*/
|
||||
if (isAutomaticRequest) {
|
||||
try {
|
||||
// Check if we have any matching passkeys in storage
|
||||
const hasMatchingPasskeys = await checkForMatchingPasskeys(publicKey, origin);
|
||||
|
||||
if (!hasMatchingPasskeys) {
|
||||
// No matching passkeys - don't intercept, let other password managers handle it
|
||||
return { fallback: true };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for matching passkeys:', error);
|
||||
// On error, fall back to showing the popup (better UX than silently failing)
|
||||
}
|
||||
}
|
||||
|
||||
// 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' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have any matching passkeys for the given request.
|
||||
* This is used to determine if we should intercept automatic passkey requests.
|
||||
*/
|
||||
async function checkForMatchingPasskeys(publicKey: any, origin: string): Promise<boolean> {
|
||||
try {
|
||||
// Check if vault is unlocked
|
||||
const encryptedVault = await storage.getItem('session:encryptedVault') as string;
|
||||
const encryptionKey = await handleGetEncryptionKey();
|
||||
|
||||
if (!encryptedVault || !encryptionKey) {
|
||||
/*
|
||||
* Vault is locked - we can't check for passkeys
|
||||
* In this case, we return false to avoid intercepting
|
||||
*/
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decrypt and load the vault
|
||||
const decryptedVault = await EncryptionUtility.symmetricDecrypt(
|
||||
encryptedVault,
|
||||
encryptionKey
|
||||
);
|
||||
const sqliteClient = new SqliteClient();
|
||||
await sqliteClient.initializeFromBase64(decryptedVault);
|
||||
|
||||
// Get the rpId from the request or derive from origin
|
||||
const rpId = publicKey.rpId || new URL(origin).hostname;
|
||||
|
||||
// Get passkeys for this rpId
|
||||
const passkeys = sqliteClient.getPasskeysByRpId(rpId);
|
||||
|
||||
// If allowCredentials is specified, filter by those specific credentials
|
||||
if (publicKey.allowCredentials && publicKey.allowCredentials.length > 0) {
|
||||
// Convert the RP's base64url credential IDs to GUIDs for comparison
|
||||
const allowedGuids = new Set(
|
||||
publicKey.allowCredentials.map((c: any) => {
|
||||
try {
|
||||
return PasskeyHelper.base64urlToGuid(c.id);
|
||||
} catch (e) {
|
||||
console.warn('Failed to convert credential ID to GUID:', c.id, e);
|
||||
return null;
|
||||
}
|
||||
}).filter((id: string | null): id is string => id !== null)
|
||||
);
|
||||
|
||||
// Check if we have any of the allowed credentials
|
||||
const matchingPasskeys = passkeys.filter(pk => allowedGuids.has(pk.Id));
|
||||
return matchingPasskeys.length > 0;
|
||||
}
|
||||
|
||||
// No allowCredentials specified - just check if we have any passkeys for this rpId
|
||||
return passkeys.length > 0;
|
||||
} catch (error) {
|
||||
console.error('Error in checkForMatchingPasskeys:', error);
|
||||
// On error, return false to avoid intercepting
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle response from passkey popup
|
||||
*/
|
||||
export async function handlePasskeyPopupResponse(data: any): Promise<{ success: boolean }> {
|
||||
const { requestId, credential, fallback, cancelled } = data as PasskeyPopupResponse;
|
||||
const request = pendingRequests.get(requestId);
|
||||
|
||||
if (!request) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the popup window from background script to ensure it always works.
|
||||
* Calling window.close() from the popup does not work in all browsers.
|
||||
*/
|
||||
if (request.windowId) {
|
||||
try {
|
||||
await browser.windows.remove(request.windowId);
|
||||
} catch (error) {
|
||||
// Window might already be closed, ignore error
|
||||
console.debug('Failed to close popup window:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up both maps
|
||||
pendingRequests.delete(requestId);
|
||||
pendingRequestData.delete(requestId);
|
||||
|
||||
if (cancelled) {
|
||||
request.resolve({ cancelled: true });
|
||||
} else if (fallback) {
|
||||
request.resolve({ fallback: true });
|
||||
} else if (credential) {
|
||||
request.resolve({ credential });
|
||||
} else {
|
||||
request.resolve({ cancelled: true });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request data by request ID
|
||||
*/
|
||||
export async function handleGetRequestData(data: any): Promise<PendingPasskeyRequest | null> {
|
||||
const { requestId } = data as { requestId: string };
|
||||
const requestData = pendingRequestData.get(requestId);
|
||||
return requestData || null;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { EncryptionKeyDerivationParams } from '@/utils/dist/shared/models/m
|
||||
import type { Vault, VaultResponse, VaultPostResponse } from '@/utils/dist/shared/models/webapi';
|
||||
import { EncryptionUtility } from '@/utils/EncryptionUtility';
|
||||
import { SqliteClient } from '@/utils/SqliteClient';
|
||||
import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError';
|
||||
import { BoolResponse as messageBoolResponse } from '@/utils/types/messaging/BoolResponse';
|
||||
import { CredentialsResponse as messageCredentialsResponse } from '@/utils/types/messaging/CredentialsResponse';
|
||||
import { IdentitySettingsResponse } from '@/utils/types/messaging/IdentitySettingsResponse';
|
||||
@@ -24,9 +25,10 @@ export async function handleCheckAuthStatus() : Promise<{ isLoggedIn: boolean, i
|
||||
const username = await storage.getItem('local:username');
|
||||
const accessToken = await storage.getItem('local:accessToken');
|
||||
const vaultData = await storage.getItem('session:encryptedVault');
|
||||
const encryptionKey = await handleGetEncryptionKey();
|
||||
|
||||
const isLoggedIn = username !== null && accessToken !== null;
|
||||
const isVaultLocked = isLoggedIn && vaultData === null;
|
||||
const isVaultLocked = isLoggedIn && (vaultData === null || encryptionKey === null);
|
||||
|
||||
// If vault is locked, we can't check for pending migrations
|
||||
if (isVaultLocked) {
|
||||
@@ -57,6 +59,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,
|
||||
@@ -91,6 +105,10 @@ export async function handleStoreVault(
|
||||
await storage.setItem('session:privateEmailDomains', vaultRequest.privateEmailDomainList);
|
||||
}
|
||||
|
||||
if (vaultRequest.hiddenPrivateEmailDomainList) {
|
||||
await storage.setItem('session:hiddenPrivateEmailDomains', vaultRequest.hiddenPrivateEmailDomainList);
|
||||
}
|
||||
|
||||
if (vaultRequest.vaultRevisionNumber) {
|
||||
await storage.setItem('session:vaultRevisionNumber', vaultRequest.vaultRevisionNumber);
|
||||
}
|
||||
@@ -98,7 +116,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') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +131,7 @@ export async function handleStoreEncryptionKey(
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to store encryption key:', error);
|
||||
return { success: false, error: await t('common.errors.failedToStoreEncryptionKey') };
|
||||
return { success: false, error: await t('common.errors.unknownErrorTryAgain') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +146,7 @@ export async function handleStoreEncryptionKeyDerivationParams(
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to store encryption key derivation params:', error);
|
||||
return { success: false, error: await t('common.errors.failedToStoreEncryptionParams') };
|
||||
return { success: false, error: await t('common.errors.unknownErrorTryAgain') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +172,7 @@ export async function handleSyncVault(
|
||||
{ key: 'session:encryptedVault', value: vaultResponse.vault.blob },
|
||||
{ key: 'session:publicEmailDomains', value: vaultResponse.vault.publicEmailDomainList },
|
||||
{ key: 'session:privateEmailDomains', value: vaultResponse.vault.privateEmailDomainList },
|
||||
{ key: 'session:hiddenPrivateEmailDomains', value: vaultResponse.vault.hiddenPrivateEmailDomainList },
|
||||
{ key: 'session:vaultRevisionNumber', value: vaultResponse.vault.currentRevisionNumber }
|
||||
]);
|
||||
}
|
||||
@@ -172,6 +191,7 @@ export async function handleGetVault(
|
||||
const encryptedVault = await storage.getItem('session:encryptedVault') as string;
|
||||
const publicEmailDomains = await storage.getItem('session:publicEmailDomains') as string[];
|
||||
const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[];
|
||||
const hiddenPrivateEmailDomains = await storage.getItem('session:hiddenPrivateEmailDomains') as string[] ?? [];
|
||||
const vaultRevisionNumber = await storage.getItem('session:vaultRevisionNumber') as number;
|
||||
|
||||
if (!encryptedVault) {
|
||||
@@ -194,11 +214,12 @@ export async function handleGetVault(
|
||||
vault: decryptedVault,
|
||||
publicEmailDomains: publicEmailDomains ?? [],
|
||||
privateEmailDomains: privateEmailDomains ?? [],
|
||||
hiddenPrivateEmailDomains: hiddenPrivateEmailDomains ?? [],
|
||||
vaultRevisionNumber: vaultRevisionNumber ?? 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get vault:', error);
|
||||
return { success: false, error: await t('common.errors.failedToRetrieveData') };
|
||||
return { success: false, error: await t('common.errors.unknownError') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +236,7 @@ export function handleClearVault(
|
||||
'session:encryptionKeyDerivationParams',
|
||||
'session:publicEmailDomains',
|
||||
'session:privateEmailDomains',
|
||||
'session:hiddenPrivateEmailDomains',
|
||||
'session:vaultRevisionNumber'
|
||||
]);
|
||||
|
||||
@@ -238,7 +260,7 @@ export async function handleGetCredentials(
|
||||
return { success: true, credentials: credentials };
|
||||
} catch (error) {
|
||||
console.error('Error getting credentials:', error);
|
||||
return { success: false, error: await t('common.errors.failedToRetrieveData') };
|
||||
return { success: false, error: await t('common.errors.unknownError') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,28 +321,26 @@ export async function getEmailAddressesForVault(
|
||||
export function handleGetDefaultEmailDomain(): Promise<stringResponse> {
|
||||
return (async (): Promise<stringResponse> => {
|
||||
try {
|
||||
const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[];
|
||||
const publicEmailDomains = await storage.getItem('session:publicEmailDomains') as string[];
|
||||
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
const defaultEmailDomain = sqliteClient.getDefaultEmailDomain(privateEmailDomains, publicEmailDomains);
|
||||
const defaultEmailDomain = await sqliteClient.getDefaultEmailDomain();
|
||||
|
||||
return { success: true, value: defaultEmailDomain ?? undefined };
|
||||
} catch (error) {
|
||||
console.error('Error getting default email domain:', error);
|
||||
return { success: false, error: await t('common.errors.failedToRetrieveData') };
|
||||
return { success: false, error: await t('common.errors.unknownError') };
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default identity settings.
|
||||
* Returns the effective language (with smart UI language matching if no explicit override is set).
|
||||
*/
|
||||
export async function handleGetDefaultIdentitySettings(
|
||||
) : Promise<IdentitySettingsResponse> {
|
||||
try {
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
const language = sqliteClient.getDefaultIdentityLanguage();
|
||||
const language = await sqliteClient.getEffectiveIdentityLanguage();
|
||||
const gender = sqliteClient.getDefaultIdentityGender();
|
||||
|
||||
return {
|
||||
@@ -332,7 +352,7 @@ export async function handleGetDefaultIdentitySettings(
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting default identity settings:', error);
|
||||
return { success: false, error: await t('common.errors.failedToRetrieveData') };
|
||||
return { success: false, error: await t('common.errors.unknownError') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,7 +368,7 @@ export async function handleGetPasswordSettings(
|
||||
return { success: true, settings: passwordSettings };
|
||||
} catch (error) {
|
||||
console.error('Error getting password settings:', error);
|
||||
return { success: false, error: await t('common.errors.failedToRetrieveData') };
|
||||
return { success: false, error: await t('common.errors.unknownError') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,7 +416,7 @@ export async function handleUploadVault(
|
||||
return { success: true, status: response.status, newRevisionNumber: response.newRevisionNumber };
|
||||
} catch (error) {
|
||||
console.error('Failed to upload vault:', error);
|
||||
return { success: false, error: await t('common.errors.failedToUploadVault') };
|
||||
return { success: false, error: await t('common.errors.unknownError') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,13 +503,11 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<Vaul
|
||||
credentialsCount: sqliteClient.getAllCredentials().length,
|
||||
currentRevisionNumber: vaultRevisionNumber,
|
||||
emailAddressList: emailAddresses,
|
||||
privateEmailDomainList: [], // Empty on purpose, API will not use this for vault updates.
|
||||
publicEmailDomainList: [], // Empty on purpose, API will not use this for vault updates.
|
||||
encryptionPublicKey: '', // Empty on purpose, only required if new public/private key pair is generated.
|
||||
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,
|
||||
// TODO: add public RSA encryption key to payload when implementing vault creation from browser extension. Currently only web app does this.
|
||||
encryptionPublicKey: '',
|
||||
};
|
||||
|
||||
const webApi = new WebApiService(() => {});
|
||||
@@ -499,7 +517,7 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<Vaul
|
||||
if (response.status === 0) {
|
||||
await storage.setItem('session:vaultRevisionNumber', response.newRevisionNumber);
|
||||
} else {
|
||||
throw new Error(await t('common.errors.failedToUploadVault'));
|
||||
throw new Error(await t('common.errors.unknownError'));
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
import { CombinedStopWords } from '@/utils/formDetector/FieldPatterns';
|
||||
|
||||
/**
|
||||
* Credential filtering for browser extension autofill.
|
||||
* This implementation follows the unified filtering algorithm specification defined in
|
||||
* docs/CREDENTIAL_FILTERING_SPEC.md for cross-platform consistency with Android and iOS.
|
||||
*
|
||||
* Algorithm Structure (Priority Order with Early Returns):
|
||||
* 1. PRIORITY 1: App Package Name Exact Match (included for consistency, not used in browser)
|
||||
* 2. PRIORITY 2: URL Domain Matching (exact, subdomain, root domain)
|
||||
* 3. PRIORITY 3: Service Name Fallback (only for credentials without URLs - anti-phishing)
|
||||
* 4. PRIORITY 4: Text/Page Title Matching (non-URL search)
|
||||
*/
|
||||
|
||||
export enum AutofillMatchingMode {
|
||||
DEFAULT = 'default',
|
||||
URL_EXACT = 'url_exact',
|
||||
URL_SUBDOMAIN = 'url_subdomain'
|
||||
}
|
||||
|
||||
type CredentialWithPriority = Credential & {
|
||||
priority: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common top-level domains (TLDs) used for app package name detection.
|
||||
* When a search string starts with one of these TLDs followed by a dot (e.g., "com.coolblue.app"),
|
||||
* it's identified as a reversed domain name (app package name) rather than a regular URL.
|
||||
* Note: This is included for cross-platform test consistency but not actively used in browser context.
|
||||
*/
|
||||
const COMMON_TLDS = new Set([
|
||||
// Generic TLDs
|
||||
'com', 'net', 'org', 'edu', 'gov', 'mil', 'int',
|
||||
// Country code TLDs
|
||||
'nl', 'de', 'uk', 'fr', 'it', 'es', 'pl', 'be', 'ch', 'at', 'se', 'no', 'dk', 'fi',
|
||||
'pt', 'gr', 'cz', 'hu', 'ro', 'bg', 'hr', 'sk', 'si', 'lt', 'lv', 'ee', 'ie', 'lu',
|
||||
'us', 'ca', 'mx', 'br', 'ar', 'cl', 'co', 've', 'pe', 'ec',
|
||||
'au', 'nz', 'jp', 'cn', 'in', 'kr', 'tw', 'hk', 'sg', 'my', 'th', 'id', 'ph', 'vn',
|
||||
'za', 'eg', 'ng', 'ke', 'ug', 'tz', 'ma',
|
||||
'ru', 'ua', 'by', 'kz', 'il', 'tr', 'sa', 'ae', 'qa', 'kw',
|
||||
// New gTLDs (common ones)
|
||||
'app', 'dev', 'io', 'ai', 'tech', 'shop', 'store', 'online', 'site', 'website',
|
||||
'blog', 'news', 'media', 'tv', 'video', 'music', 'pro', 'info', 'biz', 'name'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if a string is likely an app package name (reversed domain).
|
||||
* Package names start with TLD followed by dot (e.g., "com.example", "nl.app").
|
||||
* @param text - Text to check
|
||||
* @returns True if it looks like an app package name
|
||||
*/
|
||||
function isAppPackageName(text: string): boolean {
|
||||
// Must contain a dot
|
||||
if (!text.includes('.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not have protocol
|
||||
if (text.startsWith('http://') || text.startsWith('https://')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract first part before first dot
|
||||
const firstPart = text.split('.')[0].toLowerCase();
|
||||
|
||||
// Check if first part is a common TLD - indicates reversed domain (package name)
|
||||
return COMMON_TLDS.has(firstPart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from URL, handling both full URLs and partial domains
|
||||
* @param url - URL or domain string
|
||||
* @returns Normalized domain without protocol or www, or empty string if not a valid URL/domain
|
||||
*/
|
||||
export function extractDomain(url: string): string {
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let domain = url.toLowerCase().trim();
|
||||
|
||||
// Check if it has a protocol
|
||||
const hasProtocol = domain.startsWith('http://') || domain.startsWith('https://');
|
||||
|
||||
/*
|
||||
* If no protocol and starts with TLD + dot, it's likely an app package name
|
||||
* Return empty string to indicate that domain extraction has failed for this string
|
||||
*/
|
||||
if (!hasProtocol && isAppPackageName(domain)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove protocol if present
|
||||
domain = domain.replace(/^https?:\/\//, '');
|
||||
|
||||
// Remove www. prefix
|
||||
domain = domain.replace(/^www\./, '');
|
||||
|
||||
// Remove path, query, and fragment
|
||||
domain = domain.split('/')[0];
|
||||
domain = domain.split('?')[0];
|
||||
domain = domain.split('#')[0];
|
||||
|
||||
// Basic domain validation - must contain at least one dot and valid characters
|
||||
if (!domain.includes('.') || !/^[a-z0-9.-]+$/.test(domain)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Ensure valid domain structure
|
||||
if (domain.startsWith('.') || domain.endsWith('.') || domain.includes('..')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
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
|
||||
* Note: Both parameters should be pre-extracted domains (without protocol, www, path, etc.)
|
||||
* @param domain1 - First domain (pre-extracted)
|
||||
* @param domain2 - Second domain (pre-extracted)
|
||||
* @returns True if domains match (including partial matches)
|
||||
*/
|
||||
function domainsMatch(domain1: string, domain2: string): boolean {
|
||||
if (!domain1 || !domain2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if (domain1 === domain2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if one domain contains the other (for subdomain matching)
|
||||
if (domain1.includes(domain2) || domain2.includes(domain1)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check root domain match
|
||||
const d1Root = extractRootDomain(domain1);
|
||||
const d2Root = extractRootDomain(domain2);
|
||||
|
||||
return d1Root === d2Root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful words from text, removing punctuation and filtering stop words
|
||||
* @param text - Text to extract words from
|
||||
* @returns Array of filtered words
|
||||
*/
|
||||
function extractWords(text: string): string[] {
|
||||
if (!text || text.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return text.toLowerCase()
|
||||
// Replace common separators and punctuation with spaces (including dots)
|
||||
.replace(/[|,;:\-–—/\\()[\]{}'"`~!@#$%^&*+=<>?.]/g, ' ')
|
||||
// Split on whitespace and filter
|
||||
.split(/\s+/)
|
||||
.filter(word =>
|
||||
word.length > 3 &&
|
||||
!CombinedStopWords.has(word)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter credentials based on current URL and page context with anti-phishing protection.
|
||||
*
|
||||
* This method follows a strict priority-based algorithm with early returns:
|
||||
* 1. PRIORITY 1: App Package Name Exact Match (highest priority, included for consistency)
|
||||
* 2. PRIORITY 2: URL Domain Matching
|
||||
* 3. PRIORITY 3: Service Name Fallback (anti-phishing protection)
|
||||
* 4. PRIORITY 4: Text/Page Title Matching (lowest priority)
|
||||
*
|
||||
* @param credentials - List of credentials to filter
|
||||
* @param currentUrl - Current page URL
|
||||
* @param pageTitle - Current page title
|
||||
* @param matchingMode - Matching mode (controls subdomain and fallback behavior)
|
||||
* @returns Filtered list of credentials (max 3)
|
||||
*
|
||||
* **Security Note**: Priority 3 only searches credentials with no service URL defined.
|
||||
* This prevents phishing attacks where a malicious site might match credentials
|
||||
* intended for a legitimate site.
|
||||
*/
|
||||
export function filterCredentials(credentials: Credential[], currentUrl: string, pageTitle: string, matchingMode: AutofillMatchingMode = AutofillMatchingMode.DEFAULT): Credential[] {
|
||||
// Early return for empty URL
|
||||
if (!currentUrl) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/*
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
* PRIORITY 1: App Package Name Exact Match
|
||||
* Check if current URL is an app package name (e.g., com.coolblue.app)
|
||||
* Note: Not used in browser context but included for cross-platform test consistency
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
const isPackageName = isAppPackageName(currentUrl);
|
||||
if (isPackageName) {
|
||||
// Perform exact string match on ServiceUrl field
|
||||
const packageMatches = credentials.filter(cred =>
|
||||
cred.ServiceUrl && cred.ServiceUrl.length > 0 && currentUrl === cred.ServiceUrl
|
||||
);
|
||||
|
||||
// EARLY RETURN if matches found
|
||||
if (packageMatches.length > 0) {
|
||||
return packageMatches.slice(0, 3);
|
||||
}
|
||||
/*
|
||||
* If no matches found, skip URL matching and go directly to text matching (Priority 4)
|
||||
* Package names shouldn't be treated as URLs
|
||||
*/
|
||||
}
|
||||
|
||||
/*
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
* PRIORITY 2: URL Domain Matching
|
||||
* Try to extract domain from current URL (skip if package name)
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
if (!isPackageName) {
|
||||
const currentDomain = extractDomain(currentUrl);
|
||||
|
||||
if (currentDomain) {
|
||||
const filtered: CredentialWithPriority[] = [];
|
||||
|
||||
// Determine matching features based on mode
|
||||
const enableExactMatch = matchingMode !== undefined;
|
||||
const enableSubdomainMatch = matchingMode === AutofillMatchingMode.DEFAULT || matchingMode === AutofillMatchingMode.URL_SUBDOMAIN;
|
||||
|
||||
// Process credentials with service URLs
|
||||
for (const cred of credentials) {
|
||||
if (!cred.ServiceUrl || cred.ServiceUrl.length === 0) {
|
||||
continue; // Handle these in Priority 3
|
||||
}
|
||||
|
||||
const credDomain = extractDomain(cred.ServiceUrl);
|
||||
|
||||
// Check for exact match (priority 1)
|
||||
if (enableExactMatch && currentDomain === credDomain) {
|
||||
filtered.push({ ...cred, priority: 1 });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for subdomain/partial match (priority 2)
|
||||
if (enableSubdomainMatch && domainsMatch(currentDomain, credDomain)) {
|
||||
filtered.push({ ...cred, priority: 2 });
|
||||
}
|
||||
}
|
||||
|
||||
// EARLY RETURN if matches found
|
||||
if (filtered.length > 0) {
|
||||
const uniqueCredentials = Array.from(
|
||||
new Map(
|
||||
filtered
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map(cred => [cred.Id, cred])
|
||||
).values()
|
||||
);
|
||||
return uniqueCredentials.slice(0, 3);
|
||||
}
|
||||
|
||||
/*
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* PRIORITY 3: Page Title / Service Name Fallback (Anti-Phishing Protection)
|
||||
* No domain matches found - search in service names using page title
|
||||
* CRITICAL: Only search credentials with NO service URL defined
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
if (pageTitle) {
|
||||
const titleWords = extractWords(pageTitle);
|
||||
|
||||
if (titleWords.length > 0) {
|
||||
const nameMatches: Credential[] = [];
|
||||
|
||||
for (const cred of credentials) {
|
||||
// SECURITY: Skip credentials that have a URL defined
|
||||
if (cred.ServiceUrl && cred.ServiceUrl.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check page title match with service name
|
||||
if (cred.ServiceName) {
|
||||
const credNameWords = extractWords(cred.ServiceName);
|
||||
|
||||
/*
|
||||
* Match only complete words, not substrings
|
||||
* For example: "Express" should match "My Express Account" but not "AliExpress"
|
||||
*/
|
||||
const hasTitleMatch = titleWords.some(titleWord =>
|
||||
credNameWords.some(credWord => titleWord === credWord)
|
||||
);
|
||||
|
||||
if (hasTitleMatch) {
|
||||
nameMatches.push(cred);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return matches from Priority 3 if any found
|
||||
if (nameMatches.length > 0) {
|
||||
return nameMatches.slice(0, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No matches found in Priority 2 or Priority 3
|
||||
return [];
|
||||
}
|
||||
} // End of Priority 2 (!isPackageName)
|
||||
|
||||
/*
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
* PRIORITY 4: Text Matching
|
||||
* Used when: 1) Package name didn't match in Priority 1, OR 2) URL extraction failed
|
||||
* Performs word-based matching on service names
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
const searchWords = extractWords(currentUrl);
|
||||
|
||||
if (searchWords.length > 0) {
|
||||
return credentials.filter(cred => {
|
||||
const serviceNameWords = cred.ServiceName ? extractWords(cred.ServiceName) : [];
|
||||
|
||||
// Check if any search word matches any service name word exactly
|
||||
return searchWords.some(searchWord =>
|
||||
serviceNameWords.includes(searchWord)
|
||||
);
|
||||
}).slice(0, 3);
|
||||
}
|
||||
|
||||
// No matches found
|
||||
return [];
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
import { CombinedStopWords } from '@/utils/formDetector/FieldPatterns';
|
||||
|
||||
export enum AutofillMatchingMode {
|
||||
DEFAULT = 'default',
|
||||
URL_EXACT = 'url_exact',
|
||||
URL_SUBDOMAIN = 'url_subdomain'
|
||||
}
|
||||
|
||||
type CredentialWithPriority = Credential & {
|
||||
priority: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from URL, handling both full URLs and partial domains
|
||||
* @param url - URL or domain string
|
||||
* @returns Normalized domain without protocol or www
|
||||
*/
|
||||
function extractDomain(url: string): string {
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove protocol if present
|
||||
let domain = url.toLowerCase().trim();
|
||||
domain = domain.replace(/^https?:\/\//, '');
|
||||
|
||||
// Remove www. prefix
|
||||
domain = domain.replace(/^www\./, '');
|
||||
|
||||
// Remove path, query, and fragment
|
||||
domain = domain.split('/')[0];
|
||||
domain = domain.split('?')[0];
|
||||
domain = domain.split('#')[0];
|
||||
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two domains match, supporting partial matches
|
||||
* @param domain1 - First domain
|
||||
* @param domain2 - Second domain
|
||||
* @returns True if domains match (including partial matches)
|
||||
*/
|
||||
function domainsMatch(domain1: string, domain2: string): boolean {
|
||||
if (!domain1 || !domain2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const d1 = extractDomain(domain1);
|
||||
const d2 = extractDomain(domain2);
|
||||
|
||||
// Exact match
|
||||
if (d1 === d2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if one domain contains the other (for subdomain matching)
|
||||
if (d1.includes(d2) || d2.includes(d1)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extract root domains for comparison
|
||||
const d1Parts = d1.split('.');
|
||||
const d2Parts = d2.split('.');
|
||||
|
||||
// Get the last 2 parts (domain.tld) for comparison
|
||||
const d1Root = d1Parts.slice(-2).join('.');
|
||||
const d2Root = d2Parts.slice(-2).join('.');
|
||||
|
||||
return d1Root === d2Root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful words from text, removing punctuation and filtering stop words
|
||||
* @param text - Text to extract words from
|
||||
* @returns Array of filtered words
|
||||
*/
|
||||
function extractWords(text: string): string[] {
|
||||
if (!text || text.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return text.toLowerCase()
|
||||
// Replace common separators and punctuation with spaces
|
||||
.replace(/[|,;:\-–—/\\()[\]{}'"`~!@#$%^&*+=<>?]/g, ' ')
|
||||
// Split on whitespace and filter
|
||||
.split(/\s+/)
|
||||
.filter(word =>
|
||||
word.length > 3 &&
|
||||
!CombinedStopWords.has(word)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter credentials based on current URL and page context with anti-phishing protection.
|
||||
*
|
||||
* **Security Note**: When searching with a URL, text search fallback only applies to
|
||||
* credentials with no service URL defined. This prevents phishing attacks where a
|
||||
* malicious site might match credentials intended for the legitimate site.
|
||||
*
|
||||
* Credentials are sorted by priority:
|
||||
* 1. Exact domain match (priority 1 - highest)
|
||||
* 2. Partial/subdomain match (priority 2)
|
||||
* 3. Service name fallback match (priority 5 - lowest, only for credentials without URLs)
|
||||
*/
|
||||
export function filterCredentials(credentials: Credential[], currentUrl: string, pageTitle: string, matchingMode: AutofillMatchingMode = AutofillMatchingMode.DEFAULT): Credential[] {
|
||||
const filtered: CredentialWithPriority[] = [];
|
||||
const currentDomain = extractDomain(currentUrl);
|
||||
|
||||
// Determine feature flags based on matching mode
|
||||
let enableExactMatch = false;
|
||||
let enableSubdomainMatch = false;
|
||||
let enableServiceNameFallback = false;
|
||||
|
||||
switch (matchingMode) {
|
||||
case AutofillMatchingMode.URL_EXACT:
|
||||
enableExactMatch = true;
|
||||
enableSubdomainMatch = false;
|
||||
enableServiceNameFallback = false;
|
||||
break;
|
||||
|
||||
case AutofillMatchingMode.URL_SUBDOMAIN:
|
||||
enableExactMatch = true;
|
||||
enableSubdomainMatch = true;
|
||||
enableServiceNameFallback = false;
|
||||
break;
|
||||
|
||||
case AutofillMatchingMode.DEFAULT:
|
||||
enableExactMatch = true;
|
||||
enableSubdomainMatch = true;
|
||||
enableServiceNameFallback = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Process credentials with service URLs
|
||||
credentials.forEach(cred => {
|
||||
if (!cred.ServiceUrl || cred.ServiceUrl.length === 0) {
|
||||
return; // Handle these in service name fallback
|
||||
}
|
||||
|
||||
const credDomain = extractDomain(cred.ServiceUrl);
|
||||
|
||||
// Check for exact match (priority 1)
|
||||
if (enableExactMatch && currentDomain === credDomain) {
|
||||
filtered.push({ ...cred, priority: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for subdomain/partial match (priority 2)
|
||||
if (enableSubdomainMatch && domainsMatch(currentDomain, credDomain)) {
|
||||
filtered.push({ ...cred, priority: 2 });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Service name fallback for credentials without URLs (priority 5)
|
||||
if (enableServiceNameFallback) {
|
||||
/*
|
||||
* SECURITY: Service name matching only applies to credentials with no service URL.
|
||||
* This prevents phishing attacks where a malicious site might match credentials
|
||||
* intended for a legitimate site.
|
||||
*/
|
||||
|
||||
// Extract words from page title
|
||||
const titleWords = extractWords(pageTitle);
|
||||
|
||||
if (titleWords.length > 0) {
|
||||
credentials.forEach(cred => {
|
||||
// CRITICAL: Only check credentials that have NO service URL defined
|
||||
if (cred.ServiceUrl && cred.ServiceUrl.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already in filtered list
|
||||
if (filtered.some(f => f.Id === cred.Id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check page title match with service name
|
||||
if (cred.ServiceName) {
|
||||
const credNameWords = extractWords(cred.ServiceName);
|
||||
|
||||
/*
|
||||
* Match only complete words, not substrings
|
||||
* For example: "Express" should match "My Express Account" but not "AliExpress"
|
||||
*/
|
||||
const hasTitleMatch = titleWords.some(titleWord =>
|
||||
credNameWords.some(credWord =>
|
||||
titleWord === credWord // Exact word match only
|
||||
)
|
||||
);
|
||||
|
||||
if (hasTitleMatch) {
|
||||
filtered.push({ ...cred, priority: 5 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority and return unique credentials (max 3)
|
||||
const uniqueCredentials = Array.from(
|
||||
new Map(
|
||||
filtered
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map(cred => [cred.Id, cred])
|
||||
).values()
|
||||
);
|
||||
|
||||
return uniqueCredentials.slice(0, 3);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sendMessage } from 'webext-bridge/content-script';
|
||||
|
||||
import { filterCredentials, AutofillMatchingMode } from '@/entrypoints/contentScript/Filter';
|
||||
import { filterCredentials, AutofillMatchingMode } from '@/entrypoints/contentScript/CredentialMatcher';
|
||||
import { fillCredential } from '@/entrypoints/contentScript/Form';
|
||||
|
||||
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';
|
||||
@@ -633,10 +633,44 @@ function createCredentialList(credentials: Credential[], input: HTMLInputElement
|
||||
const credTextContainer = document.createElement('div');
|
||||
credTextContainer.className = 'av-credential-text';
|
||||
|
||||
// Service name (primary text)
|
||||
// Service name (primary text) with passkey indicator
|
||||
const serviceName = document.createElement('div');
|
||||
serviceName.className = 'av-service-name';
|
||||
serviceName.textContent = cred.ServiceName;
|
||||
|
||||
// Create a flex container for service name and passkey icon
|
||||
const serviceNameContainer = document.createElement('div');
|
||||
serviceNameContainer.style.display = 'flex';
|
||||
serviceNameContainer.style.alignItems = 'center';
|
||||
serviceNameContainer.style.gap = '4px';
|
||||
|
||||
const serviceNameText = document.createElement('span');
|
||||
serviceNameText.textContent = cred.ServiceName;
|
||||
serviceNameContainer.appendChild(serviceNameText);
|
||||
|
||||
// Add passkey indicator if credential has a passkey
|
||||
if (cred.HasPasskey) {
|
||||
const passkeyIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
passkeyIcon.setAttribute('class', 'av-passkey-icon');
|
||||
passkeyIcon.setAttribute('viewBox', '0 0 24 24');
|
||||
passkeyIcon.setAttribute('fill', 'none');
|
||||
passkeyIcon.setAttribute('stroke', 'currentColor');
|
||||
passkeyIcon.setAttribute('stroke-width', '2');
|
||||
passkeyIcon.setAttribute('stroke-linecap', 'round');
|
||||
passkeyIcon.setAttribute('stroke-linejoin', 'round');
|
||||
passkeyIcon.setAttribute('aria-label', 'Has passkey');
|
||||
passkeyIcon.style.width = '14px';
|
||||
passkeyIcon.style.height = '14px';
|
||||
passkeyIcon.style.flexShrink = '0';
|
||||
passkeyIcon.style.opacity = '0.7';
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4');
|
||||
|
||||
passkeyIcon.appendChild(path);
|
||||
serviceNameContainer.appendChild(passkeyIcon);
|
||||
}
|
||||
|
||||
serviceName.appendChild(serviceNameContainer);
|
||||
|
||||
// Details container (secondary text)
|
||||
const detailsContainer = document.createElement('div');
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Track when the page finished loading to detect automatic vs user-initiated requests.
|
||||
* Some websites (like Nintendo, Amazon) automatically trigger passkey requests on page load.
|
||||
* We should filter these if no matching credentials exist.
|
||||
*/
|
||||
let pageLoadTime = 0;
|
||||
const AUTO_REQUEST_THRESHOLD_MS = 1000; // Requests within 1 second of page load are considered "automatic"
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// Track page load time for detecting automatic requests
|
||||
pageLoadTime = Date.now();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Detect if this is an automatic request (within 2 seconds of page load)
|
||||
const isAutomaticRequest = (Date.now() - pageLoadTime) < AUTO_REQUEST_THRESHOLD_MS;
|
||||
|
||||
// Send to background script to handle
|
||||
const result = await sendMessage('WEBAUTHN_GET', {
|
||||
publicKey,
|
||||
origin,
|
||||
isAutomaticRequest
|
||||
}, '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;
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
|
||||
import { filterCredentials } from '../Filter';
|
||||
import { filterCredentials } from '../CredentialMatcher';
|
||||
|
||||
describe('Filter - Credential URL Matching', () => {
|
||||
describe('CredentialMatcher - Credential URL Matching', () => {
|
||||
let testCredentials: Credential[];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -292,6 +292,103 @@ describe('Filter - Credential URL Matching', () => {
|
||||
expect(matches[0].ServiceName).toBe('Reddit');
|
||||
});
|
||||
|
||||
// [#20] - Test reversed domain (app package name) doesn't match on TLD
|
||||
it('should not match credentials based on TLD when filtering reversed domains', () => {
|
||||
/*
|
||||
* Test that dumpert.nl credential doesn't match nl.marktplaats.android package
|
||||
* They both contain "nl" in the name but shouldn't match since "nl" is just a TLD
|
||||
*/
|
||||
const reversedDomainCredentials = [
|
||||
createTestCredential('Dumpert.nl', '', 'user@dumpert.nl'),
|
||||
createTestCredential('Marktplaats.nl', '', 'user@marktplaats.nl'),
|
||||
];
|
||||
|
||||
const matches = filterCredentials(
|
||||
reversedDomainCredentials,
|
||||
'nl.marktplaats.android',
|
||||
''
|
||||
);
|
||||
|
||||
// Should only match Marktplaats, not Dumpert (even though both have "nl")
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].ServiceName).toBe('Marktplaats.nl');
|
||||
});
|
||||
|
||||
// [#21] - Test app package names are properly detected and handled
|
||||
it('should properly handle app package names in filtering', () => {
|
||||
const packageCredentials = [
|
||||
createTestCredential('Google App', 'com.google.android.googlequicksearchbox', 'user@google.com'),
|
||||
createTestCredential('Facebook', 'com.facebook.katana', 'user@facebook.com'),
|
||||
createTestCredential('WhatsApp', 'com.whatsapp', 'user@whatsapp.com'),
|
||||
createTestCredential('Generic Site', 'example.com', 'user@example.com'),
|
||||
];
|
||||
|
||||
// Test com.google.android package matches
|
||||
const googleMatches = filterCredentials(
|
||||
packageCredentials,
|
||||
'com.google.android.googlequicksearchbox',
|
||||
''
|
||||
);
|
||||
expect(googleMatches).toHaveLength(1);
|
||||
expect(googleMatches[0].ServiceName).toBe('Google App');
|
||||
|
||||
// Test com.facebook package matches
|
||||
const facebookMatches = filterCredentials(
|
||||
packageCredentials,
|
||||
'com.facebook.katana',
|
||||
''
|
||||
);
|
||||
expect(facebookMatches).toHaveLength(1);
|
||||
expect(facebookMatches[0].ServiceName).toBe('Facebook');
|
||||
|
||||
// Test that web domain doesn't match package name
|
||||
const webMatches = filterCredentials(
|
||||
packageCredentials,
|
||||
'https://example.com',
|
||||
''
|
||||
);
|
||||
expect(webMatches).toHaveLength(1);
|
||||
expect(webMatches[0].ServiceName).toBe('Generic Site');
|
||||
});
|
||||
|
||||
// [#22] - Test multi-part TLDs like .com.au don't match incorrectly
|
||||
it('should handle multi-part TLDs correctly without false matches', () => {
|
||||
// Create test data with different .com.au domains
|
||||
const australianCredentials = [
|
||||
createTestCredential('Example Site AU', 'https://example.com.au', 'user@example.com.au'),
|
||||
createTestCredential('BlaBla AU', 'https://blabla.blabla.com.au', 'user@blabla.com.au'),
|
||||
createTestCredential('Another AU', 'https://another.com.au', 'user@another.com.au'),
|
||||
createTestCredential('UK Site', 'https://example.co.uk', 'user@example.co.uk'),
|
||||
];
|
||||
|
||||
// Test that blabla.blabla.com.au doesn't match other .com.au sites
|
||||
const blablaMatches = filterCredentials(
|
||||
australianCredentials,
|
||||
'https://blabla.blabla.com.au',
|
||||
''
|
||||
);
|
||||
expect(blablaMatches).toHaveLength(1);
|
||||
expect(blablaMatches[0].ServiceName).toBe('BlaBla AU');
|
||||
|
||||
// Test that example.com.au doesn't match blabla.blabla.com.au
|
||||
const exampleMatches = filterCredentials(
|
||||
australianCredentials,
|
||||
'https://example.com.au',
|
||||
''
|
||||
);
|
||||
expect(exampleMatches).toHaveLength(1);
|
||||
expect(exampleMatches[0].ServiceName).toBe('Example Site AU');
|
||||
|
||||
// Test that .co.uk domains work correctly too
|
||||
const ukMatches = filterCredentials(
|
||||
australianCredentials,
|
||||
'https://example.co.uk',
|
||||
''
|
||||
);
|
||||
expect(ukMatches).toHaveLength(1);
|
||||
expect(ukMatches[0].ServiceName).toBe('UK Site');
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates the shared test credential dataset used across all platforms.
|
||||
* Note: when making changes to this list, make sure to update the corresponding list for iOS and Android tests as well.
|
||||
@@ -1,19 +1,18 @@
|
||||
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 DefaultLayout from '@/entrypoints/popup/components/Layout/DefaultLayout';
|
||||
import Header from '@/entrypoints/popup/components/Layout/Header';
|
||||
import PasskeyLayout from '@/entrypoints/popup/components/Layout/PasskeyLayout';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { NavigationProvider } from '@/entrypoints/popup/context/NavigationContext';
|
||||
import AuthSettings from '@/entrypoints/popup/pages/auth/AuthSettings';
|
||||
import Login from '@/entrypoints/popup/pages/auth/Login';
|
||||
import Logout from '@/entrypoints/popup/pages/auth/Logout';
|
||||
import Unlock from '@/entrypoints/popup/pages/auth/Unlock';
|
||||
import UnlockSuccess from '@/entrypoints/popup/pages/auth/UnlockSuccess';
|
||||
import Upgrade from '@/entrypoints/popup/pages/auth/Upgrade';
|
||||
@@ -23,17 +22,34 @@ import CredentialsList from '@/entrypoints/popup/pages/credentials/CredentialsLi
|
||||
import EmailDetails from '@/entrypoints/popup/pages/emails/EmailDetails';
|
||||
import EmailsList from '@/entrypoints/popup/pages/emails/EmailsList';
|
||||
import Index from '@/entrypoints/popup/pages/Index';
|
||||
import PasskeyAuthenticate from '@/entrypoints/popup/pages/passkeys/PasskeyAuthenticate';
|
||||
import PasskeyCreate from '@/entrypoints/popup/pages/passkeys/PasskeyCreate';
|
||||
import Reinitialize from '@/entrypoints/popup/pages/Reinitialize';
|
||||
import AutofillSettings from '@/entrypoints/popup/pages/settings/AutofillSettings';
|
||||
import AutoLockSettings from '@/entrypoints/popup/pages/settings/AutoLockSettings';
|
||||
import ClipboardSettings from '@/entrypoints/popup/pages/settings/ClipboardSettings';
|
||||
import ContextMenuSettings from '@/entrypoints/popup/pages/settings/ContextMenuSettings';
|
||||
import LanguageSettings from '@/entrypoints/popup/pages/settings/LanguageSettings';
|
||||
import PasskeySettings from '@/entrypoints/popup/pages/settings/PasskeySettings';
|
||||
import Settings from '@/entrypoints/popup/pages/settings/Settings';
|
||||
import VaultUnlockSettings from '@/entrypoints/popup/pages/settings/VaultUnlockSettings';
|
||||
|
||||
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',
|
||||
/** Auth layout for login/unlock pages - no footer menu */
|
||||
AUTH = 'auth',
|
||||
}
|
||||
|
||||
/**
|
||||
* Route configuration.
|
||||
@@ -43,6 +59,107 @@ 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="mb-4 text-red-500 dark:text-red-400 text-sm">{message}</p>
|
||||
)}
|
||||
{routesComponent}
|
||||
</PasskeyLayout>
|
||||
);
|
||||
|
||||
case LayoutType.AUTH:
|
||||
// Auth layout - header only, no footer menu for login/unlock pages
|
||||
return (
|
||||
<div className="min-h-screen min-w-[350px] bg-white dark:bg-gray-900 flex flex-col max-h-[600px]">
|
||||
{loadingOverlay}
|
||||
<Header
|
||||
routes={routes}
|
||||
rightButtons={headerButtons}
|
||||
/>
|
||||
<main
|
||||
className="flex-1 overflow-y-auto bg-gray-100 dark:bg-gray-900"
|
||||
style={{
|
||||
paddingTop: '64px',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{message && (
|
||||
<div className="px-4 pt-0">
|
||||
<p className="text-red-500 dark:text-red-400 text-sm">{message}</p>
|
||||
</div>
|
||||
)}
|
||||
{routesComponent}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case LayoutType.DEFAULT:
|
||||
default:
|
||||
// Default layout with full header, footer, navigation
|
||||
return (
|
||||
<>
|
||||
{loadingOverlay}
|
||||
<DefaultLayout
|
||||
routes={routes}
|
||||
headerButtons={headerButtons}
|
||||
message={message}
|
||||
>
|
||||
{routesComponent}
|
||||
</DefaultLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -50,7 +167,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);
|
||||
@@ -60,8 +177,8 @@ const App: React.FC = () => {
|
||||
const routes: RouteConfig[] = React.useMemo(() => [
|
||||
{ path: '/', element: <Index />, showBackButton: false },
|
||||
{ path: '/reinitialize', element: <Reinitialize />, showBackButton: false },
|
||||
{ path: '/login', element: <Login />, showBackButton: false },
|
||||
{ path: '/unlock', element: <Unlock />, showBackButton: false },
|
||||
{ path: '/login', element: <Login />, showBackButton: false, layout: LayoutType.AUTH },
|
||||
{ path: '/unlock', element: <Unlock />, showBackButton: false, layout: LayoutType.AUTH },
|
||||
{ path: '/unlock-success', element: <UnlockSuccess />, showBackButton: false },
|
||||
{ path: '/upgrade', element: <Upgrade />, showBackButton: false },
|
||||
{ path: '/auth-settings', element: <AuthSettings />, showBackButton: true, title: t('settings.title') },
|
||||
@@ -69,15 +186,18 @@ 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 },
|
||||
{ path: '/settings/unlock-method', element: <VaultUnlockSettings />, showBackButton: true, title: t('settings.unlockMethod.title') },
|
||||
{ path: '/settings/autofill', element: <AutofillSettings />, showBackButton: true, title: t('settings.autofillSettings') },
|
||||
{ path: '/settings/context-menu', element: <ContextMenuSettings />, showBackButton: true, title: t('settings.contextMenuSettings') },
|
||||
{ path: '/settings/clipboard', element: <ClipboardSettings />, showBackButton: true, title: t('settings.clipboardSettings') },
|
||||
{ path: '/settings/language', element: <LanguageSettings />, showBackButton: true, title: t('settings.language') },
|
||||
{ path: '/settings/auto-lock', element: <AutoLockSettings />, showBackButton: true, title: t('settings.autoLockTimeout') },
|
||||
{ path: '/logout', element: <Logout />, showBackButton: false },
|
||||
{ path: '/settings/passkeys', element: <PasskeySettings />, showBackButton: true, title: t('settings.passkeySettings') },
|
||||
], [t]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -109,57 +229,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;
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
|
||||
export type AlertType = 'error' | 'success' | 'warning' | 'info';
|
||||
|
||||
interface IAlertMessageProps {
|
||||
type: AlertType;
|
||||
message: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert message component for displaying error, success, warning, or info messages.
|
||||
* @param props - The component props.
|
||||
* @param props.type - The type of alert (error, success, warning, info).
|
||||
* @param props.message - The message to display.
|
||||
* @param props.className - Optional additional CSS classes.
|
||||
* @returns The rendered alert message component.
|
||||
*/
|
||||
const AlertMessage: React.FC<IAlertMessageProps> = ({ type, message, className = '' }) => {
|
||||
/**
|
||||
* Get the appropriate CSS classes based on alert type.
|
||||
* @returns CSS class string.
|
||||
*/
|
||||
const getAlertClasses = (): string => {
|
||||
const baseClasses = 'p-3 border rounded-md text-sm';
|
||||
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return `${baseClasses} bg-red-100 dark:bg-red-900/30 border-red-300 dark:border-red-700 text-red-800 dark:text-red-300`;
|
||||
case 'success':
|
||||
return `${baseClasses} bg-green-100 dark:bg-green-900/30 border-green-300 dark:border-green-700 text-green-800 dark:text-green-300`;
|
||||
case 'warning':
|
||||
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/30 border-yellow-300 dark:border-yellow-700 text-yellow-800 dark:text-yellow-300`;
|
||||
case 'info':
|
||||
return `${baseClasses} bg-blue-100 dark:bg-blue-900/30 border-blue-300 dark:border-blue-700 text-blue-800 dark:text-blue-300`;
|
||||
default:
|
||||
return baseClasses;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${getAlertClasses()} ${className}`}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertMessage;
|
||||
@@ -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()
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ const HeaderBlock: React.FC<HeaderBlockProps> = ({ credential }) => (
|
||||
{credential.ServiceUrl}
|
||||
</a>
|
||||
) : (
|
||||
<span className="break-all">{credential.ServiceUrl}</span>
|
||||
<span className="text-gray-500 dark:text-gray-300 break-all">{credential.ServiceUrl}</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@@ -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;
|
||||
@@ -0,0 +1,317 @@
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { TotpCode } from '@/utils/dist/shared/models/vault';
|
||||
|
||||
type TotpFormData = {
|
||||
name: string;
|
||||
secretKey: string;
|
||||
}
|
||||
|
||||
type TotpEditorState = {
|
||||
isAddFormVisible: boolean;
|
||||
formData: TotpFormData;
|
||||
}
|
||||
|
||||
type TotpEditorProps = {
|
||||
totpCodes: TotpCode[];
|
||||
onTotpCodesChange: (totpCodes: TotpCode[]) => void;
|
||||
originalTotpCodeIds: string[];
|
||||
isAddFormVisible: boolean;
|
||||
formData: TotpFormData;
|
||||
onStateChange: (state: TotpEditorState) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for editing TOTP codes for a credential.
|
||||
*/
|
||||
const TotpEditor: React.FC<TotpEditorProps> = ({
|
||||
totpCodes,
|
||||
onTotpCodesChange,
|
||||
originalTotpCodeIds,
|
||||
isAddFormVisible,
|
||||
formData,
|
||||
onStateChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Sanitizes the secret key by extracting it from a TOTP URI if needed
|
||||
*/
|
||||
const sanitizeSecretKey = (secretKeyInput: string, nameInput: string): { secretKey: string, name: string } => {
|
||||
let secretKey = secretKeyInput.trim();
|
||||
let name = nameInput.trim();
|
||||
|
||||
// Check if it's a TOTP URI
|
||||
if (secretKey.toLowerCase().startsWith('otpauth://totp/')) {
|
||||
try {
|
||||
const uri = OTPAuth.URI.parse(secretKey);
|
||||
if (uri instanceof OTPAuth.TOTP) {
|
||||
secretKey = uri.secret.base32;
|
||||
// If name is empty, use the label from the URI
|
||||
if (!name && uri.label) {
|
||||
name = uri.label;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
throw new Error(t('totp.errors.invalidSecretKey'));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove spaces from the secret key
|
||||
secretKey = secretKey.replace(/\s/g, '');
|
||||
|
||||
// Validate the secret key format (base32)
|
||||
if (!/^[A-Z2-7]+=*$/i.test(secretKey)) {
|
||||
throw new Error(t('totp.errors.invalidSecretKey'));
|
||||
}
|
||||
|
||||
return { secretKey, name: name || 'Authenticator' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the add form
|
||||
*/
|
||||
const showAddForm = (): void => {
|
||||
onStateChange({
|
||||
isAddFormVisible: true,
|
||||
formData: { name: '', secretKey: '' }
|
||||
});
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hides the add form
|
||||
*/
|
||||
const hideAddForm = (): void => {
|
||||
onStateChange({
|
||||
isAddFormVisible: false,
|
||||
formData: { name: '', secretKey: '' }
|
||||
});
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates form data
|
||||
*/
|
||||
const updateFormData = (updates: Partial<TotpFormData>): void => {
|
||||
onStateChange({
|
||||
isAddFormVisible,
|
||||
formData: { ...formData, ...updates }
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles adding a new TOTP code
|
||||
*/
|
||||
const handleAddTotpCode = (e?: React.MouseEvent | React.KeyboardEvent): void => {
|
||||
e?.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
// Validate required fields
|
||||
if (!formData.secretKey) {
|
||||
setFormError(t('credentials.validation.required'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Sanitize the secret key
|
||||
const { secretKey, name } = sanitizeSecretKey(formData.secretKey, formData.name);
|
||||
|
||||
// Create new TOTP code
|
||||
const newTotpCode: TotpCode = {
|
||||
Id: crypto.randomUUID().toUpperCase(),
|
||||
Name: name,
|
||||
SecretKey: secretKey,
|
||||
CredentialId: '' // Will be set when saving the credential
|
||||
};
|
||||
|
||||
// Add to the list
|
||||
const updatedTotpCodes = [...totpCodes, newTotpCode];
|
||||
onTotpCodesChange(updatedTotpCodes);
|
||||
|
||||
// Hide the form
|
||||
hideAddForm();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setFormError(error.message);
|
||||
} else {
|
||||
setFormError(t('common.errors.unknownErrorTryAgain'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initiates the delete process for a TOTP code
|
||||
*/
|
||||
const deleteTotpCode = (totpToDelete: TotpCode): void => {
|
||||
// Check if this TOTP code was part of the original set
|
||||
const wasOriginal = originalTotpCodeIds.includes(totpToDelete.Id);
|
||||
|
||||
let updatedTotpCodes: TotpCode[];
|
||||
if (wasOriginal) {
|
||||
// Mark as deleted (soft delete for syncing)
|
||||
updatedTotpCodes = totpCodes.map(tc =>
|
||||
tc.Id === totpToDelete.Id
|
||||
? { ...tc, IsDeleted: true }
|
||||
: tc
|
||||
);
|
||||
} else {
|
||||
// Hard delete (remove from array)
|
||||
updatedTotpCodes = totpCodes.filter(tc => tc.Id !== totpToDelete.Id);
|
||||
}
|
||||
|
||||
onTotpCodesChange(updatedTotpCodes);
|
||||
};
|
||||
|
||||
// Filter out deleted TOTP codes for display
|
||||
const activeTotpCodes = totpCodes.filter(tc => !tc.IsDeleted);
|
||||
const hasActiveTotpCodes = activeTotpCodes.length > 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('common.twoFactorAuthentication')}
|
||||
</h2>
|
||||
{hasActiveTotpCodes && !isAddFormVisible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={showAddForm}
|
||||
className="w-8 h-8 flex items-center justify-center text-primary-700 hover:text-white border border-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg dark:border-primary-500 dark:text-primary-500 dark:hover:text-white dark:hover:bg-primary-600 dark:focus:ring-primary-800"
|
||||
title={t('totp.addCode')}
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasActiveTotpCodes && !isAddFormVisible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={showAddForm}
|
||||
className="w-full py-1.5 px-4 flex items-center justify-center gap-2 text-primary-700 hover:text-white border border-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg dark:border-primary-500 dark:text-primary-500 dark:hover:text-white dark:hover:bg-primary-600 dark:focus:ring-primary-800"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
<span>{t('totp.addCode')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isAddFormVisible && (
|
||||
<div className="p-4 mb-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{t('totp.addCode')}
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={hideAddForm}
|
||||
className="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('totp.instructions')}
|
||||
</p>
|
||||
|
||||
{formError && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-900/20 dark:border-red-800">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="totp-name" className="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('totp.nameOptional')}
|
||||
</label>
|
||||
<input
|
||||
id="totp-name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => updateFormData({ name: e.target.value })}
|
||||
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="totp-secret" className="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('totp.secretKey')}
|
||||
</label>
|
||||
<input
|
||||
id="totp-secret"
|
||||
type="text"
|
||||
value={formData.secretKey}
|
||||
onChange={(e) => updateFormData({ secretKey: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTotpCode(e);
|
||||
}
|
||||
}}
|
||||
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleAddTotpCode(e)}
|
||||
className="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasActiveTotpCodes && (
|
||||
<div className="grid grid-cols-1 gap-4 mt-4">
|
||||
{activeTotpCodes.map(totpCode => (
|
||||
<div
|
||||
key={totpCode.Id}
|
||||
className="p-2 ps-3 pe-3 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<div className="flex items-center flex-1">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{totpCode.Name}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('totp.saveToViewCode')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteTotpCode(totpCode)}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-500 dark:hover:text-red-400"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TotpEditor;
|
||||
@@ -2,8 +2,8 @@ import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type HelpModalProps = {
|
||||
titleKey: string;
|
||||
contentKey: string;
|
||||
title: string;
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ type HelpModalProps = {
|
||||
* Reusable help modal component with a question mark icon button.
|
||||
* Shows a modal popup with help information when clicked.
|
||||
*/
|
||||
const HelpModal: React.FC<HelpModalProps> = ({ titleKey, contentKey, className = '' }) => {
|
||||
const HelpModal: React.FC<HelpModalProps> = ({ title, content, className = '' }) => {
|
||||
const { t } = useTranslation();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
@@ -39,11 +39,11 @@ 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">
|
||||
{t(titleKey)}
|
||||
{title}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
@@ -66,7 +66,7 @@ const HelpModal: React.FC<HelpModalProps> = ({ titleKey, contentKey, className =
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{t(contentKey)}
|
||||
{content}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
@@ -0,0 +1,244 @@
|
||||
import QRCode from 'qrcode';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { MobileLoginErrorCode } from '@/entrypoints/popup/types/MobileLoginErrorCode';
|
||||
import { MobileLoginUtility } from '@/entrypoints/popup/utils/MobileLoginUtility';
|
||||
|
||||
import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult';
|
||||
import type { WebApiService } from '@/utils/WebApiService';
|
||||
|
||||
interface IMobileUnlockModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (result: MobileLoginResult) => Promise<void>;
|
||||
webApi: WebApiService;
|
||||
mode?: 'login' | 'unlock';
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal component for mobile login/unlock via QR code scanning.
|
||||
*/
|
||||
const MobileUnlockModal: React.FC<IMobileUnlockModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
webApi,
|
||||
mode = 'login'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<MobileLoginErrorCode | null>(null);
|
||||
const [timeRemaining, setTimeRemaining] = useState<number>(120); // 2 minutes in seconds
|
||||
const mobileLoginRef = useRef<MobileLoginUtility | null>(null);
|
||||
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
/**
|
||||
* Get translated error message for error code.
|
||||
*/
|
||||
const getErrorMessage = (errorCode: MobileLoginErrorCode): string => {
|
||||
switch (errorCode) {
|
||||
case MobileLoginErrorCode.TIMEOUT:
|
||||
return t('auth.errors.mobileLoginRequestExpired');
|
||||
case MobileLoginErrorCode.GENERIC:
|
||||
default:
|
||||
return t('common.errors.unknownError');
|
||||
}
|
||||
};
|
||||
|
||||
// Countdown timer effect
|
||||
useEffect(() => {
|
||||
if (qrCodeUrl && timeRemaining > 0 && isOpen) {
|
||||
countdownIntervalRef.current = setInterval(() => {
|
||||
setTimeRemaining(prev => {
|
||||
if (prev <= 1) {
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return (): void => {
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [qrCodeUrl, timeRemaining, isOpen]);
|
||||
|
||||
// Initialize mobile login when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize mobile login on modal open.
|
||||
*/
|
||||
const initiateMobileLogin = async (): Promise<void> => {
|
||||
try {
|
||||
setError(null);
|
||||
setQrCodeUrl(null);
|
||||
setTimeRemaining(120);
|
||||
|
||||
// Initialize mobile login utility
|
||||
if (!mobileLoginRef.current) {
|
||||
mobileLoginRef.current = new MobileLoginUtility(webApi);
|
||||
}
|
||||
|
||||
// Initiate mobile login and get QR code data
|
||||
const requestId = await mobileLoginRef.current.initiate();
|
||||
|
||||
// Generate QR code with AliasVault prefix for mobile login
|
||||
const qrData = `aliasvault://open/mobile-unlock/${requestId}`;
|
||||
const qrDataUrl = await QRCode.toDataURL(qrData, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
});
|
||||
|
||||
setQrCodeUrl(qrDataUrl);
|
||||
|
||||
// Start polling for response
|
||||
await mobileLoginRef.current.startPolling(
|
||||
async (result: MobileLoginResult) => {
|
||||
try {
|
||||
// Call success callback (parent handles loading state)
|
||||
await onSuccess(result);
|
||||
// Close modal after successful processing
|
||||
handleClose();
|
||||
} catch {
|
||||
// Show error if success handler fails and hide QR code
|
||||
setQrCodeUrl(null);
|
||||
setError(MobileLoginErrorCode.GENERIC);
|
||||
}
|
||||
},
|
||||
(errorCode) => {
|
||||
// Hide QR code when error occurs
|
||||
setQrCodeUrl(null);
|
||||
setError(errorCode);
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
// err is a MobileLoginErrorCode thrown by initiate()
|
||||
if (typeof err === 'string' && Object.values(MobileLoginErrorCode).includes(err as MobileLoginErrorCode)) {
|
||||
setError(err as MobileLoginErrorCode);
|
||||
} else {
|
||||
setError(MobileLoginErrorCode.GENERIC);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initiateMobileLogin();
|
||||
|
||||
// Cleanup on unmount or when modal closes
|
||||
return (): void => {
|
||||
if (mobileLoginRef.current) {
|
||||
mobileLoginRef.current.cleanup();
|
||||
}
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
/**
|
||||
* Handle modal close.
|
||||
*/
|
||||
const handleClose = (): void => {
|
||||
if (mobileLoginRef.current) {
|
||||
mobileLoginRef.current.cleanup();
|
||||
}
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
}
|
||||
setQrCodeUrl(null);
|
||||
setError(null);
|
||||
setTimeRemaining(120);
|
||||
onClose();
|
||||
};
|
||||
|
||||
/**
|
||||
* Format time remaining as MM:SS.
|
||||
*/
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = mode === 'unlock' ? t('auth.unlockWithMobile') : t('auth.loginWithMobile');
|
||||
const description = t('auth.scanQrCode');
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 bg-black bg-opacity-80 transition-opacity" onClick={handleClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-5 text-left shadow-xl transition-all w-full max-w-md">
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-4 top-4 text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<span className="sr-only">{t('common.close')}</span>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-3">
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 rounded text-red-700 dark:text-red-400 text-sm">
|
||||
{getErrorMessage(error)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qrCodeUrl && (
|
||||
<div className="flex flex-col items-center mb-4">
|
||||
<img src={qrCodeUrl} alt="QR Code" className="border-4 border-gray-200 dark:border-gray-600 rounded mb-3" />
|
||||
<div className="text-gray-700 dark:text-gray-300 text-sm font-medium">
|
||||
{formatTime(timeRemaining)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!qrCodeUrl && !error && (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="mt-4 w-full inline-flex justify-center rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileUnlockModal;
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -45,18 +45,18 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
|
||||
const [selectedDomain, setSelectedDomain] = useState('');
|
||||
const [isPopupVisible, setIsPopupVisible] = useState(false);
|
||||
const [privateEmailDomains, setPrivateEmailDomains] = useState<string[]>([]);
|
||||
const [hiddenPrivateEmailDomains, setHiddenPrivateEmailDomains] = useState<string[]>([]);
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get private email domains from vault metadata
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Load private email domains from vault metadata.
|
||||
* Load private email domains from vault metadata, excluding hidden ones.
|
||||
*/
|
||||
const loadDomains = async (): Promise<void> => {
|
||||
const metadata = await dbContext.getVaultMetadata();
|
||||
if (metadata?.privateEmailDomains) {
|
||||
setPrivateEmailDomains(metadata.privateEmailDomains);
|
||||
}
|
||||
setPrivateEmailDomains(metadata?.privateEmailDomains ?? []);
|
||||
setHiddenPrivateEmailDomains(metadata?.hiddenPrivateEmailDomains ?? []);
|
||||
};
|
||||
loadDomains();
|
||||
}, [dbContext]);
|
||||
@@ -84,9 +84,10 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
|
||||
setLocalPart(local);
|
||||
setSelectedDomain(domain);
|
||||
|
||||
// Check if it's a custom domain
|
||||
// Check if it's a custom domain (including hidden private domains as known domains)
|
||||
const isKnownDomain = PUBLIC_EMAIL_DOMAINS.includes(domain) ||
|
||||
privateEmailDomains.includes(domain);
|
||||
privateEmailDomains.includes(domain) ||
|
||||
hiddenPrivateEmailDomains.includes(domain);
|
||||
setIsCustomDomain(!isKnownDomain);
|
||||
} else {
|
||||
setLocalPart(value);
|
||||
@@ -101,7 +102,8 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [value, privateEmailDomains, showPrivateDomains, selectedDomain]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value, privateEmailDomains, hiddenPrivateEmailDomains, showPrivateDomains]);
|
||||
|
||||
// Handle local part changes
|
||||
const handleLocalPartChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -245,20 +247,22 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
|
||||
{t('credentials.privateEmailDescription')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{privateEmailDomains.map((domain) => (
|
||||
<button
|
||||
key={domain}
|
||||
type="button"
|
||||
onClick={() => selectDomain(domain)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
selectedDomain === domain
|
||||
? 'bg-primary-600 text-white hover:bg-primary-700'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{domain}
|
||||
</button>
|
||||
))}
|
||||
{privateEmailDomains
|
||||
.filter((domain) => !hiddenPrivateEmailDomains.includes(domain))
|
||||
.map((domain) => (
|
||||
<button
|
||||
key={domain}
|
||||
type="button"
|
||||
onClick={() => selectDomain(domain)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
selectedDomain === domain
|
||||
? 'bg-primary-600 text-white hover:bg-primary-700'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{domain}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1,13 +1,12 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import PasswordConfigDialog from '@/entrypoints/popup/components/Dialogs/PasswordConfigDialog';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
import type { PasswordSettings } from '@/utils/dist/shared/models/vault';
|
||||
import { CreatePasswordGenerator } from '@/utils/dist/shared/password-generator';
|
||||
|
||||
import PasswordConfigDialog from './PasswordConfigDialog';
|
||||
|
||||
interface IPasswordFieldProps {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -34,7 +34,7 @@ const BottomNav: React.FC = () => {
|
||||
};
|
||||
|
||||
// Auth pages that don't show bottom navigation but still show header
|
||||
const authPages = ['/login', '/auth-settings', '/unlock', '/unlock-success', '/upgrade'];
|
||||
const authPages = ['/', '/login', '/auth-settings', '/unlock', '/unlock-success', '/upgrade'];
|
||||
const isAuthPage = authPages.includes(location.pathname);
|
||||
|
||||
if (isAuthPage) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
|
||||
import { ClipboardCountdownBar } from '@/entrypoints/popup/components/ClipboardCountdownBar';
|
||||
import BottomNav from '@/entrypoints/popup/components/Layout/BottomNav';
|
||||
import Header from '@/entrypoints/popup/components/Layout/Header';
|
||||
|
||||
/**
|
||||
* Route configuration type.
|
||||
*/
|
||||
type RouteConfig = {
|
||||
path: string;
|
||||
element: React.ReactNode;
|
||||
showBackButton?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* DefaultLayout props.
|
||||
*/
|
||||
type DefaultLayoutProps = {
|
||||
routes: RouteConfig[];
|
||||
headerButtons: React.ReactNode;
|
||||
message?: string | null;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* DefaultLayout - Standard layout with full header, footer navigation, and complete UI.
|
||||
* This is the main layout used for most pages in the extension.
|
||||
*/
|
||||
const DefaultLayout: React.FC<DefaultLayoutProps> = ({ routes, headerButtons, message, children }) => {
|
||||
return (
|
||||
<div className="min-h-screen min-w-[350px] bg-white dark:bg-gray-900 flex flex-col max-h-[600px]">
|
||||
<ClipboardCountdownBar />
|
||||
|
||||
<Header
|
||||
routes={routes}
|
||||
rightButtons={headerButtons}
|
||||
/>
|
||||
|
||||
<main
|
||||
className="flex-1 overflow-y-auto bg-gray-100 dark:bg-gray-900"
|
||||
style={{
|
||||
paddingTop: '64px',
|
||||
height: 'calc(100% - 120px)',
|
||||
}}
|
||||
>
|
||||
<div className="px-4 pb-4 pt-2 mb-16">
|
||||
{message && (
|
||||
<p className="text-red-500 mb-4">{message}</p>
|
||||
)}
|
||||
{children || (
|
||||
<Routes>
|
||||
{routes.map((route) => (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
))}
|
||||
</Routes>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultLayout;
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
import Logo from '@/entrypoints/popup/components/Logo';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
|
||||
/**
|
||||
* Header props.
|
||||
@@ -25,7 +25,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
rightButtons
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const authContext = useAuth();
|
||||
const app = useApp();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -54,7 +54,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
}
|
||||
|
||||
// If logged in, navigate to credentials.
|
||||
if (authContext.isLoggedIn) {
|
||||
if (app.isLoggedIn) {
|
||||
navigate('/credentials');
|
||||
} else {
|
||||
// If not logged in, navigate to index.
|
||||
@@ -105,7 +105,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<div className="flex-grow" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!authContext.isLoggedIn ? (
|
||||
{!app.isLoggedIn ? (
|
||||
<>
|
||||
{rightButtons}
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
import Logo from '@/entrypoints/popup/components/Logo';
|
||||
|
||||
/**
|
||||
* PasskeyLayout - Minimal layout for passkey create/authenticate pages.
|
||||
* Shows only the AliasVault logo header, no navigation, no footer.
|
||||
*/
|
||||
const PasskeyLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<div className="min-h-screen min-w-[350px] bg-white dark:bg-gray-900 flex flex-col max-h-[600px]">
|
||||
{/* Minimal header with just logo */}
|
||||
<header className="fixed z-30 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<div className="flex items-center justify-center h-16 px-4">
|
||||
<Logo
|
||||
width={125}
|
||||
height={40}
|
||||
showText={true}
|
||||
className="text-gray-900 dark:text-white"
|
||||
/>
|
||||
{/* Hide beta badge on Safari as it's not allowed to show non-production badges */}
|
||||
{!import.meta.env.SAFARI && (
|
||||
<span className="text-primary-500 text-[10px] font-normal">BETA</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content without footer padding */}
|
||||
<main
|
||||
className="flex-1 overflow-y-auto bg-gray-100 dark:bg-gray-900"
|
||||
style={{
|
||||
paddingTop: '64px',
|
||||
}}
|
||||
>
|
||||
<div className="p-4">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasskeyLayout;
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
|
||||
/**
|
||||
* Username avatar component that shows the avatar and username.
|
||||
* Displays centered above the unlock form.
|
||||
*/
|
||||
const UsernameAvatar: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { username } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<div className="w-12 h-12 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center mb-3">
|
||||
<span className="text-primary-600 dark:text-primary-400 text-2xl font-medium">
|
||||
{username?.[0]?.toUpperCase() || '?'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium text-gray-900 dark:text-white text-base">
|
||||
{username}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('auth.loggedIn')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsernameAvatar;
|
||||
@@ -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,20 +1,19 @@
|
||||
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';
|
||||
|
||||
import { VAULT_LOCKED_DISMISS_UNTIL_KEY } from '@/utils/Constants';
|
||||
import { removeAndDisablePin } from '@/utils/PinUnlockService';
|
||||
|
||||
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 +27,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 +35,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,34 +58,36 @@ 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();
|
||||
|
||||
// Clear PIN unlock data (if any)
|
||||
try {
|
||||
await removeAndDisablePin();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove PIN data:', error);
|
||||
// Non-fatal error - continue with logout
|
||||
}
|
||||
|
||||
// Set local storage global message that will be shown on the login page.
|
||||
if (errorMessage) {
|
||||
setGlobalMessage(errorMessage);
|
||||
}
|
||||
|
||||
setUsername(null);
|
||||
setIsLoggedIn(false);
|
||||
}, [dbContext]);
|
||||
|
||||
/**
|
||||
@@ -108,16 +98,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}>
|
||||
|
||||
@@ -8,6 +8,8 @@ import SqliteClient from '@/utils/SqliteClient';
|
||||
import { StoreVaultRequest } from '@/utils/types/messaging/StoreVaultRequest';
|
||||
import type { VaultResponse as messageVaultResponse } from '@/utils/types/messaging/VaultResponse';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
type DbContextType = {
|
||||
sqliteClient: SqliteClient | null;
|
||||
dbInitialized: boolean;
|
||||
@@ -42,11 +44,6 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
*/
|
||||
const [dbAvailable, setDbAvailable] = useState(false);
|
||||
|
||||
/**
|
||||
* Vault revision.
|
||||
*/
|
||||
const [vaultMetadata, setVaultMetadata] = useState<VaultMetadata | null>(null);
|
||||
|
||||
const initializeDatabase = useCallback(async (vaultResponse: VaultResponse, derivedKey: string) => {
|
||||
// Attempt to decrypt the blob.
|
||||
const decryptedBlob = await EncryptionUtility.symmetricDecrypt(
|
||||
@@ -61,19 +58,15 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
setSqliteClient(client);
|
||||
setDbInitialized(true);
|
||||
setDbAvailable(true);
|
||||
setVaultMetadata({
|
||||
publicEmailDomains: vaultResponse.vault.publicEmailDomainList,
|
||||
privateEmailDomains: vaultResponse.vault.privateEmailDomainList,
|
||||
vaultRevisionNumber: vaultResponse.vault.currentRevisionNumber,
|
||||
});
|
||||
|
||||
/**
|
||||
* Store encrypted vault in background worker.
|
||||
* Store encrypted vault and metadata in background worker (session storage).
|
||||
*/
|
||||
const request: StoreVaultRequest = {
|
||||
vaultBlob: vaultResponse.vault.blob,
|
||||
publicEmailDomainList: vaultResponse.vault.publicEmailDomainList,
|
||||
privateEmailDomainList: vaultResponse.vault.privateEmailDomainList,
|
||||
hiddenPrivateEmailDomainList: vaultResponse.vault.hiddenPrivateEmailDomainList,
|
||||
vaultRevisionNumber: vaultResponse.vault.currentRevisionNumber,
|
||||
};
|
||||
|
||||
@@ -92,12 +85,7 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
setSqliteClient(client);
|
||||
setDbInitialized(true);
|
||||
setDbAvailable(true);
|
||||
|
||||
setVaultMetadata({
|
||||
publicEmailDomains: response.publicEmailDomains ?? [],
|
||||
privateEmailDomains: response.privateEmailDomains ?? [],
|
||||
vaultRevisionNumber: response.vaultRevisionNumber ?? 0,
|
||||
});
|
||||
// Metadata is already stored in session storage by background worker
|
||||
} else {
|
||||
setDbInitialized(true);
|
||||
setDbAvailable(false);
|
||||
@@ -110,22 +98,37 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get the vault metadata.
|
||||
* Get the vault metadata from session storage.
|
||||
*/
|
||||
const getVaultMetadata = useCallback(async () : Promise<VaultMetadata | null> => {
|
||||
return vaultMetadata;
|
||||
}, [vaultMetadata]);
|
||||
try {
|
||||
const publicEmailDomains = await storage.getItem('session:publicEmailDomains') as string[] | null;
|
||||
const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[] | null;
|
||||
const hiddenPrivateEmailDomains = await storage.getItem('session:hiddenPrivateEmailDomains') as string[] | null;
|
||||
const vaultRevisionNumber = await storage.getItem('session:vaultRevisionNumber') as number | null;
|
||||
|
||||
if (!publicEmailDomains && !privateEmailDomains) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
publicEmailDomains: publicEmailDomains ?? [],
|
||||
privateEmailDomains: privateEmailDomains ?? [],
|
||||
hiddenPrivateEmailDomains: hiddenPrivateEmailDomains ?? [],
|
||||
vaultRevisionNumber: vaultRevisionNumber ?? 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting vault metadata from session storage:', error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Set the current vault revision number.
|
||||
* Set the current vault revision number in session storage.
|
||||
*/
|
||||
const setCurrentVaultRevisionNumber = useCallback(async (revisionNumber: number) => {
|
||||
setVaultMetadata({
|
||||
publicEmailDomains: vaultMetadata?.publicEmailDomains ?? [],
|
||||
privateEmailDomains: vaultMetadata?.privateEmailDomains ?? [],
|
||||
vaultRevisionNumber: revisionNumber,
|
||||
});
|
||||
}, [vaultMetadata]);
|
||||
await storage.setItem('session:vaultRevisionNumber', revisionNumber);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Check if there are pending migrations.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
import { storage } from '#imports';
|
||||
@@ -29,21 +29,32 @@ const NavigationContext = createContext<NavigationContextType | undefined>(undef
|
||||
*/
|
||||
export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Auth and DB state
|
||||
const { isInitialized: authInitialized, isLoggedIn } = useAuth();
|
||||
const { dbInitialized, dbAvailable, upgradeRequired } = useDb();
|
||||
const { isInitialized: authInitialized, isLoggedIn } = useApp();
|
||||
const { dbInitialized, dbAvailable } = useDb();
|
||||
|
||||
// Derived state
|
||||
const isFullyInitialized = authInitialized && dbInitialized;
|
||||
const requiresAuth = isFullyInitialized && (!isLoggedIn || (!dbAvailable && !upgradeRequired));
|
||||
const requiresAuth = isFullyInitialized && (!isLoggedIn || !dbAvailable);
|
||||
|
||||
/**
|
||||
* Store the current page path, timestamp, and navigation history in storage.
|
||||
*/
|
||||
const storeCurrentPage = useCallback(async (): Promise<void> => {
|
||||
// Pages that are not allowed to be stored as these are auth conditional pages.
|
||||
const notAllowedPaths = ['/', '/reinitialize', '/login', '/unlock', '/unlock-success', '/auth-settings', '/upgrade', '/logout'];
|
||||
// Pages that are not allowed to be stored as these are auth conditional pages or dedicated popup pages.
|
||||
const notAllowedPaths = [
|
||||
'/',
|
||||
'/reinitialize',
|
||||
'/login',
|
||||
'/unlock',
|
||||
'/unlock-success',
|
||||
'/auth-settings',
|
||||
'/upgrade',
|
||||
'/passkeys/create',
|
||||
'/passkeys/authenticate'
|
||||
];
|
||||
|
||||
// Only store the page if we're fully initialized and don't need auth
|
||||
if (isFullyInitialized && !requiresAuth && !notAllowedPaths.includes(location.pathname)) {
|
||||
@@ -55,7 +66,7 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
let currentPath = '';
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
currentPath += '/' + segments[i];
|
||||
|
||||
|
||||
/*
|
||||
* For settings subpages, include both /settings and the subpage
|
||||
* For email details, include both /emails and the specific email
|
||||
@@ -82,6 +93,15 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
}
|
||||
}, [location.pathname, location.search, location.hash, isFullyInitialized, storeCurrentPage]);
|
||||
|
||||
// Listen on isloggedin state to redirect to login page if not logged in
|
||||
useEffect(() => {
|
||||
if (isFullyInitialized && !isLoggedIn) {
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isFullyInitialized, isLoggedIn]);
|
||||
|
||||
// Return the context value
|
||||
const contextValue = useMemo(() => ({
|
||||
storeCurrentPage,
|
||||
isFullyInitialized,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
|
||||
import { WebApiService } from '@/utils/WebApiService';
|
||||
|
||||
const WebApiContext = createContext<WebApiService | null>(null);
|
||||
@@ -10,24 +8,15 @@ const WebApiContext = createContext<WebApiService | null>(null);
|
||||
* WebApiProvider to provide the WebApiService to the app that components can use.
|
||||
*/
|
||||
export const WebApiProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { logout } = useAuth();
|
||||
const [webApiService, setWebApiService] = useState<WebApiService | null>(null);
|
||||
|
||||
/**
|
||||
* Initialize WebApiService
|
||||
*/
|
||||
useEffect(() : void => {
|
||||
const service = new WebApiService(
|
||||
(statusError: string | null) => {
|
||||
if (statusError) {
|
||||
logout(statusError);
|
||||
} else {
|
||||
logout();
|
||||
}
|
||||
}
|
||||
);
|
||||
const service = new WebApiService();
|
||||
setWebApiService(service);
|
||||
}, [logout]);
|
||||
}, []);
|
||||
|
||||
if (!webApiService) {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
import { PENDING_REDIRECT_URL_KEY } from '@/utils/Constants';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
/**
|
||||
* Hook to handle vault lock redirects.
|
||||
* Automatically redirects to unlock page if vault is locked,
|
||||
* preserving the current URL for restoration after unlock.
|
||||
*/
|
||||
export function useVaultLockRedirect(options: { enabled?: boolean } = {}): { isLocked: boolean } {
|
||||
const { enabled = true } = options;
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { dbInitialized, dbAvailable } = useDb();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !dbInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if vault is locked
|
||||
if (!dbAvailable) {
|
||||
// Store the full current URL (pathname + search) for restoration after unlock
|
||||
const currentUrl = `${location.pathname}${location.search}`;
|
||||
storage.setItem(PENDING_REDIRECT_URL_KEY, currentUrl);
|
||||
|
||||
// Navigate to unlock without redirect in URL - we use storage instead
|
||||
navigate('/unlock');
|
||||
}
|
||||
}, [enabled, dbInitialized, dbAvailable, location, navigate]);
|
||||
|
||||
return {
|
||||
isLocked: dbInitialized && !dbAvailable
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and clear the pending redirect URL from storage.
|
||||
* Used by Reinitialize page to restore user's intended destination after unlock.
|
||||
*
|
||||
* @returns The pending redirect URL, or null if none exists
|
||||
*/
|
||||
export async function consumePendingRedirectUrl(): Promise<string | null> {
|
||||
const url = await storage.getItem<string>(PENDING_REDIRECT_URL_KEY);
|
||||
if (url) {
|
||||
await storage.removeItem(PENDING_REDIRECT_URL_KEY);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the pending redirect URL from storage.
|
||||
* Used when popup is opened without a specific hash path to clear stale redirects.
|
||||
*/
|
||||
export async function clearPendingRedirectUrl(): Promise<void> {
|
||||
await storage.removeItem(PENDING_REDIRECT_URL_KEY);
|
||||
}
|
||||
@@ -43,55 +43,52 @@ export function useVaultMutate() : {
|
||||
|
||||
setSyncStatus(t('common.uploadingVaultToServer'));
|
||||
|
||||
try {
|
||||
// Upload the updated vault to the server.
|
||||
const base64Vault = dbContext.sqliteClient!.exportToBase64();
|
||||
// Upload the updated vault to the server.
|
||||
const base64Vault = dbContext.sqliteClient!.exportToBase64();
|
||||
|
||||
// Get encryption key from background worker
|
||||
const encryptionKey = await sendMessage('GET_ENCRYPTION_KEY', {}, 'background') as string;
|
||||
// Get encryption key from background worker
|
||||
const encryptionKey = await sendMessage('GET_ENCRYPTION_KEY', {}, 'background') as string;
|
||||
|
||||
// Encrypt the vault.
|
||||
const encryptedVaultBlob = await EncryptionUtility.symmetricEncrypt(
|
||||
base64Vault,
|
||||
encryptionKey
|
||||
);
|
||||
// Encrypt the vault.
|
||||
const encryptedVaultBlob = await EncryptionUtility.symmetricEncrypt(
|
||||
base64Vault,
|
||||
encryptionKey
|
||||
);
|
||||
|
||||
const request: UploadVaultRequest = {
|
||||
vaultBlob: encryptedVaultBlob,
|
||||
};
|
||||
const request: UploadVaultRequest = {
|
||||
vaultBlob: encryptedVaultBlob,
|
||||
};
|
||||
|
||||
const response = await sendMessage('UPLOAD_VAULT', request, 'background') as messageVaultUploadResponse;
|
||||
const response = await sendMessage('UPLOAD_VAULT', request, 'background') as messageVaultUploadResponse;
|
||||
|
||||
/*
|
||||
* If we get here, it means we have a valid connection to the server.
|
||||
* TODO: offline mode is not implemented for browser extension yet.
|
||||
* authContext.setOfflineMode(false);
|
||||
*/
|
||||
/*
|
||||
* If we get here, it means we have a valid connection to the server.
|
||||
* TODO: offline mode is not implemented for browser extension yet.
|
||||
* authContext.setOfflineMode(false);
|
||||
*/
|
||||
|
||||
if (response.status === 0 && response.newRevisionNumber) {
|
||||
await dbContext.setCurrentVaultRevisionNumber(response.newRevisionNumber);
|
||||
options.onSuccess?.();
|
||||
} else if (response.status === 1) {
|
||||
// Note: vault merge is no longer allowed by the API as of 0.20.0, updates with the same revision number are rejected. So this check can be removed later.
|
||||
throw new Error('Vault merge required. Please login via the web app to merge the multiple pending updates to your vault.');
|
||||
} else if (response.status === 2) {
|
||||
throw new Error('Your vault is outdated. Please login on the AliasVault website and follow the steps.');
|
||||
} else {
|
||||
throw new Error('Failed to upload vault to server. Please try again by re-opening the app.');
|
||||
}
|
||||
} catch (error) {
|
||||
// Check if it's a network error
|
||||
if (error instanceof Error && (error.message.includes('network') || error.message.includes('timeout'))) {
|
||||
/*
|
||||
* Network error, mark as offline and track pending changes
|
||||
* TODO: offline mode is not implemented for browser extension yet.
|
||||
* authContext.setOfflineMode(true);
|
||||
*/
|
||||
options.onError?.(new Error('Network error'));
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
if (response.status === 0 && response.newRevisionNumber) {
|
||||
await dbContext.setCurrentVaultRevisionNumber(response.newRevisionNumber);
|
||||
options.onSuccess?.();
|
||||
} else if (response.status === 1) {
|
||||
// Note: vault merge is no longer allowed by the API as of 0.20.0, updates with the same revision number are rejected. So this check can be removed later.
|
||||
throw new Error('Vault merge required. Please login via the web app to merge the multiple pending updates to your vault.');
|
||||
} else if (response.status === 2) {
|
||||
throw new Error(t('common.errors.unknownError'));
|
||||
} else {
|
||||
throw new Error(t('common.errors.unknownError'));
|
||||
}
|
||||
|
||||
// Check if it's a network error
|
||||
/*
|
||||
* if (error instanceof Error && (error.message.includes('network') || error.message.includes('timeout'))) {
|
||||
*
|
||||
* // Network error, mark as offline and track pending changes - TODO: offline mode is not implemented for browser extension yet.
|
||||
* // authContext.setOfflineMode(true);
|
||||
*options.onError?.(new Error('Network error'));
|
||||
*return;
|
||||
*}
|
||||
*/
|
||||
}, [dbContext, t]);
|
||||
|
||||
/**
|
||||
@@ -130,28 +127,12 @@ export function useVaultMutate() : {
|
||||
* Handle error during vault sync.
|
||||
*/
|
||||
onError: (error) => {
|
||||
/**
|
||||
*Toast.show({
|
||||
*type: 'error',
|
||||
*text1: 'Failed to sync vault',
|
||||
*text2: error,
|
||||
*position: 'bottom'
|
||||
*});
|
||||
*/
|
||||
options.onError?.(new Error(error));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error during vault mutation:', error);
|
||||
/*
|
||||
* Toast.show({
|
||||
*type: 'error',
|
||||
*text1: 'Operation failed',
|
||||
*text2: error instanceof Error ? error.message : 'Unknown error',
|
||||
*position: 'bottom'
|
||||
*});
|
||||
*/
|
||||
options.onError?.(error instanceof Error ? error : new Error('Unknown error'));
|
||||
options.onError?.(error instanceof Error ? error : new Error(t('common.errors.unknownError')));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setSyncStatus('');
|
||||
|
||||
@@ -2,12 +2,13 @@ import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
|
||||
import type { EncryptionKeyDerivationParams } from '@/utils/dist/shared/models/metadata';
|
||||
import type { VaultResponse } from '@/utils/dist/shared/models/webapi';
|
||||
import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError';
|
||||
|
||||
/**
|
||||
* Utility function to ensure a minimum time has elapsed for an operation
|
||||
@@ -49,7 +50,7 @@ export const useVaultSync = () : {
|
||||
syncVault: (options?: VaultSyncOptions) => Promise<boolean>;
|
||||
} => {
|
||||
const { t } = useTranslation();
|
||||
const authContext = useAuth();
|
||||
const app = useApp();
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
|
||||
@@ -60,7 +61,7 @@ export const useVaultSync = () : {
|
||||
const enableDelay = initialSync;
|
||||
|
||||
try {
|
||||
const { isLoggedIn } = await authContext.initializeAuth();
|
||||
const isLoggedIn = await app.initializeAuth();
|
||||
|
||||
if (!isLoggedIn) {
|
||||
// Not authenticated, return false immediately
|
||||
@@ -73,7 +74,9 @@ export const useVaultSync = () : {
|
||||
|
||||
// Check if server is actually available, 0.0.0 indicates connection error which triggers offline mode.
|
||||
if (statusResponse.serverVersion === '0.0.0') {
|
||||
// Offline mode is not implemented for browser extension yet, let it fail below due to the validateStatusResponse check.
|
||||
// Offline mode is not implemented for browser extension yet, so logout the user.
|
||||
onError?.(t('common.errors.serverNotAvailable'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const statusError = webApi.validateStatusResponse(statusResponse);
|
||||
@@ -91,7 +94,7 @@ export const useVaultSync = () : {
|
||||
* longer valid and the user needs to re-authenticate. We trigger a logout but do not revoke tokens
|
||||
* as these were already revoked by the server upon password change.
|
||||
*/
|
||||
await webApi.logout(t('common.errors.passwordChanged'));
|
||||
await app.logout(t('common.errors.passwordChanged'));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -109,24 +112,6 @@ export const useVaultSync = () : {
|
||||
onStatus?.(t('common.syncingUpdatedVault'));
|
||||
const vaultResponseJson = await withMinimumDelay(() => webApi.get<VaultResponse>('Vault'), 1000, enableDelay);
|
||||
|
||||
const vaultError = webApi.validateVaultResponse(vaultResponseJson as VaultResponse, t);
|
||||
if (vaultError) {
|
||||
// Only logout if it's an authentication error, not a network error
|
||||
if (vaultError.includes('authentication') || vaultError.includes('unauthorized')) {
|
||||
await webApi.logout(vaultError);
|
||||
onError?.(vaultError);
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* TODO: browser extension does not support offline mode yet.
|
||||
* For other errors, go into offline mode
|
||||
* authContext.setOfflineMode(true);
|
||||
*/
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get encryption key from background worker
|
||||
const encryptionKey = await sendMessage('GET_ENCRYPTION_KEY', {}, 'background') as string;
|
||||
@@ -142,9 +127,8 @@ export const useVaultSync = () : {
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Check if it's a version-related error (app needs to be updated)
|
||||
if (error instanceof Error && error.message.includes('This browser extension is outdated')) {
|
||||
await webApi.logout(error.message);
|
||||
onError?.(error.message);
|
||||
if (error instanceof VaultVersionIncompatibleError) {
|
||||
await app.logout(error.message);
|
||||
return false;
|
||||
}
|
||||
// Vault could not be decrypted, throw an error
|
||||
@@ -165,9 +149,8 @@ export const useVaultSync = () : {
|
||||
console.error('Vault sync error:', err);
|
||||
|
||||
// Check if it's a version-related error (app needs to be updated)
|
||||
if (errorMessage.includes('This browser extension is outdated')) {
|
||||
await webApi.logout(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
if (err instanceof VaultVersionIncompatibleError) {
|
||||
await app.logout(errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -185,7 +168,7 @@ export const useVaultSync = () : {
|
||||
onError?.(errorMessage);
|
||||
return false;
|
||||
}
|
||||
}, [authContext, dbContext, webApi, t]);
|
||||
}, [app, dbContext, webApi, t]);
|
||||
|
||||
return { syncVault };
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
import App from '@/entrypoints/popup/App';
|
||||
import { AppProvider } from '@/entrypoints/popup/context/AppContext';
|
||||
import { AuthProvider } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { DbProvider } from '@/entrypoints/popup/context/DbContext';
|
||||
import { HeaderButtonsProvider } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
@@ -17,17 +18,19 @@ const renderApp = (): void => {
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
|
||||
root.render(
|
||||
<DbProvider>
|
||||
<AuthProvider>
|
||||
<WebApiProvider>
|
||||
<LoadingProvider>
|
||||
<HeaderButtonsProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</HeaderButtonsProvider>
|
||||
</LoadingProvider>
|
||||
</WebApiProvider>
|
||||
</AuthProvider>
|
||||
<WebApiProvider>
|
||||
<AuthProvider>
|
||||
<AppProvider>
|
||||
<LoadingProvider>
|
||||
<HeaderButtonsProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</HeaderButtonsProvider>
|
||||
</LoadingProvider>
|
||||
</AppProvider>
|
||||
</AuthProvider>
|
||||
</WebApiProvider>
|
||||
</DbProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,9 +2,10 @@ import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { consumePendingRedirectUrl } from '@/entrypoints/popup/hooks/useVaultLockRedirect';
|
||||
import { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync';
|
||||
|
||||
import { storage } from '#imports';
|
||||
@@ -31,7 +32,7 @@ const Reinitialize: React.FC = () => {
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
// Auth and DB state
|
||||
const { isInitialized: authInitialized, isLoggedIn } = useAuth();
|
||||
const { isInitialized: authInitialized, isLoggedIn } = useApp();
|
||||
const { dbInitialized, dbAvailable } = useDb();
|
||||
|
||||
// Derived state
|
||||
@@ -78,11 +79,20 @@ const Reinitialize: React.FC = () => {
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for inline unlock mode
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const inlineUnlock = urlParams.get('mode') === 'inline_unlock';
|
||||
/**
|
||||
* Handle initialization and redirect logic
|
||||
*/
|
||||
const handleInitialization = async (): Promise<void> => {
|
||||
// Check for inline unlock mode
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const inlineUnlock = urlParams.get('mode') === 'inline_unlock';
|
||||
|
||||
if (isFullyInitialized) {
|
||||
// Check for pending redirect URL in storage (set by useVaultLockRedirect hook)
|
||||
const pendingRedirectUrl = await consumePendingRedirectUrl();
|
||||
|
||||
if (!isFullyInitialized) {
|
||||
return;
|
||||
}
|
||||
// Prevent multiple vault syncs (only run sync once)
|
||||
const shouldRunSync = !hasInitialized.current;
|
||||
|
||||
@@ -110,6 +120,10 @@ const Reinitialize: React.FC = () => {
|
||||
if (inlineUnlock) {
|
||||
setIsInitialLoading(false);
|
||||
navigate('/unlock-success', { replace: true });
|
||||
} else if (pendingRedirectUrl) {
|
||||
// If there's a pending redirect URL in storage, use it (most reliable)
|
||||
setIsInitialLoading(false);
|
||||
navigate(pendingRedirectUrl, { replace: true });
|
||||
} else {
|
||||
await restoreLastPage();
|
||||
}
|
||||
@@ -138,7 +152,9 @@ const Reinitialize: React.FC = () => {
|
||||
setIsInitialLoading(false);
|
||||
restoreLastPage();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleInitialization();
|
||||
}, [isFullyInitialized, requiresAuth, isLoggedIn, dbAvailable, navigate, setIsInitialLoading, syncVault, restoreLastPage]);
|
||||
|
||||
// This component doesn't render anything visible - it just handles initialization
|
||||
|
||||
@@ -26,7 +26,7 @@ const DEFAULT_OPTIONS: ApiOption[] = [
|
||||
*/
|
||||
const createUrlSchema = (t: (key: string) => string): Yup.ObjectSchema<{apiUrl: string; clientUrl: string}> => Yup.object().shape({
|
||||
apiUrl: Yup.string()
|
||||
.required(t('validation.apiUrlRequired'))
|
||||
.required(t('settings.validation.apiUrlRequired'))
|
||||
.test('is-valid-api-url', t('settings.validation.apiUrlInvalid'), (value: string | undefined) => {
|
||||
if (!value) {
|
||||
return true; // Allow empty for non-custom option
|
||||
@@ -39,7 +39,7 @@ const createUrlSchema = (t: (key: string) => string): Yup.ObjectSchema<{apiUrl:
|
||||
}
|
||||
}),
|
||||
clientUrl: Yup.string()
|
||||
.required(t('validation.clientUrlRequired'))
|
||||
.required(t('settings.validation.clientUrlRequired'))
|
||||
.test('is-valid-client-url', t('settings.validation.clientUrlInvalid'), (value: string | undefined) => {
|
||||
if (!value) {
|
||||
return true; // Allow empty for non-custom option
|
||||
@@ -172,76 +172,105 @@ const AuthSettings: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
{/* Language Settings Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{t('common.language')}</p>
|
||||
<LanguageSwitcher variant="dropdown" size="sm" />
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Server Configuration Section */}
|
||||
<div className="space-y-4 pb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t('settings.serverConfiguration', 'Server Configuration')}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('settings.serverConfigurationDescription', 'Configure the AliasVault server URL for self-hosted instances')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="api-connection" className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
{t('settings.serverUrl')}
|
||||
</label>
|
||||
<select
|
||||
id="api-connection"
|
||||
value={selectedOption}
|
||||
onChange={handleOptionChange}
|
||||
className="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||
>
|
||||
{DEFAULT_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedOption === 'custom' && (
|
||||
<div className="space-y-4 pl-4 border-l-2 border-primary-500">
|
||||
<div>
|
||||
<label htmlFor="custom-api-url" className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
{t('settings.customApiUrl', 'API URL')}
|
||||
</label>
|
||||
<input
|
||||
id="custom-api-url"
|
||||
type="text"
|
||||
value={customUrl}
|
||||
onChange={handleCustomUrlChange}
|
||||
placeholder="https://vault.example.com/api"
|
||||
className={`w-full bg-gray-50 border ${errors.apiUrl ? 'border-red-500' : 'border-gray-300'} text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white`}
|
||||
/>
|
||||
{errors.apiUrl && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.apiUrl}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('settings.apiUrlHint', 'The API endpoint URL (usually client URL + /api)')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="custom-client-url" className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
{t('settings.customClientUrl', 'Client URL')}
|
||||
</label>
|
||||
<input
|
||||
id="custom-client-url"
|
||||
type="text"
|
||||
value={customClientUrl}
|
||||
onChange={handleCustomClientUrlChange}
|
||||
placeholder="https://vault.example.com"
|
||||
className={`w-full bg-gray-50 border ${errors.clientUrl ? 'border-red-500' : 'border-gray-300'} text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white`}
|
||||
/>
|
||||
{errors.clientUrl && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.clientUrl}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('settings.clientUrlHint', 'The web interface URL of your self-hosted instance')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="api-connection" className="block font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
{t('settings.serverUrl')}
|
||||
</label>
|
||||
<select
|
||||
value={selectedOption}
|
||||
onChange={handleOptionChange}
|
||||
className="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||
>
|
||||
{DEFAULT_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Autofill Settings Section */}
|
||||
<div className="space-y-4 pb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t('settings.autofillSettings', 'Autofill Settings')}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('settings.autofillSettingsDescription', 'Enable or disable the autofill popup on web pages')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedOption === 'custom' && (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<label htmlFor="custom-client-url" className="block font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
Custom client URL
|
||||
</label>
|
||||
<input
|
||||
id="custom-client-url"
|
||||
type="text"
|
||||
value={customClientUrl}
|
||||
onChange={handleCustomClientUrlChange}
|
||||
placeholder="https://my-aliasvault-instance.com"
|
||||
className={`w-full bg-gray-50 border ${errors.clientUrl ? 'border-red-500' : 'border-gray-300'} text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white`}
|
||||
/>
|
||||
{errors.clientUrl && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.clientUrl}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('settings.autofillEnabled')}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{isGloballyEnabled
|
||||
? t('settings.autofillEnabledDescription', 'Autofill suggestions will appear on login forms')
|
||||
: t('settings.autofillDisabledDescription', 'Autofill suggestions are disabled globally')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label htmlFor="custom-api-url" className="block font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
Custom API URL
|
||||
</label>
|
||||
<input
|
||||
id="custom-api-url"
|
||||
type="text"
|
||||
value={customUrl}
|
||||
onChange={handleCustomUrlChange}
|
||||
placeholder="https://my-aliasvault-instance.com/api"
|
||||
className={`w-full bg-gray-50 border ${errors.apiUrl ? 'border-red-500' : 'border-gray-300'} text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white`}
|
||||
/>
|
||||
{errors.apiUrl && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.apiUrl}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Autofill Popup Settings Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{t('settings.autofillEnabled')}</p>
|
||||
<button
|
||||
onClick={toggleGlobalPopup}
|
||||
className={`px-4 py-2 rounded-md transition-colors ${
|
||||
className={`px-4 py-2 rounded-md transition-colors font-medium text-sm ${
|
||||
isGloballyEnabled
|
||||
? 'bg-green-200 text-green-800 hover:bg-green-300 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
|
||||
: 'bg-red-200 text-red-800 hover:bg-red-300 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
|
||||
@@ -252,7 +281,21 @@ const AuthSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-gray-400 dark:text-gray-600">
|
||||
{/* Language Settings Section */}
|
||||
<div className="space-y-4 pb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t('settings.languageSettings', 'Language')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LanguageSwitcher variant="dropdown" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Info */}
|
||||
<div className="text-center text-xs text-gray-400 dark:text-gray-600 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{t('settings.version')}: {AppInfo.VERSION}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import Button from '@/entrypoints/popup/components/Button';
|
||||
import MobileUnlockModal from '@/entrypoints/popup/components/Dialogs/MobileUnlockModal';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIcon, HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import LoginServerInfo from '@/entrypoints/popup/components/LoginServerInfo';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
@@ -21,6 +22,7 @@ import { AppInfo } from '@/utils/AppInfo';
|
||||
import type { VaultResponse, LoginResponse } from '@/utils/dist/shared/models/webapi';
|
||||
import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
import { ApiAuthError } from '@/utils/types/errors/ApiAuthError';
|
||||
import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
@@ -30,7 +32,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({
|
||||
@@ -47,6 +49,7 @@ const Login: React.FC = () => {
|
||||
const [twoFactorCode, setTwoFactorCode] = useState('');
|
||||
const [clientUrl, setClientUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showMobileLoginModal, setShowMobileLoginModal] = useState(false);
|
||||
const webApi = useWebApi();
|
||||
const srpUtil = new SrpUtility(webApi);
|
||||
|
||||
@@ -65,15 +68,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 +82,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,8 +90,8 @@ const Login: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
await authContext.logout();
|
||||
setError(err instanceof Error ? err.message : t('auth.errors.migrationError'));
|
||||
await app.logout();
|
||||
setError(err instanceof Error ? err.message : t('common.errors.unknownError'));
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
@@ -157,7 +150,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));
|
||||
@@ -200,7 +193,7 @@ const Login: React.FC = () => {
|
||||
|
||||
// Check if token was returned.
|
||||
if (!validationResponse.token) {
|
||||
throw new Error(t('auth.errors.noToken'));
|
||||
throw new Error(t('common.errors.unknownError'));
|
||||
}
|
||||
|
||||
// Handle successful authentication
|
||||
@@ -233,7 +226,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
|
||||
@@ -252,7 +245,7 @@ const Login: React.FC = () => {
|
||||
|
||||
// Check if token was returned.
|
||||
if (!validationResponse.token) {
|
||||
throw new Error(t('auth.errors.noToken'));
|
||||
throw new Error(t('common.errors.unknownError'));
|
||||
}
|
||||
|
||||
// Handle successful authentication
|
||||
@@ -282,6 +275,63 @@ const Login: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle successful mobile login
|
||||
*/
|
||||
const handleMobileLoginSuccess = async (result: MobileLoginResult): Promise<void> => {
|
||||
showLoading();
|
||||
try {
|
||||
// Clear global message if set
|
||||
app.clearGlobalMessage();
|
||||
|
||||
// Fetch vault from server with the new auth token
|
||||
const vaultResponse = await webApi.authFetch<VaultResponse>('Vault', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${result.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Store auth tokens and username
|
||||
await app.setAuthTokens(result.username, result.token, result.refreshToken);
|
||||
|
||||
// Store the encryption key and derivation params
|
||||
await dbContext.storeEncryptionKey(result.decryptionKey);
|
||||
await dbContext.storeEncryptionKeyDerivationParams({
|
||||
salt: result.salt,
|
||||
encryptionType: result.encryptionType,
|
||||
encryptionSettings: result.encryptionSettings,
|
||||
});
|
||||
|
||||
// Initialize the database with the vault data
|
||||
const sqliteClient = await dbContext.initializeDatabase(vaultResponse, result.decryptionKey);
|
||||
|
||||
// Check for pending migrations
|
||||
try {
|
||||
if (await sqliteClient.hasPendingMigrations()) {
|
||||
navigate('/upgrade', { replace: true });
|
||||
hideLoading();
|
||||
setIsInitialLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
await app.logout();
|
||||
setError(err instanceof Error ? err.message : t('common.errors.unknownError'));
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to reinitialize page
|
||||
hideLoading();
|
||||
setIsInitialLoading(false);
|
||||
navigate('/reinitialize', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('common.errors.unknownError'));
|
||||
hideLoading();
|
||||
throw err; // Re-throw to let modal show error
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle change
|
||||
*/
|
||||
@@ -340,7 +390,7 @@ const Login: React.FC = () => {
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
{t('auth.cancel')}
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4 text-center">
|
||||
@@ -352,82 +402,113 @@ const Login: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-700 w-full shadow-md rounded px-8 pt-6 pb-8 mb-4">
|
||||
{error && (
|
||||
<div className="mb-4 text-red-500 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
<div className="flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||
{/* Title */}
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">{t('auth.loginTitle')}</h2>
|
||||
<LoginServerInfo />
|
||||
</div>
|
||||
)}
|
||||
<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 font-bold mb-2" htmlFor="username">
|
||||
{t('auth.username')}
|
||||
</label>
|
||||
<input
|
||||
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"
|
||||
placeholder={t('auth.usernamePlaceholder')}
|
||||
value={credentials.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-200 font-bold mb-2" htmlFor="password">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 text-red-500 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-200 font-medium mb-2" htmlFor="username">
|
||||
{t('auth.username')}
|
||||
</label>
|
||||
<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}
|
||||
className="shadow appearance-none border rounded-lg w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-700 dark:border-gray-600 leading-tight focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
id="username"
|
||||
type="text"
|
||||
name="username"
|
||||
placeholder={t('auth.usernamePlaceholder')}
|
||||
value={credentials.username}
|
||||
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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-200">{t('auth.rememberMe')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-200 font-medium mb-2" htmlFor="password">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="shadow appearance-none border rounded-lg w-full py-2 px-3 pr-10 text-gray-700 dark:text-gray-200 dark:bg-gray-700 dark:border-gray-600 leading-tight focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-200">{t('auth.rememberMe')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button type="submit">
|
||||
{t('auth.loginButton')}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{t('auth.loginButton')}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="text-center text-gray-600 dark:text-gray-400">
|
||||
{t('auth.noAccount')}{' '}
|
||||
<a
|
||||
href={clientUrl ?? ''}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-orange-500 hover:text-orange-600 dark:text-orange-400 dark:hover:text-orange-500"
|
||||
>
|
||||
{t('auth.createVault')}
|
||||
</a>
|
||||
|
||||
{/* Mobile Login Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMobileLoginModal(true)}
|
||||
className="w-full max-w-md mt-4 px-4 py-2 text-sm font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 dark:bg-gray-600 dark:text-white dark:border-gray-500 dark:hover:bg-gray-500 dark:focus:ring-gray-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
{t('auth.loginWithMobile')}
|
||||
</button>
|
||||
|
||||
<div className="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
|
||||
{t('auth.noAccount')}{' '}
|
||||
<a
|
||||
href={clientUrl ?? ''}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-orange-500 hover:text-orange-600 dark:text-orange-400 dark:hover:text-orange-500"
|
||||
>
|
||||
{t('auth.createVault')}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Mobile Login Modal */}
|
||||
<MobileUnlockModal
|
||||
isOpen={showMobileLoginModal}
|
||||
onClose={() => setShowMobileLoginModal(false)}
|
||||
onSuccess={handleMobileLoginSuccess}
|
||||
webApi={webApi}
|
||||
mode="login"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import AlertMessage from '@/entrypoints/popup/components/AlertMessage';
|
||||
import Button from '@/entrypoints/popup/components/Button';
|
||||
import MobileUnlockModal from '@/entrypoints/popup/components/Dialogs/MobileUnlockModal';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIcon, HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import UsernameAvatar from '@/entrypoints/popup/components/Unlock/UsernameAvatar';
|
||||
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,14 +22,31 @@ 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 {
|
||||
getPinLength,
|
||||
isPinEnabled,
|
||||
PinLockedError,
|
||||
IncorrectPinError,
|
||||
InvalidPinFormatError,
|
||||
resetFailedAttempts,
|
||||
unlockWithPin
|
||||
} from '@/utils/PinUnlockService';
|
||||
import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError';
|
||||
import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
/**
|
||||
* Unlock page
|
||||
* Unlock mode type
|
||||
*/
|
||||
type UnlockMode = 'pin' | 'password';
|
||||
|
||||
/**
|
||||
* Unified unlock page that handles both PIN and password unlock
|
||||
*/
|
||||
const Unlock: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const app = useApp();
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
const navigate = useNavigate();
|
||||
@@ -34,27 +55,80 @@ const Unlock: React.FC = () => {
|
||||
const webApi = useWebApi();
|
||||
const srpUtil = new SrpUtility(webApi);
|
||||
|
||||
// Unlock mode state
|
||||
const [unlockMode, setUnlockMode] = useState<UnlockMode>('password');
|
||||
const [pinAvailable, setPinAvailable] = useState<boolean>(false);
|
||||
|
||||
// Password unlock state
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// PIN unlock state
|
||||
const [pin, setPin] = useState('');
|
||||
const [pinLength, setPinLength] = useState<number>(6);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Common state
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { showLoading, hideLoading, setIsInitialLoading } = useLoading();
|
||||
|
||||
// Mobile unlock state
|
||||
const [showMobileUnlockModal, setShowMobileUnlockModal] = useState(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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize unlock page - check status and PIN availability
|
||||
*/
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Make status call to API which acts as health check.
|
||||
* Initialize unlock page - check status and PIN availability
|
||||
*/
|
||||
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');
|
||||
const initialize = async (): Promise<void> => {
|
||||
// First check PIN availability and set initial mode
|
||||
const [pinEnabled, pinLength] = await Promise.all([
|
||||
isPinEnabled(),
|
||||
getPinLength(),
|
||||
]);
|
||||
|
||||
setPinAvailable(pinEnabled);
|
||||
setPinLength(pinLength || 6);
|
||||
|
||||
// Default to PIN mode if available, otherwise password
|
||||
if (pinEnabled) {
|
||||
setUnlockMode('pin');
|
||||
} else {
|
||||
setUnlockMode('password');
|
||||
}
|
||||
setIsInitialLoading(false);
|
||||
|
||||
// Then check API status
|
||||
await checkStatus();
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
}, [webApi, authContext, setIsInitialLoading, navigate, t]);
|
||||
initialize();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Only run once on mount
|
||||
|
||||
// Set header buttons on mount and clear on unmount
|
||||
useEffect((): (() => void) => {
|
||||
@@ -74,13 +148,66 @@ const Unlock: React.FC = () => {
|
||||
}, [setHeaderButtons, t]);
|
||||
|
||||
/**
|
||||
* Handle submit
|
||||
* Keep input focused for PIN mode
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent) : Promise<void> => {
|
||||
useEffect(() => {
|
||||
if (unlockMode !== 'pin') {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the hidden input element
|
||||
*/
|
||||
const focusInput = (): void => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-focus input whenever user clicks anywhere on the page
|
||||
*/
|
||||
const handleClick = (): void => {
|
||||
focusInput();
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-focus input when window/extension regains focus
|
||||
*/
|
||||
const handleFocus = (): void => {
|
||||
focusInput();
|
||||
};
|
||||
|
||||
focusInput();
|
||||
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
container.addEventListener('click', handleClick);
|
||||
}
|
||||
window.addEventListener('focus', handleFocus);
|
||||
|
||||
return (): void => {
|
||||
if (container) {
|
||||
container.removeEventListener('click', handleClick);
|
||||
}
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
};
|
||||
}, [unlockMode]);
|
||||
|
||||
/**
|
||||
* Handle password unlock
|
||||
*/
|
||||
const handlePasswordSubmit = async (e: React.FormEvent) : Promise<void> => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
showLoading();
|
||||
|
||||
const isStatusOk = await checkStatus();
|
||||
if (!isStatusOk) {
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Initiate login to get salt and server ephemeral
|
||||
const loginResponse = await srpUtil.initiateLogin(authContext.username!);
|
||||
@@ -96,13 +223,6 @@ const Unlock: React.FC = () => {
|
||||
// Make API call to get latest vault
|
||||
const vaultResponseJson = await webApi.get<VaultResponse>('Vault');
|
||||
|
||||
const vaultError = webApi.validateVaultResponse(vaultResponseJson, t);
|
||||
if (vaultError) {
|
||||
setError(t('common.apiErrors.' + vaultError));
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the derived key as base64 string required for decryption.
|
||||
const passwordHashBase64 = Buffer.from(passwordHash).toString('base64');
|
||||
|
||||
@@ -110,95 +230,390 @@ 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);
|
||||
|
||||
// Clear dismiss until (which can be enabled after user has dimissed vault is locked popup) to ensure popup is shown.
|
||||
// Check if there are pending migrations
|
||||
if (await sqliteClient.hasPendingMigrations()) {
|
||||
navigate('/upgrade', { replace: true });
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear dismiss until
|
||||
await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, 0);
|
||||
|
||||
// Redirect to reinitialize page
|
||||
// Reset PIN failed attempts on successful password unlock
|
||||
await resetFailedAttempts();
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle PIN input change
|
||||
*/
|
||||
const handlePinChange = useCallback(async (newPin: string): Promise<void> => {
|
||||
setPin(newPin);
|
||||
setError(null);
|
||||
|
||||
// Auto-submit when PIN length is reached
|
||||
if (newPin.length === pinLength) {
|
||||
// Small delay to allow UI to update with the last digit before showing loading spinner
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
await handlePinUnlock(newPin);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pinLength]);
|
||||
|
||||
/**
|
||||
* Handle numpad button click
|
||||
*/
|
||||
const handleNumpadClick = (digit: string): void => {
|
||||
if (pin.length < 8) {
|
||||
handlePinChange(pin + digit);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle backspace
|
||||
*/
|
||||
const handleBackspace = (): void => {
|
||||
setPin(pin.slice(0, -1));
|
||||
setError(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle PIN unlock
|
||||
*/
|
||||
const handlePinUnlock = async (pinToUse: string = pin): Promise<void> => {
|
||||
if (pinToUse.length !== pinLength) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
showLoading();
|
||||
|
||||
try {
|
||||
// Unlock with PIN
|
||||
const passwordHashBase64 = await unlockWithPin(pinToUse);
|
||||
|
||||
// Get latest vault from API
|
||||
const vaultResponseJson = await webApi.get<VaultResponse>('Vault');
|
||||
|
||||
// Store the encryption key in session storage
|
||||
await dbContext.storeEncryptionKey(passwordHashBase64);
|
||||
|
||||
// Initialize the SQLite context with the vault data
|
||||
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
|
||||
await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, 0);
|
||||
|
||||
navigate('/reinitialize', { replace: true });
|
||||
hideLoading();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof PinLockedError) {
|
||||
setPinAvailable(false);
|
||||
setUnlockMode('password');
|
||||
setError(t('settings.unlockMethod.pinLocked'));
|
||||
} else if (err instanceof IncorrectPinError) {
|
||||
/* Show translatable error with attempts remaining */
|
||||
const attemptsRemaining = err.attemptsRemaining;
|
||||
if (attemptsRemaining === 1) {
|
||||
setError(t('settings.unlockMethod.incorrectPinSingular'));
|
||||
} else {
|
||||
setError(t('settings.unlockMethod.incorrectPin', { attemptsRemaining }));
|
||||
}
|
||||
setPin('');
|
||||
} else if (err instanceof InvalidPinFormatError) {
|
||||
setError(t('settings.unlockMethod.invalidPinFormat'));
|
||||
setPin('');
|
||||
} else {
|
||||
console.error('PIN unlock failed:', err);
|
||||
setError(t('common.errors.unknownErrorTryAgain'));
|
||||
setPin('');
|
||||
}
|
||||
hideLoading();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle logout
|
||||
*/
|
||||
const handleLogout = () : void => {
|
||||
navigate('/logout', { replace: true });
|
||||
app.logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-700 w-full shadow-md rounded px-8 pt-6 pb-8 mb-4">
|
||||
{/* User Avatar and Username Section */}
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
|
||||
<span className="text-primary-600 dark:text-primary-400 text-lg font-medium">
|
||||
{authContext.username?.[0]?.toUpperCase() || '?'}
|
||||
</span>
|
||||
/**
|
||||
* Handle successful mobile unlock
|
||||
*/
|
||||
const handleMobileUnlockSuccess = async (result: MobileLoginResult): Promise<void> => {
|
||||
showLoading();
|
||||
try {
|
||||
// Revoke current tokens before setting new ones (since we're already logged in)
|
||||
await webApi.revokeTokens();
|
||||
|
||||
// Set new auth tokens
|
||||
await authContext.setAuthTokens(result.username, result.token, result.refreshToken);
|
||||
|
||||
// Fetch vault from server with the new auth token
|
||||
const vaultResponse = await webApi.get<VaultResponse>('Vault');
|
||||
|
||||
// Store the encryption key and derivation params
|
||||
await dbContext.storeEncryptionKey(result.decryptionKey);
|
||||
await dbContext.storeEncryptionKeyDerivationParams({
|
||||
salt: result.salt,
|
||||
encryptionType: result.encryptionType,
|
||||
encryptionSettings: result.encryptionSettings,
|
||||
});
|
||||
|
||||
// Initialize the database with the vault data
|
||||
const sqliteClient = await dbContext.initializeDatabase(vaultResponse, result.decryptionKey);
|
||||
|
||||
// Check if there are pending migrations
|
||||
if (await sqliteClient.hasPendingMigrations()) {
|
||||
navigate('/upgrade', { replace: true });
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear dismiss until
|
||||
await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, 0);
|
||||
|
||||
// Reset PIN failed attempts on successful unlock
|
||||
await resetFailedAttempts();
|
||||
|
||||
navigate('/reinitialize', { replace: true });
|
||||
} catch (err) {
|
||||
// Check if it's a version incompatibility error
|
||||
if (err instanceof VaultVersionIncompatibleError) {
|
||||
await app.logout(err.message);
|
||||
} else {
|
||||
setError(t('common.errors.unknownErrorTryAgain'));
|
||||
}
|
||||
console.error('Mobile unlock error:', err);
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Switch to password mode
|
||||
*/
|
||||
const switchToPassword = () : void => {
|
||||
setUnlockMode('password');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Switch to PIN mode
|
||||
*/
|
||||
const switchToPin = () : void => {
|
||||
setUnlockMode('pin');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
// Generate PIN dots display
|
||||
const pinDots = Array.from({ length: pinLength }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-4 h-4 rounded-full border-2 transition-all ${
|
||||
i < pin.length
|
||||
? 'bg-primary-500 border-primary-500'
|
||||
: 'bg-transparent border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
));
|
||||
|
||||
// Render PIN unlock UI
|
||||
if (unlockMode === 'pin') {
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* User Avatar and Username Section */}
|
||||
<UsernameAvatar />
|
||||
|
||||
{/* Main Content Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||
{/* Title */}
|
||||
<div className="text-center mb-4">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
{t('auth.unlockTitle')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('auth.enterPinToUnlock')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* PIN Dots Display */}
|
||||
<div className="flex justify-center gap-2 mb-4">
|
||||
{pinDots}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && <AlertMessage type="error" message={error} className="mb-3 text-center" />}
|
||||
|
||||
{/* Hidden Input for Keyboard Entry */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={8}
|
||||
value={pin}
|
||||
onChange={(e) => handlePinChange(e.target.value.replace(/\D/g, ''))}
|
||||
className="w-0 h-0 opacity-0 absolute"
|
||||
autoFocus
|
||||
aria-label="PIN input"
|
||||
/>
|
||||
|
||||
{/* On-Screen Numpad */}
|
||||
<div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{/* Numbers 1-9 */}
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => (
|
||||
<button
|
||||
key={num}
|
||||
type="button"
|
||||
onClick={() => handleNumpadClick(num.toString())}
|
||||
className="h-12 flex items-center justify-center text-xl font-semibold bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white rounded-lg transition-colors active:scale-95"
|
||||
>
|
||||
{num}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Empty space, 0, Backspace */}
|
||||
<div />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNumpadClick('0')}
|
||||
className="h-12 flex items-center justify-center text-xl font-semibold bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white rounded-lg transition-colors active:scale-95"
|
||||
>
|
||||
0
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackspace}
|
||||
className="h-12 flex items-center justify-center bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white rounded-lg transition-colors active:scale-95"
|
||||
aria-label="Backspace"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M3 12l6.414 6.414a2 2 0 001.414.586H19a2 2 0 002-2V7a2 2 0 00-2-2h-8.172a2 2 0 00-1.414.586L3 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{authContext.username}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('auth.loggedIn')}
|
||||
</p>
|
||||
|
||||
{/* Use Password Button */}
|
||||
<div className="mt-4">
|
||||
<div className="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
|
||||
<button type="button" onClick={switchToPassword} className="text-primary-600 hover:text-primary-700 dark:text-primary-500 dark:hover:text-primary-400 hover:underline font-medium">{t('auth.useMasterPassword')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Instruction Title */}
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('auth.unlockTitle')}
|
||||
</h2>
|
||||
// Render password unlock UI
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* User Avatar and Username Section */}
|
||||
<UsernameAvatar />
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 text-red-500 dark:text-red-400">
|
||||
{error}
|
||||
{/* Main Content Card */}
|
||||
<form onSubmit={handlePasswordSubmit} className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||
{/* Title */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{t('auth.unlockTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-2">
|
||||
<label className="block text-gray-700 dark:text-gray-200 font-bold mb-2" htmlFor="password">
|
||||
{t('auth.masterPassword')}
|
||||
</label>
|
||||
<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>
|
||||
{/* Error Message */}
|
||||
{error && <AlertMessage type="error" message={error} className="mb-4 text-center" />}
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-200 font-medium mb-2" htmlFor="password">
|
||||
{t('auth.masterPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="shadow appearance-none border rounded-lg w-full py-2 px-3 pr-10 text-gray-700 dark:text-gray-200 dark:bg-gray-700 dark:border-gray-600 leading-tight focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<Button type="submit">
|
||||
{t('auth.unlockVault')}
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
{t('auth.unlockVault')}
|
||||
</Button>
|
||||
|
||||
<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>
|
||||
{/* Mobile Unlock Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMobileUnlockModal(true)}
|
||||
className="w-full max-w-md mt-4 px-4 py-2 text-sm font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 dark:bg-gray-600 dark:text-white dark:border-gray-500 dark:hover:bg-gray-500 dark:focus:ring-gray-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
{t('auth.unlockWithMobile')}
|
||||
</button>
|
||||
|
||||
<div className="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
|
||||
{t('auth.switchAccounts')} <button type="button" onClick={handleLogout} className="text-primary-600 hover:text-primary-700 dark:text-primary-500 dark:hover:text-primary-400 hover:underline font-medium">{t('auth.logout')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{pinAvailable && (
|
||||
<div className="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
|
||||
<button type="button" onClick={switchToPin} className="text-primary-600 hover:text-primary-700 dark:text-primary-500 dark:hover:text-primary-400 hover:underline font-medium">{t('auth.unlockWithPin')}</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Mobile Unlock Modal */}
|
||||
<MobileUnlockModal
|
||||
isOpen={showMobileUnlockModal}
|
||||
onClose={() => setShowMobileUnlockModal(false)}
|
||||
onSuccess={handleMobileUnlockSuccess}
|
||||
webApi={webApi}
|
||||
mode="unlock"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import Button from '@/entrypoints/popup/components/Button';
|
||||
import Modal from '@/entrypoints/popup/components/Dialogs/Modal';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import Modal from '@/entrypoints/popup/components/Modal';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
@@ -24,7 +24,7 @@ import { VaultSqlGenerator } from '@/utils/dist/shared/vault-sql';
|
||||
*/
|
||||
const Upgrade: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { username } = useAuth();
|
||||
const { username, logout } = useApp();
|
||||
const dbContext = useDb();
|
||||
const { sqliteClient } = dbContext;
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
@@ -65,7 +65,7 @@ const Upgrade: React.FC = () => {
|
||||
const loadVersionInfo = useCallback(async () => {
|
||||
try {
|
||||
if (sqliteClient) {
|
||||
const current = sqliteClient.getDatabaseVersion();
|
||||
const current = await sqliteClient.getDatabaseVersion();
|
||||
const latest = await sqliteClient.getLatestDatabaseVersion();
|
||||
setCurrentVersion(current);
|
||||
setLatestVersion(latest);
|
||||
@@ -165,7 +165,7 @@ const Upgrade: React.FC = () => {
|
||||
console.debug('executeVaultMutation done?');
|
||||
} catch (error) {
|
||||
console.error('Upgrade failed:', error);
|
||||
setError(error instanceof Error ? error.message : t('upgrade.alerts.unknownErrorDuringUpgrade'));
|
||||
setError(error instanceof Error ? error.message : t('common.errors.unknownError'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -206,7 +206,7 @@ const Upgrade: React.FC = () => {
|
||||
* Handle the logout.
|
||||
*/
|
||||
const handleLogout = async (): Promise<void> => {
|
||||
navigate('/logout');
|
||||
logout();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -296,7 +296,7 @@ const Upgrade: React.FC = () => {
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{t('upgrade.yourVault')}</span>
|
||||
<span className="text-sm font-bold text-orange-600 dark:text-orange-400">
|
||||
{currentVersion?.releaseVersion ?? '...'}
|
||||
{currentVersion?.compatibleUpToVersion ?? '...'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -312,6 +312,7 @@ const Upgrade: React.FC = () => {
|
||||
<div className="flex flex-col w-full space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
id="upgrade-button"
|
||||
onClick={handleUpgrade}
|
||||
>
|
||||
{isLoading || isVaultMutationLoading ? (syncStatus || t('upgrade.upgrading')) : t('upgrade.upgrade')}
|
||||
|
||||
@@ -8,15 +8,16 @@ 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 TotpEditor from '@/entrypoints/popup/components/Credentials/Details/TotpEditor';
|
||||
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';
|
||||
@@ -24,8 +25,8 @@ 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 { IdentityHelperUtils, CreateIdentityGenerator, CreateUsernameEmailGenerator, Identity, Gender, convertAgeRangeToBirthdateOptions } from '@/utils/dist/shared/identity-generator';
|
||||
import type { Attachment, Credential, TotpCode } from '@/utils/dist/shared/models/vault';
|
||||
import { CreatePasswordGenerator } from '@/utils/dist/shared/password-generator';
|
||||
import { ServiceDetectionUtility } from '@/utils/serviceDetection/ServiceDetectionUtility';
|
||||
|
||||
@@ -38,6 +39,13 @@ type PersistedFormData = {
|
||||
credentialId: string | null;
|
||||
mode: CredentialMode;
|
||||
formValues: Omit<Credential, 'Logo'> & { Logo?: string | null };
|
||||
totpEditorState?: {
|
||||
isAddFormVisible: boolean;
|
||||
formData: {
|
||||
name: string;
|
||||
secretKey: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +100,16 @@ const CredentialAddEdit: React.FC = () => {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [originalAttachmentIds, setOriginalAttachmentIds] = useState<string[]>([]);
|
||||
const [totpCodes, setTotpCodes] = useState<TotpCode[]>([]);
|
||||
const [originalTotpCodeIds, setOriginalTotpCodeIds] = useState<string[]>([]);
|
||||
const [totpEditorState, setTotpEditorState] = useState<{
|
||||
isAddFormVisible: boolean;
|
||||
formData: { name: string; secretKey: string };
|
||||
}>({
|
||||
isAddFormVisible: false,
|
||||
formData: { name: '', secretKey: '' }
|
||||
});
|
||||
const [passkeyMarkedForDeletion, setPasskeyMarkedForDeletion] = useState(false);
|
||||
const webApi = useWebApi();
|
||||
|
||||
// Track last generated values to avoid overwriting manual entries
|
||||
@@ -140,19 +158,20 @@ const CredentialAddEdit: React.FC = () => {
|
||||
formValues: {
|
||||
...formValues,
|
||||
Logo: null // Don't persist the Logo field as it can't be user modified in the UI.
|
||||
}
|
||||
},
|
||||
totpEditorState
|
||||
};
|
||||
await sendMessage('PERSIST_FORM_VALUES', JSON.stringify(persistedData), 'background');
|
||||
}, [watch, id, mode, localLoading]);
|
||||
}, [watch, id, mode, localLoading, totpEditorState]);
|
||||
|
||||
/**
|
||||
* Watch for mode changes and persist form values
|
||||
* Watch for mode and totpEditorState changes and persist form values
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!localLoading) {
|
||||
void persistFormValues();
|
||||
}
|
||||
}, [mode, persistFormValues, localLoading]);
|
||||
}, [mode, totpEditorState, persistFormValues, localLoading]);
|
||||
|
||||
// Watch for form changes and persist them
|
||||
useEffect(() => {
|
||||
@@ -199,6 +218,11 @@ const CredentialAddEdit: React.FC = () => {
|
||||
Object.entries(persistedDataObject.formValues).forEach(([key, value]) => {
|
||||
setValue(key as keyof Credential, value as Credential[keyof Credential]);
|
||||
});
|
||||
|
||||
// Restore TOTP editor state if it exists
|
||||
if (persistedDataObject.totpEditorState) {
|
||||
setTotpEditorState(persistedDataObject.totpEditorState);
|
||||
}
|
||||
} else {
|
||||
console.error('Persisted values do not match current page');
|
||||
}
|
||||
@@ -329,6 +353,11 @@ const CredentialAddEdit: React.FC = () => {
|
||||
setAttachments(credentialAttachments);
|
||||
setOriginalAttachmentIds(credentialAttachments.map(a => a.Id));
|
||||
|
||||
// Load TOTP codes for this credential
|
||||
const credentialTotpCodes = dbContext.sqliteClient.getTotpCodesForCredential(id);
|
||||
setTotpCodes(credentialTotpCodes);
|
||||
setOriginalTotpCodeIds(credentialTotpCodes.map(tc => tc.Id));
|
||||
|
||||
setMode('manual');
|
||||
setIsInitialLoading(false);
|
||||
|
||||
@@ -369,8 +398,8 @@ const CredentialAddEdit: React.FC = () => {
|
||||
* Initialize the identity and password generators with settings from user's vault.
|
||||
*/
|
||||
const initializeGenerators = useCallback(async () => {
|
||||
// Get default identity language from database
|
||||
const identityLanguage = dbContext.sqliteClient!.getDefaultIdentityLanguage();
|
||||
// Get effective identity language (smart default based on UI language if no explicit override)
|
||||
const identityLanguage = await dbContext.sqliteClient!.getEffectiveIdentityLanguage();
|
||||
|
||||
// Initialize identity generator based on language
|
||||
const identityGenerator = CreateIdentityGenerator(identityLanguage);
|
||||
@@ -391,15 +420,15 @@ const CredentialAddEdit: React.FC = () => {
|
||||
// Get gender preference from database
|
||||
const genderPreference = dbContext.sqliteClient!.getDefaultIdentityGender();
|
||||
|
||||
// Generate identity with gender preference
|
||||
const identity = identityGenerator.generateRandomIdentity(genderPreference);
|
||||
// Get age range preference and convert to birthdate options
|
||||
const ageRange = dbContext.sqliteClient!.getDefaultIdentityAgeRange();
|
||||
const birthdateOptions = convertAgeRangeToBirthdateOptions(ageRange);
|
||||
|
||||
// Generate identity with gender preference and birthdate options (null is handled by generator)
|
||||
const identity = identityGenerator.generateRandomIdentity(genderPreference, birthdateOptions);
|
||||
const password = passwordGenerator.generateRandomPassword();
|
||||
|
||||
const metadata = await dbContext.getVaultMetadata();
|
||||
|
||||
const privateEmailDomains = metadata?.privateEmailDomains ?? [];
|
||||
const publicEmailDomains = metadata?.publicEmailDomains ?? [];
|
||||
const defaultEmailDomain = dbContext.sqliteClient!.getDefaultEmailDomain(privateEmailDomains, publicEmailDomains);
|
||||
const defaultEmailDomain = await dbContext.sqliteClient!.getDefaultEmailDomain();
|
||||
const email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix;
|
||||
|
||||
// Check current values
|
||||
@@ -462,36 +491,57 @@ const CredentialAddEdit: React.FC = () => {
|
||||
|
||||
const generateRandomUsername = useCallback(async () => {
|
||||
try {
|
||||
const usernameEmailGenerator = CreateUsernameEmailGenerator();
|
||||
const firstName = watch('Alias.FirstName') ?? '';
|
||||
const lastName = watch('Alias.LastName') ?? '';
|
||||
const nickName = watch('Alias.NickName') ?? '';
|
||||
const birthDate = watch('Alias.BirthDate') ?? '';
|
||||
|
||||
let gender = Gender.Other;
|
||||
try {
|
||||
gender = watch('Alias.Gender') as Gender;
|
||||
} catch {
|
||||
// Gender parsing failed, default to other.
|
||||
let username: string;
|
||||
|
||||
// If alias fields are empty, generate a completely random username
|
||||
if (!firstName && !lastName && !nickName && !birthDate) {
|
||||
const { identityGenerator } = await initializeGenerators();
|
||||
const genderPreference = dbContext.sqliteClient!.getDefaultIdentityGender();
|
||||
const ageRange = dbContext.sqliteClient!.getDefaultIdentityAgeRange();
|
||||
const birthdateOptions = convertAgeRangeToBirthdateOptions(ageRange);
|
||||
const randomIdentity = identityGenerator.generateRandomIdentity(genderPreference, birthdateOptions);
|
||||
username = randomIdentity.nickName;
|
||||
} else {
|
||||
// Generate username based on current identity fields
|
||||
const usernameEmailGenerator = CreateUsernameEmailGenerator();
|
||||
|
||||
let gender = Gender.Other;
|
||||
try {
|
||||
gender = watch('Alias.Gender') as Gender;
|
||||
} catch {
|
||||
// Gender parsing failed, default to other.
|
||||
}
|
||||
|
||||
// Parse birthDate, fallback to current date if invalid
|
||||
let parsedBirthDate = new Date(birthDate);
|
||||
if (!birthDate || isNaN(parsedBirthDate.getTime())) {
|
||||
parsedBirthDate = new Date();
|
||||
}
|
||||
|
||||
const identity: Identity = {
|
||||
firstName,
|
||||
lastName,
|
||||
nickName,
|
||||
gender,
|
||||
birthDate: parsedBirthDate,
|
||||
emailPrefix: watch('Alias.Email') ?? '',
|
||||
};
|
||||
|
||||
username = usernameEmailGenerator.generateUsername(identity);
|
||||
}
|
||||
|
||||
const identity: Identity = {
|
||||
firstName: watch('Alias.FirstName') ?? '',
|
||||
lastName: watch('Alias.LastName') ?? '',
|
||||
nickName: watch('Alias.NickName') ?? '',
|
||||
gender: gender,
|
||||
birthDate: new Date(watch('Alias.BirthDate') ?? ''),
|
||||
emailPrefix: watch('Alias.Email') ?? '',
|
||||
};
|
||||
|
||||
const username = usernameEmailGenerator.generateUsername(identity);
|
||||
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 }));
|
||||
}
|
||||
setValue('Username', username);
|
||||
// Update the tracking for username
|
||||
setLastGeneratedValues(prev => ({ ...prev, username }));
|
||||
} catch (error) {
|
||||
console.error('Error generating random username:', error);
|
||||
}
|
||||
}, [setValue, watch, lastGeneratedValues, setLastGeneratedValues]);
|
||||
}, [setValue, watch, setLastGeneratedValues, initializeGenerators, dbContext.sqliteClient]);
|
||||
|
||||
/**
|
||||
* Handle form submission.
|
||||
@@ -517,7 +567,7 @@ const CredentialAddEdit: React.FC = () => {
|
||||
data.Alias.FirstName = watch('Alias.FirstName');
|
||||
data.Alias.LastName = watch('Alias.LastName');
|
||||
data.Alias.NickName = watch('Alias.NickName');
|
||||
data.Alias.BirthDate = birthdate;
|
||||
data.Alias.BirthDate = watch('Alias.BirthDate');
|
||||
data.Alias.Gender = watch('Alias.Gender');
|
||||
data.Alias.Email = watch('Alias.Email');
|
||||
// Clean up ServiceUrl for random mode too
|
||||
@@ -549,9 +599,14 @@ const CredentialAddEdit: React.FC = () => {
|
||||
setLocalLoading(false);
|
||||
|
||||
if (isEditMode) {
|
||||
await dbContext.sqliteClient!.updateCredentialById(data, originalAttachmentIds, attachments);
|
||||
await dbContext.sqliteClient!.updateCredentialById(data, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes);
|
||||
|
||||
// Delete passkeys if marked for deletion
|
||||
if (passkeyMarkedForDeletion) {
|
||||
await dbContext.sqliteClient!.deletePasskeysByCredentialId(data.Id);
|
||||
}
|
||||
} else {
|
||||
const credentialId = await dbContext.sqliteClient!.createCredential(data, attachments);
|
||||
const credentialId = await dbContext.sqliteClient!.createCredential(data, attachments, totpCodes);
|
||||
data.Id = credentialId.toString();
|
||||
}
|
||||
}, {
|
||||
@@ -570,7 +625,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, originalTotpCodeIds, totpCodes, passkeyMarkedForDeletion]);
|
||||
|
||||
// Set header buttons on mount and clear on unmount
|
||||
useEffect((): (() => void) => {
|
||||
@@ -695,30 +750,167 @@ const CredentialAddEdit: React.FC = () => {
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('credentials.loginCredentials')}</h2>
|
||||
<div className="space-y-4">
|
||||
<EmailDomainField
|
||||
id="email"
|
||||
label={t('common.email')}
|
||||
value={watch('Alias.Email') ?? ''}
|
||||
onChange={(value: string) => setValue('Alias.Email', value)}
|
||||
error={errors.Alias?.Email?.message}
|
||||
/>
|
||||
<UsernameField
|
||||
id="username"
|
||||
label={t('common.username')}
|
||||
value={watch('Username') ?? ''}
|
||||
onChange={(value) => setValue('Username', value)}
|
||||
error={errors.Username?.message}
|
||||
onRegenerate={generateRandomUsername}
|
||||
/>
|
||||
<PasswordField
|
||||
id="password"
|
||||
label={t('common.password')}
|
||||
value={watch('Password') ?? ''}
|
||||
onChange={(value) => setValue('Password', value)}
|
||||
error={errors.Password?.message}
|
||||
showPassword={showPassword}
|
||||
onShowPasswordChange={setShowPassword}
|
||||
/>
|
||||
{watch('HasPasskey') ? (
|
||||
<>
|
||||
{/* When passkey exists: username, passkey, email, password */}
|
||||
<UsernameField
|
||||
id="username"
|
||||
label={t('common.username')}
|
||||
value={watch('Username') ?? ''}
|
||||
onChange={(value) => setValue('Username', value)}
|
||||
error={errors.Username?.message}
|
||||
onRegenerate={generateRandomUsername}
|
||||
/>
|
||||
{!passkeyMarkedForDeletion && (
|
||||
<div className="p-3 rounded bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-600 dark:text-gray-400 mt-0.5 flex-shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{t('passkeys.passkey')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPasskeyMarkedForDeletion(true)}
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
||||
title="Delete passkey"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<line x1="10" y1="11" x2="10" y2="17" />
|
||||
<line x1="14" y1="11" x2="14" y2="17" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1 mb-2">
|
||||
{watch('PasskeyRpId') && (
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{t('passkeys.site')}: </span>
|
||||
<span className="text-sm text-gray-900 dark:text-white">{watch('PasskeyRpId')}</span>
|
||||
</div>
|
||||
)}
|
||||
{watch('PasskeyDisplayName') && (
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{t('passkeys.displayName')}: </span>
|
||||
<span className="text-sm text-gray-900 dark:text-white">{watch('PasskeyDisplayName')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{t('passkeys.helpText')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{passkeyMarkedForDeletion && (
|
||||
<div className="p-3 rounded bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-red-900 dark:text-red-100">{t('passkeys.passkeyMarkedForDeletion')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPasskeyMarkedForDeletion(false)}
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
title="Undo"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 7v6h6" />
|
||||
<path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-red-800 dark:text-red-200">
|
||||
{t('passkeys.passkeyWillBeDeleted')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<EmailDomainField
|
||||
id="email"
|
||||
label={t('common.email')}
|
||||
value={watch('Alias.Email') ?? ''}
|
||||
onChange={(value: string) => setValue('Alias.Email', value)}
|
||||
error={errors.Alias?.Email?.message}
|
||||
/>
|
||||
<PasswordField
|
||||
id="password"
|
||||
label={t('common.password')}
|
||||
value={watch('Password') ?? ''}
|
||||
onChange={(value) => setValue('Password', value)}
|
||||
error={errors.Password?.message}
|
||||
showPassword={showPassword}
|
||||
onShowPasswordChange={setShowPassword}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* When no passkey: email, username, password */}
|
||||
<EmailDomainField
|
||||
id="email"
|
||||
label={t('common.email')}
|
||||
value={watch('Alias.Email') ?? ''}
|
||||
onChange={(value: string) => setValue('Alias.Email', value)}
|
||||
error={errors.Alias?.Email?.message}
|
||||
/>
|
||||
<UsernameField
|
||||
id="username"
|
||||
label={t('common.username')}
|
||||
value={watch('Username') ?? ''}
|
||||
onChange={(value) => setValue('Username', value)}
|
||||
error={errors.Username?.message}
|
||||
onRegenerate={generateRandomUsername}
|
||||
/>
|
||||
<PasswordField
|
||||
id="password"
|
||||
label={t('common.password')}
|
||||
value={watch('Password') ?? ''}
|
||||
onChange={(value) => setValue('Password', value)}
|
||||
error={errors.Password?.message}
|
||||
showPassword={showPassword}
|
||||
onShowPasswordChange={setShowPassword}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -810,6 +1002,15 @@ const CredentialAddEdit: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TotpEditor
|
||||
totpCodes={totpCodes}
|
||||
onTotpCodesChange={setTotpCodes}
|
||||
originalTotpCodeIds={originalTotpCodeIds}
|
||||
isAddFormVisible={totpEditorState.isAddFormVisible}
|
||||
formData={totpEditorState.formData}
|
||||
onStateChange={setTotpEditorState}
|
||||
/>
|
||||
|
||||
<AttachmentUploader
|
||||
attachments={attachments}
|
||||
onAttachmentsChange={setAttachments}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
AliasBlock,
|
||||
NotesBlock,
|
||||
AttachmentBlock
|
||||
} from '@/entrypoints/popup/components/CredentialDetails';
|
||||
} from '@/entrypoints/popup/components/Credentials/Details';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
@@ -2,15 +2,15 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import CredentialCard from '@/entrypoints/popup/components/CredentialCard';
|
||||
import CredentialCard from '@/entrypoints/popup/components/Credentials/CredentialCard';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import ReloadButton from '@/entrypoints/popup/components/ReloadButton';
|
||||
import { useApp } from '@/entrypoints/popup/context/AppContext';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
import { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync';
|
||||
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
|
||||
|
||||
@@ -18,18 +18,64 @@ import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
|
||||
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
|
||||
|
||||
type FilterType = 'all' | 'passkeys' | 'aliases' | 'userpass' | 'attachments';
|
||||
|
||||
const FILTER_STORAGE_KEY = 'credentials-filter';
|
||||
const FILTER_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Get stored filter from localStorage if not expired
|
||||
*/
|
||||
const getStoredFilter = (): FilterType => {
|
||||
try {
|
||||
const stored = localStorage.getItem(FILTER_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
const { filter, timestamp } = JSON.parse(stored);
|
||||
const now = Date.now();
|
||||
|
||||
// Check if expired (5 minutes)
|
||||
if (now - timestamp > FILTER_EXPIRY_MS) {
|
||||
localStorage.removeItem(FILTER_STORAGE_KEY);
|
||||
return 'all';
|
||||
}
|
||||
|
||||
return filter as FilterType;
|
||||
} catch {
|
||||
return 'all';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Store filter in localStorage with timestamp
|
||||
*/
|
||||
const storeFilter = (filter: FilterType): void => {
|
||||
try {
|
||||
localStorage.setItem(FILTER_STORAGE_KEY, JSON.stringify({
|
||||
filter,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Credentials list page.
|
||||
*/
|
||||
const CredentialsList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
const app = useApp();
|
||||
const navigate = useNavigate();
|
||||
const { syncVault } = useVaultSync();
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterType, setFilterType] = useState<FilterType>(getStoredFilter());
|
||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
|
||||
/**
|
||||
@@ -72,16 +118,13 @@ const CredentialsList: React.FC = () => {
|
||||
*/
|
||||
onError: async (error) => {
|
||||
console.error('Error syncing vault:', error);
|
||||
await webApi.logout('Error while syncing vault, please re-authenticate.');
|
||||
navigate('/logout');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error refreshing credentials:', err);
|
||||
await webApi.logout('Error while syncing vault, please re-authenticate.');
|
||||
navigate('/logout');
|
||||
await app.logout('Error while syncing vault, please re-authenticate.');
|
||||
}
|
||||
}, [dbContext, webApi, syncVault, navigate]);
|
||||
}, [dbContext, app, syncVault]);
|
||||
|
||||
/**
|
||||
* Get latest vault from server and refresh the credentials list.
|
||||
@@ -135,8 +178,67 @@ const CredentialsList: React.FC = () => {
|
||||
refreshCredentials();
|
||||
}, [dbContext?.sqliteClient, setIsLoading, setIsInitialLoading]);
|
||||
|
||||
const filteredCredentials = credentials.filter(credential => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
/**
|
||||
* Get the title based on the active filter
|
||||
*/
|
||||
const getFilterTitle = () : string => {
|
||||
switch (filterType) {
|
||||
case 'passkeys':
|
||||
return t('credentials.filters.passkeys');
|
||||
case 'aliases':
|
||||
return t('credentials.filters.aliases');
|
||||
case 'userpass':
|
||||
return t('credentials.filters.userpass');
|
||||
case 'attachments':
|
||||
return t('credentials.filters.attachments');
|
||||
default:
|
||||
return t('credentials.title');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCredentials = credentials.filter((credential: Credential) => {
|
||||
// First apply type filter
|
||||
let passesTypeFilter = true;
|
||||
|
||||
if (filterType === 'passkeys') {
|
||||
passesTypeFilter = credential.HasPasskey === true;
|
||||
} else if (filterType === 'aliases') {
|
||||
// Check for non-empty alias fields (excluding email which is used everywhere)
|
||||
passesTypeFilter = !!(
|
||||
(credential.Alias?.FirstName && credential.Alias.FirstName.trim()) ||
|
||||
(credential.Alias?.LastName && credential.Alias.LastName.trim()) ||
|
||||
(credential.Alias?.NickName && credential.Alias.NickName.trim()) ||
|
||||
(credential.Alias?.Gender && credential.Alias.Gender.trim()) ||
|
||||
(credential.Alias?.BirthDate && credential.Alias.BirthDate.trim() && credential.Alias.BirthDate.trim().startsWith('0001-01-01') !== true)
|
||||
);
|
||||
} else if (filterType === 'userpass') {
|
||||
// Show only credentials that have username/password AND do NOT have alias fields AND do NOT have passkey
|
||||
const hasAliasFields = !!(
|
||||
(credential.Alias?.FirstName && credential.Alias.FirstName.trim()) ||
|
||||
(credential.Alias?.LastName && credential.Alias.LastName.trim()) ||
|
||||
(credential.Alias?.NickName && credential.Alias.NickName.trim()) ||
|
||||
(credential.Alias?.Gender && credential.Alias.Gender.trim()) ||
|
||||
(credential.Alias?.BirthDate && credential.Alias.BirthDate.trim() && credential.Alias.BirthDate.trim().startsWith('0001-01-01') !== true)
|
||||
);
|
||||
const hasUsernameOrPassword = !!(
|
||||
(credential.Username && credential.Username.trim()) ||
|
||||
(credential.Password && credential.Password.trim())
|
||||
);
|
||||
passesTypeFilter = hasUsernameOrPassword && !credential.HasPasskey && !hasAliasFields;
|
||||
} else if (filterType === 'attachments') {
|
||||
passesTypeFilter = credential.HasAttachment === true;
|
||||
}
|
||||
|
||||
if (!passesTypeFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Then apply search filter
|
||||
const searchLower = searchTerm.toLowerCase().trim();
|
||||
|
||||
if (!searchLower) {
|
||||
return true; // No search term, include all
|
||||
}
|
||||
|
||||
/**
|
||||
* We filter credentials by searching in the following fields:
|
||||
@@ -147,13 +249,20 @@ const CredentialsList: React.FC = () => {
|
||||
* - Notes
|
||||
*/
|
||||
const searchableFields = [
|
||||
credential.ServiceName?.toLowerCase(),
|
||||
credential.Username?.toLowerCase(),
|
||||
credential.Alias?.Email?.toLowerCase(),
|
||||
credential.ServiceUrl?.toLowerCase(),
|
||||
credential.Notes?.toLowerCase(),
|
||||
credential.ServiceName?.toLowerCase() || '',
|
||||
credential.Username?.toLowerCase() || '',
|
||||
credential.Alias?.Email?.toLowerCase() || '',
|
||||
credential.ServiceUrl?.toLowerCase() || '',
|
||||
credential.Notes?.toLowerCase() || '',
|
||||
];
|
||||
return searchableFields.some(field => field?.includes(searchLower));
|
||||
|
||||
// Split search term into words for AND search
|
||||
const searchWords = searchLower.split(/\s+/).filter(word => word.length > 0);
|
||||
|
||||
// All search words must be found (each in at least one field)
|
||||
return searchWords.every(word =>
|
||||
searchableFields.some(field => field.includes(word))
|
||||
);
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
@@ -167,7 +276,106 @@ const CredentialsList: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-gray-900 dark:text-white text-xl">{t('credentials.title')}</h2>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowFilterMenu(!showFilterMenu)}
|
||||
className="flex items-center gap-1 text-gray-900 dark:text-white text-xl hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none"
|
||||
>
|
||||
<h2 className="flex items-baseline gap-1.5">
|
||||
{getFilterTitle()}
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">({filteredCredentials.length})</span>
|
||||
</h2>
|
||||
<svg
|
||||
className="w-4 h-4 mt-1"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showFilterMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setShowFilterMenu(false)}
|
||||
/>
|
||||
<div className="absolute left-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20">
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
const newFilter = 'all';
|
||||
setFilterType(newFilter);
|
||||
storeFilter(newFilter);
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
filterType === 'all' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('credentials.filters.all')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newFilter = 'passkeys';
|
||||
setFilterType(newFilter);
|
||||
storeFilter(newFilter);
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
filterType === 'passkeys' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('credentials.filters.passkeys')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newFilter = 'aliases';
|
||||
setFilterType(newFilter);
|
||||
storeFilter(newFilter);
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
filterType === 'aliases' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('credentials.filters.aliases')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newFilter = 'userpass';
|
||||
setFilterType(newFilter);
|
||||
storeFilter(newFilter);
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
filterType === 'userpass' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('credentials.filters.userpass')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newFilter = 'attachments';
|
||||
setFilterType(newFilter);
|
||||
storeFilter(newFilter);
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
filterType === 'attachments' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('credentials.filters.attachments')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ReloadButton onClick={syncVaultAndRefresh} />
|
||||
</div>
|
||||
|
||||
@@ -195,6 +403,17 @@ const CredentialsList: React.FC = () => {
|
||||
{t('credentials.welcomeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredCredentials.length === 0 ? (
|
||||
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
|
||||
<p>
|
||||
{filterType === 'passkeys'
|
||||
? t('credentials.noPasskeysFound')
|
||||
: filterType === 'attachments'
|
||||
? t('credentials.noAttachmentsFound')
|
||||
: t('credentials.noMatchingCredentials')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{filteredCredentials.map(cred => (
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
import Modal from '@/entrypoints/popup/components/Dialogs/Modal';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import Modal from '@/entrypoints/popup/components/Modal';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
@@ -158,7 +158,7 @@ const EmailDetails: React.FC = (): React.ReactElement => {
|
||||
)}
|
||||
<HeaderButton
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
title={t('emails.deleteEmail')}
|
||||
title={t('emails.deleteEmailTitle')}
|
||||
iconType={HeaderIconType.DELETE}
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { extractDomain, extractRootDomain } from '@/entrypoints/contentScript/CredentialMatcher';
|
||||
import Button from '@/entrypoints/popup/components/Button';
|
||||
import PasskeyBypassDialog from '@/entrypoints/popup/components/Dialogs/PasskeyBypassDialog';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { useVaultLockRedirect } from '@/entrypoints/popup/hooks/useVaultLockRedirect';
|
||||
|
||||
import { PASSKEY_DISABLED_SITES_KEY } from '@/utils/Constants';
|
||||
import { PasskeyAuthenticator } from '@/utils/passkey/PasskeyAuthenticator';
|
||||
import { PasskeyHelper } from '@/utils/passkey/PasskeyHelper';
|
||||
import type { GetRequest, PasskeyGetCredentialResponse, PendingPasskeyGetRequest, StoredPasskeyRecord } from '@/utils/passkey/types';
|
||||
|
||||
import { storage } from "#imports";
|
||||
|
||||
/**
|
||||
* PasskeyAuthenticate
|
||||
*/
|
||||
const PasskeyAuthenticate: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const dbContext = useDb();
|
||||
const [request, setRequest] = useState<PendingPasskeyGetRequest | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [availablePasskeys, setAvailablePasskeys] = useState<Array<{ id: string; displayName: string; rpId: string; serviceName?: string | null }>>([]);
|
||||
const [showBypassDialog, setShowBypassDialog] = useState(false);
|
||||
const { isLocked } = useVaultLockRedirect();
|
||||
const firstPasskeyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* fetchRequestData
|
||||
*/
|
||||
const fetchRequestData = async () : Promise<void> => {
|
||||
// Wait for DB to be initialized
|
||||
if (!dbContext.dbInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If vault is locked, the hook will handle redirect, we just return
|
||||
if (isLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the requestId from URL
|
||||
const params = new URLSearchParams(location.search);
|
||||
const requestId = params.get('requestId');
|
||||
|
||||
if (requestId) {
|
||||
try {
|
||||
// Fetch the full request data from background
|
||||
const data = await sendMessage('GET_REQUEST_DATA', { requestId }, 'background') as unknown as PendingPasskeyGetRequest;
|
||||
|
||||
if (data && data.type === 'get') {
|
||||
setRequest(data);
|
||||
|
||||
// Get passkeys for this rpId from the vault
|
||||
const rpId = data.publicKey.rpId || new URL(data.origin).hostname;
|
||||
const passkeys = dbContext.sqliteClient!.getPasskeysByRpId(rpId);
|
||||
|
||||
// Filter by allowCredentials if specified
|
||||
let filteredPasskeys = passkeys;
|
||||
if (data.publicKey.allowCredentials && data.publicKey.allowCredentials.length > 0) {
|
||||
// Convert the RP's base64url credential IDs to GUIDs for comparison
|
||||
const allowedGuids = new Set(
|
||||
data.publicKey.allowCredentials.map(c => {
|
||||
try {
|
||||
return PasskeyHelper.base64urlToGuid(c.id);
|
||||
} catch (e) {
|
||||
console.warn('Failed to convert credential ID to GUID:', c.id, e);
|
||||
return null;
|
||||
}
|
||||
}).filter((id): id is string => id !== null)
|
||||
);
|
||||
filteredPasskeys = passkeys.filter(pk => allowedGuids.has(pk.Id));
|
||||
}
|
||||
|
||||
// Map to display format
|
||||
setAvailablePasskeys(filteredPasskeys.map(pk => ({
|
||||
id: pk.Id,
|
||||
displayName: pk.DisplayName,
|
||||
serviceName: pk.ServiceName,
|
||||
rpId: pk.RpId,
|
||||
username: pk.Username
|
||||
})));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch request data:', error);
|
||||
setError(t('common.errors.unknownError'));
|
||||
}
|
||||
}
|
||||
|
||||
// Mark initial loading as complete
|
||||
setIsInitialLoading(false);
|
||||
};
|
||||
|
||||
fetchRequestData();
|
||||
}, [location, setIsInitialLoading, dbContext.dbInitialized, isLocked, dbContext.sqliteClient, t]);
|
||||
|
||||
// Auto-focus first passkey
|
||||
useEffect(() => {
|
||||
if (availablePasskeys.length > 0 && firstPasskeyRef.current) {
|
||||
firstPasskeyRef.current.focus();
|
||||
}
|
||||
}, [availablePasskeys.length]);
|
||||
|
||||
// Handle Enter key to select first passkey
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Handle Enter key to select first passkey
|
||||
*/
|
||||
const handleKeyDown = (e: KeyboardEvent) : void => {
|
||||
if (e.key === 'Enter' && !loading && availablePasskeys.length > 0) {
|
||||
handleUsePasskey(availablePasskeys[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle Enter key to select first passkey
|
||||
*/
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () : void => window.removeEventListener('keydown', handleKeyDown);
|
||||
|
||||
/**
|
||||
* Handle Enter key to select first passkey
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, availablePasskeys]);
|
||||
|
||||
/**
|
||||
* Handle passkey authentication
|
||||
*/
|
||||
const handleUsePasskey = async (passkeyId: string) : Promise<void> => {
|
||||
if (!request || !dbContext.sqliteClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get the stored passkey from vault
|
||||
const storedPasskey = dbContext.sqliteClient.getPasskeyById(passkeyId);
|
||||
if (!storedPasskey) {
|
||||
throw new Error(t('common.errors.unknownError'));
|
||||
}
|
||||
|
||||
// Parse the stored keys
|
||||
const publicKey = JSON.parse(storedPasskey.PublicKey) as JsonWebKey;
|
||||
const privateKey = JSON.parse(storedPasskey.PrivateKey) as JsonWebKey;
|
||||
|
||||
// Extract PRF secret from PrfKey if available
|
||||
let prfSecret: string | undefined;
|
||||
|
||||
if (storedPasskey.PrfKey) {
|
||||
try {
|
||||
// Convert PrfKey bytes to base64url string
|
||||
prfSecret = PasskeyHelper.bytesToBase64url(storedPasskey.PrfKey);
|
||||
} catch (e) {
|
||||
console.warn('Failed to convert PrfKey to base64url', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the stored record for the provider
|
||||
* Convert UserHandle from byte array to base64 string for serialization
|
||||
*/
|
||||
let userIdBase64: string | null = null;
|
||||
if (storedPasskey.UserHandle) {
|
||||
try {
|
||||
const userHandleBytes = storedPasskey.UserHandle instanceof Uint8Array ? storedPasskey.UserHandle : new Uint8Array(storedPasskey.UserHandle);
|
||||
userIdBase64 = PasskeyHelper.bytesToBase64url(userHandleBytes);
|
||||
} catch (e) {
|
||||
console.warn('Failed to convert UserHandle to base64', e);
|
||||
}
|
||||
}
|
||||
|
||||
const storedRecord: StoredPasskeyRecord = {
|
||||
rpId: storedPasskey.RpId,
|
||||
credentialId: PasskeyHelper.guidToBase64url(storedPasskey.Id),
|
||||
publicKey,
|
||||
privateKey,
|
||||
userId: userIdBase64,
|
||||
userName: storedPasskey.Username ?? undefined,
|
||||
userDisplayName: storedPasskey.ServiceName ?? undefined,
|
||||
prfSecret
|
||||
};
|
||||
|
||||
// Build the GetRequest
|
||||
const getRequest: GetRequest = {
|
||||
origin: request.origin,
|
||||
requestId: request.requestId,
|
||||
publicKey: {
|
||||
rpId: request.publicKey.rpId,
|
||||
challenge: request.publicKey.challenge,
|
||||
userVerification: request.publicKey.userVerification
|
||||
}
|
||||
};
|
||||
|
||||
// Extract PRF inputs if requested
|
||||
let prfInputs: { first: ArrayBuffer | Uint8Array; second?: ArrayBuffer | Uint8Array } | undefined;
|
||||
if (request.publicKey.extensions?.prf?.eval) {
|
||||
// Handle numeric object format (serialized Uint8Array through events)
|
||||
const firstInput = request.publicKey.extensions.prf.eval.first;
|
||||
let firstBytes: Uint8Array;
|
||||
|
||||
if (typeof firstInput === 'object' && firstInput !== null && !Array.isArray(firstInput)) {
|
||||
// Numeric object format: {0: 68, 1: 204, ...}
|
||||
const keys = Object.keys(firstInput).map(Number).sort((a, b) => a - b);
|
||||
firstBytes = new Uint8Array(keys.length);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
firstBytes[i] = (firstInput as unknown as Record<string, number>)[i];
|
||||
}
|
||||
} else if (typeof firstInput === 'string') {
|
||||
// Base64 string format
|
||||
const firstDecoded = atob(firstInput);
|
||||
firstBytes = new Uint8Array(firstDecoded.length);
|
||||
for (let i = 0; i < firstDecoded.length; i++) {
|
||||
firstBytes[i] = firstDecoded.charCodeAt(i);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unknown PRF input format');
|
||||
}
|
||||
|
||||
prfInputs = { first: firstBytes };
|
||||
|
||||
if (request.publicKey.extensions.prf.eval.second) {
|
||||
const secondInput = request.publicKey.extensions.prf.eval.second;
|
||||
let secondBytes: Uint8Array;
|
||||
|
||||
if (typeof secondInput === 'object' && secondInput !== null && !Array.isArray(secondInput)) {
|
||||
const keys = Object.keys(secondInput).map(Number).sort((a, b) => a - b);
|
||||
secondBytes = new Uint8Array(keys.length);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
secondBytes[i] = (secondInput as unknown as Record<string, number>)[i];
|
||||
}
|
||||
} else if (typeof secondInput === 'string') {
|
||||
const secondDecoded = atob(secondInput);
|
||||
secondBytes = new Uint8Array(secondDecoded.length);
|
||||
for (let i = 0; i < secondDecoded.length; i++) {
|
||||
secondBytes[i] = secondDecoded.charCodeAt(i);
|
||||
}
|
||||
} else {
|
||||
console.error('[PasskeyAuth] Unknown PRF second input type:', typeof secondInput);
|
||||
throw new Error('Unknown PRF second input format');
|
||||
}
|
||||
|
||||
prfInputs.second = secondBytes;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the assertion using the static method
|
||||
const assertion = await PasskeyAuthenticator.getAssertion(getRequest, storedRecord, {
|
||||
uvPerformed: true, // TODO: implement explicit user verification check
|
||||
includeBEBS: true, // Backup eligible/state - defaults to true
|
||||
prfInputs
|
||||
});
|
||||
|
||||
// Convert PRF results to base64 for transport
|
||||
let prfResults: { first: string; second?: string } | undefined;
|
||||
if (assertion.prfResults) {
|
||||
prfResults = {
|
||||
first: PasskeyHelper.arrayBufferToBase64(assertion.prfResults.first)
|
||||
};
|
||||
if (assertion.prfResults.second) {
|
||||
prfResults.second = PasskeyHelper.arrayBufferToBase64(assertion.prfResults.second);
|
||||
}
|
||||
}
|
||||
|
||||
const credential: PasskeyGetCredentialResponse = {
|
||||
id: assertion.id,
|
||||
rawId: assertion.rawId,
|
||||
clientDataJSON: assertion.clientDataJSON,
|
||||
authenticatorData: assertion.authenticatorData,
|
||||
signature: assertion.signature,
|
||||
userHandle: assertion.userHandle,
|
||||
prfResults
|
||||
};
|
||||
|
||||
/*
|
||||
* Send response back
|
||||
* The background script will close the window (Safari-compatible)
|
||||
*/
|
||||
await sendMessage('PASSKEY_POPUP_RESPONSE', {
|
||||
requestId: request.requestId,
|
||||
credential
|
||||
}, 'background');
|
||||
} catch (error) {
|
||||
console.error('PasskeyAuthenticate: Error during authentication', error);
|
||||
setLoading(false);
|
||||
setError(t('common.errors.unknownError'));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle fallback - show bypass dialog first
|
||||
*/
|
||||
const handleFallback = async () : Promise<void> => {
|
||||
setShowBypassDialog(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle bypass choice
|
||||
*/
|
||||
const handleBypassChoice = async (choice: 'once' | 'always') : Promise<void> => {
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (choice === 'always') {
|
||||
// Add to permanent disabled list
|
||||
const hostname = new URL(request.origin).hostname;
|
||||
const baseDomain = extractRootDomain(extractDomain(hostname));
|
||||
|
||||
const disabledSites = await storage.getItem(PASSKEY_DISABLED_SITES_KEY) as string[] ?? [];
|
||||
if (!disabledSites.includes(baseDomain)) {
|
||||
disabledSites.push(baseDomain);
|
||||
await storage.setItem(PASSKEY_DISABLED_SITES_KEY, disabledSites);
|
||||
}
|
||||
}
|
||||
// For 'once', we don't store anything - just bypass this one time
|
||||
|
||||
/*
|
||||
* Tell background to use native implementation
|
||||
* The background script will close the window (Safari-compatible)
|
||||
*/
|
||||
await sendMessage('PASSKEY_POPUP_RESPONSE', {
|
||||
requestId: request.requestId,
|
||||
fallback: true
|
||||
}, 'background');
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle cancel
|
||||
*/
|
||||
const handleCancel = async () : Promise<void> => {
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Tell background user cancelled
|
||||
* The background script will close the window (Safari-compatible)
|
||||
*/
|
||||
await sendMessage('PASSKEY_POPUP_RESPONSE', {
|
||||
requestId: request.requestId,
|
||||
cancelled: true
|
||||
}, 'background');
|
||||
};
|
||||
|
||||
if (!request) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showBypassDialog && request && (
|
||||
<PasskeyBypassDialog
|
||||
origin={new URL(request.origin).hostname}
|
||||
onChoice={handleBypassChoice}
|
||||
onCancel={() => setShowBypassDialog(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('passkeys.authenticate.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('passkeys.authenticate.signInFor')} <strong>{request.origin}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{availablePasskeys && availablePasskeys.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('passkeys.authenticate.selectPasskey')}
|
||||
</label>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto border rounded-lg p-2 bg-gray-50 dark:bg-gray-800">
|
||||
{availablePasskeys.map((pk, index) => (
|
||||
<div
|
||||
key={pk.id}
|
||||
ref={index === 0 ? firstPasskeyRef : null}
|
||||
tabIndex={0}
|
||||
className="p-3 rounded-lg border cursor-pointer transition-colors bg-white border-gray-200 hover:bg-gray-100 hover:border-gray-300 dark:bg-gray-700 dark:border-gray-600 dark:hover:bg-gray-600 dark:hover:border-gray-500 focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
onClick={() => !loading && handleUsePasskey(pk.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !loading) {
|
||||
handleUsePasskey(pk.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-white text-sm truncate">
|
||||
{pk.serviceName}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="truncate">{pk.displayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('passkeys.authenticate.noPasskeysFound')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleFallback}
|
||||
>
|
||||
{t('passkeys.authenticate.useBrowserPasskey')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasskeyAuthenticate;
|
||||
@@ -0,0 +1,653 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { extractDomain, extractRootDomain } from '@/entrypoints/contentScript/CredentialMatcher';
|
||||
import Alert from '@/entrypoints/popup/components/Alert';
|
||||
import Button from '@/entrypoints/popup/components/Button';
|
||||
import PasskeyBypassDialog from '@/entrypoints/popup/components/Dialogs/PasskeyBypassDialog';
|
||||
import { FormInput } from '@/entrypoints/popup/components/Forms/FormInput';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
import { useVaultLockRedirect } from '@/entrypoints/popup/hooks/useVaultLockRedirect';
|
||||
import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
|
||||
|
||||
import { PASSKEY_DISABLED_SITES_KEY } from '@/utils/Constants';
|
||||
import type { Passkey } from '@/utils/dist/shared/models/vault';
|
||||
import { PasskeyAuthenticator } from '@/utils/passkey/PasskeyAuthenticator';
|
||||
import { PasskeyHelper } from '@/utils/passkey/PasskeyHelper';
|
||||
import type { CreateRequest, PasskeyCreateCredentialResponse, PendingPasskeyCreateRequest } from '@/utils/passkey/types';
|
||||
|
||||
import { storage } from "#imports";
|
||||
|
||||
/**
|
||||
* PasskeyCreate
|
||||
*/
|
||||
const PasskeyCreate: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
const { executeVaultMutation, isLoading: isMutating, syncStatus } = useVaultMutate();
|
||||
const [request, setRequest] = useState<PendingPasskeyCreateRequest | null>(null);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isLocked } = useVaultLockRedirect();
|
||||
const [existingPasskeys, setExistingPasskeys] = useState<Array<Passkey & { Username?: string | null; ServiceName?: string | null }>>([]);
|
||||
const [selectedPasskeyToReplace, setSelectedPasskeyToReplace] = useState<string | null>(null);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [localLoading, setLocalLoading] = useState(false);
|
||||
const [showBypassDialog, setShowBypassDialog] = useState(false);
|
||||
const createNewButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const displayNameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* fetchRequestData
|
||||
*/
|
||||
const fetchRequestData = async () : Promise<void> => {
|
||||
// Wait for DB to be initialized
|
||||
if (!dbContext.dbInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If vault is locked, the hook will handle redirect, we just return
|
||||
if (isLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the requestId from URL
|
||||
const params = new URLSearchParams(location.search);
|
||||
const requestId = params.get('requestId');
|
||||
|
||||
if (requestId) {
|
||||
try {
|
||||
// Fetch the full request data from background
|
||||
const data = await sendMessage('GET_REQUEST_DATA', { requestId }, 'background') as unknown as PendingPasskeyCreateRequest;
|
||||
if (data && data.type === 'create') {
|
||||
setRequest(data);
|
||||
|
||||
/**
|
||||
* Set default displayName: use rp.name if available, otherwise use rpId
|
||||
* This aligns with iOS/Android behavior
|
||||
*/
|
||||
const defaultName = data.publicKey?.rp?.name || data.publicKey?.rp?.id || 'Passkey';
|
||||
setDisplayName(defaultName);
|
||||
|
||||
// Check for existing passkeys for this RP ID and user
|
||||
if (dbContext.sqliteClient && data.publicKey?.rp?.id) {
|
||||
const allPasskeysForRpId = dbContext.sqliteClient.getPasskeysByRpId(data.publicKey.rp.id);
|
||||
|
||||
/**
|
||||
* Filter by user ID and/or username if provided
|
||||
* This allows for multiple users on the same site
|
||||
*/
|
||||
let filtered = allPasskeysForRpId;
|
||||
|
||||
if (data.publicKey.user?.id || data.publicKey.user?.name) {
|
||||
filtered = allPasskeysForRpId.filter(passkey => {
|
||||
/**
|
||||
* Match by user handle if both are available
|
||||
* The request has base64url encoded user.id, passkey has UserHandle as byte array
|
||||
* Convert request's user.id to bytes for comparison
|
||||
*/
|
||||
if (data.publicKey.user?.id && passkey.UserHandle) {
|
||||
try {
|
||||
const requestUserIdBytes = PasskeyHelper.base64urlToBytes(data.publicKey.user.id);
|
||||
const passkeyUserHandle = passkey.UserHandle instanceof Uint8Array ? passkey.UserHandle : new Uint8Array(passkey.UserHandle);
|
||||
|
||||
// Compare byte arrays
|
||||
if (requestUserIdBytes.length === passkeyUserHandle.length &&
|
||||
requestUserIdBytes.every((byte, idx) => byte === passkeyUserHandle[idx])) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// If conversion fails, skip this passkey
|
||||
}
|
||||
}
|
||||
|
||||
// Also match by username if available (from the credential)
|
||||
if (data.publicKey.user?.name && passkey.Username) {
|
||||
if (passkey.Username === data.publicKey.user.name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If neither user ID nor username match, exclude this passkey
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
setExistingPasskeys(filtered);
|
||||
// If no existing passkeys for this user, go straight to create form
|
||||
if (filtered.length === 0) {
|
||||
setShowCreateForm(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch request data:', error);
|
||||
setError(t('common.errors.unknownError'));
|
||||
}
|
||||
}
|
||||
|
||||
setIsInitialLoading(false);
|
||||
};
|
||||
|
||||
fetchRequestData();
|
||||
}, [location, setIsInitialLoading, dbContext.dbInitialized, dbContext.sqliteClient, isLocked, t]);
|
||||
|
||||
// Auto-focus create new button or input field
|
||||
useEffect(() => {
|
||||
if (showCreateForm && displayNameInputRef.current) {
|
||||
displayNameInputRef.current.focus();
|
||||
} else if (!showCreateForm && existingPasskeys.length > 0 && createNewButtonRef.current) {
|
||||
createNewButtonRef.current.focus();
|
||||
}
|
||||
}, [showCreateForm, existingPasskeys.length]);
|
||||
|
||||
// Handle Enter key to submit
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Handle Enter key to submit
|
||||
*/
|
||||
const handleKeyDown = (e: KeyboardEvent) : void => {
|
||||
if (e.key === 'Enter' && !localLoading && !isMutating) {
|
||||
if (showCreateForm) {
|
||||
handleCreate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () : void => window.removeEventListener('keydown', handleKeyDown);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showCreateForm, localLoading, isMutating]);
|
||||
|
||||
/**
|
||||
* Handle when user clicks "Create New Passkey" button
|
||||
*/
|
||||
const handleCreateNew = () : void => {
|
||||
setSelectedPasskeyToReplace(null);
|
||||
setShowCreateForm(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle when user selects an existing passkey to replace
|
||||
*/
|
||||
const handleSelectReplace = (passkeyId: string) : void => {
|
||||
setSelectedPasskeyToReplace(passkeyId);
|
||||
setShowCreateForm(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle passkey creation
|
||||
*/
|
||||
const handleCreate = async () : Promise<void> => {
|
||||
if (!request || !dbContext.sqliteClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Extract favicon from origin URL
|
||||
let faviconLogo: Uint8Array | undefined = undefined;
|
||||
if (request.origin) {
|
||||
setLocalLoading(true);
|
||||
try {
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Favicon extraction timed out')), 5000)
|
||||
);
|
||||
|
||||
const faviconPromise = webApi.get<{ image: string }>('Favicon/Extract?url=' + request.origin);
|
||||
const faviconResponse = await Promise.race([faviconPromise, timeoutPromise]) as { image: string };
|
||||
|
||||
if (faviconResponse?.image) {
|
||||
// Use browser-compatible base64 decoding
|
||||
const binaryString = atob(faviconResponse.image);
|
||||
const decodedImage = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
decodedImage[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
faviconLogo = decodedImage;
|
||||
}
|
||||
} catch {
|
||||
// Favicon extraction failed or timed out, this is not a critical error so we can ignore it.
|
||||
}
|
||||
}
|
||||
|
||||
// Build the CreateRequest
|
||||
const createRequest: CreateRequest = {
|
||||
origin: request.origin,
|
||||
requestId: request.requestId,
|
||||
publicKey: {
|
||||
rp: request.publicKey.rp,
|
||||
user: request.publicKey.user,
|
||||
challenge: request.publicKey.challenge,
|
||||
pubKeyCredParams: request.publicKey.pubKeyCredParams,
|
||||
attestation: request.publicKey.attestation,
|
||||
authenticatorSelection: request.publicKey.authenticatorSelection
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a new GUID for the passkey which will be embedded in the passkey
|
||||
* metadata and send back to the RP as the credential.id and credential.rawId.
|
||||
*/
|
||||
const newPasskeyGuid = crypto.randomUUID().toUpperCase();
|
||||
const newPasskeyGuidBytes = PasskeyHelper.guidToBytes(newPasskeyGuid);
|
||||
const newPasskeyGuidBase64url = PasskeyHelper.guidToBase64url(newPasskeyGuid);
|
||||
|
||||
// Check if PRF evaluation is requested during registration
|
||||
const prfExtension = request.publicKey?.extensions?.prf;
|
||||
const enablePrf = !!prfExtension;
|
||||
const prfEvalInputs = prfExtension?.eval;
|
||||
|
||||
// Create passkey using static method (generates keys and credential ID)
|
||||
const result = await PasskeyAuthenticator.createPasskey(newPasskeyGuidBytes, createRequest, {
|
||||
uvPerformed: true,
|
||||
credentialIdBytes: 16,
|
||||
enablePrf,
|
||||
prfInputs: prfEvalInputs // Pass PRF evaluation salts if provided
|
||||
});
|
||||
|
||||
const { credential, stored, prfEnabled, prfResults } = result;
|
||||
|
||||
// Use vault mutation to store both credential and passkey
|
||||
await executeVaultMutation(
|
||||
async () => {
|
||||
if (selectedPasskeyToReplace) {
|
||||
// Replace existing passkey: update the credential and passkey
|
||||
const existingPasskey = dbContext.sqliteClient!.getPasskeyById(selectedPasskeyToReplace);
|
||||
if (existingPasskey) {
|
||||
// Update the parent credential with new favicon and user-provided display name
|
||||
await dbContext.sqliteClient!.updateCredentialById(
|
||||
{
|
||||
Id: existingPasskey.CredentialId,
|
||||
ServiceName: displayName,
|
||||
ServiceUrl: request.origin,
|
||||
Username: request.publicKey.user.name,
|
||||
Password: '',
|
||||
Notes: '',
|
||||
Logo: faviconLogo ?? undefined,
|
||||
Alias: {
|
||||
FirstName: '',
|
||||
LastName: '',
|
||||
NickName: '',
|
||||
BirthDate: '0001-01-01 00:00:00',
|
||||
Gender: '',
|
||||
Email: ''
|
||||
},
|
||||
},
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
// Delete the old passkey
|
||||
await dbContext.sqliteClient!.deletePasskeyById(selectedPasskeyToReplace);
|
||||
|
||||
/**
|
||||
* Create new passkey with same credential
|
||||
* Convert userId from base64 string to byte array for database storage
|
||||
*/
|
||||
let userHandleBytes: Uint8Array | null = null;
|
||||
if (stored.userId) {
|
||||
try {
|
||||
userHandleBytes = PasskeyHelper.base64urlToBytes(stored.userId);
|
||||
} catch {
|
||||
// If conversion fails, store as null
|
||||
userHandleBytes = null;
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.sqliteClient!.createPasskey({
|
||||
Id: newPasskeyGuid,
|
||||
CredentialId: existingPasskey.CredentialId,
|
||||
RpId: stored.rpId,
|
||||
UserHandle: userHandleBytes,
|
||||
PublicKey: JSON.stringify(stored.publicKey),
|
||||
PrivateKey: JSON.stringify(stored.privateKey),
|
||||
DisplayName: request.publicKey.user.displayName || request.publicKey.user.name || '',
|
||||
PrfKey: stored.prfSecret ? PasskeyHelper.base64urlToBytes(stored.prfSecret) : undefined,
|
||||
AdditionalData: null
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Create new credential and passkey
|
||||
const credentialId = await dbContext.sqliteClient!.createCredential(
|
||||
{
|
||||
Id: '',
|
||||
ServiceName: displayName,
|
||||
ServiceUrl: request.origin,
|
||||
Username: request.publicKey.user.name,
|
||||
Password: '',
|
||||
Notes: '',
|
||||
Logo: faviconLogo ?? undefined,
|
||||
Alias: {
|
||||
FirstName: '',
|
||||
LastName: '',
|
||||
NickName: '',
|
||||
BirthDate: '0001-01-01 00:00:00', // TODO: once birthdate is made nullable in datamodel refactor, remove this.
|
||||
Gender: '',
|
||||
Email: ''
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Create the Passkey linked to the credential
|
||||
* Note: We let the database generate a GUID for Id, which we'll convert to base64url for the RP
|
||||
* Convert userId from base64 string to byte array for database storage
|
||||
*/
|
||||
let userHandleBytes: Uint8Array | null = null;
|
||||
if (stored.userId) {
|
||||
try {
|
||||
userHandleBytes = PasskeyHelper.base64urlToBytes(stored.userId);
|
||||
} catch {
|
||||
// If conversion fails, store as null
|
||||
userHandleBytes = null;
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.sqliteClient!.createPasskey({
|
||||
Id: newPasskeyGuid,
|
||||
CredentialId: credentialId,
|
||||
RpId: stored.rpId,
|
||||
UserHandle: userHandleBytes,
|
||||
PublicKey: JSON.stringify(stored.publicKey),
|
||||
PrivateKey: JSON.stringify(stored.privateKey),
|
||||
DisplayName: request.publicKey.user.displayName || request.publicKey.user.name || '',
|
||||
PrfKey: stored.prfSecret ? PasskeyHelper.base64urlToBytes(stored.prfSecret) : undefined,
|
||||
AdditionalData: null
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Wait for vault mutation to have synced with server, then send passkey create success response
|
||||
* with the GUID-based credential ID.
|
||||
*/
|
||||
onSuccess: async () => {
|
||||
// Prepare PRF extension response if PRF was enabled
|
||||
let prfExtensionResponse;
|
||||
if (prfEnabled) {
|
||||
prfExtensionResponse = {
|
||||
prf: {
|
||||
enabled: true,
|
||||
results: prfResults ? {
|
||||
first: PasskeyHelper.bytesToBase64url(new Uint8Array(prfResults.first)),
|
||||
second: prfResults.second ? PasskeyHelper.bytesToBase64url(new Uint8Array(prfResults.second)) : undefined
|
||||
} : undefined
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Use the GUID-based credential ID instead of the random one from the provider
|
||||
const flattenedCredential: PasskeyCreateCredentialResponse = {
|
||||
id: newPasskeyGuidBase64url,
|
||||
rawId: newPasskeyGuidBase64url,
|
||||
clientDataJSON: credential.response.clientDataJSON,
|
||||
attestationObject: credential.response.attestationObject,
|
||||
extensions: prfExtensionResponse
|
||||
};
|
||||
|
||||
/*
|
||||
* Send response back to background
|
||||
* The background script will close the window (Safari-compatible)
|
||||
*/
|
||||
await sendMessage('PASSKEY_POPUP_RESPONSE', {
|
||||
requestId: request.requestId,
|
||||
credential: flattenedCredential
|
||||
}, 'background');
|
||||
},
|
||||
/**
|
||||
* onError
|
||||
*/
|
||||
onError: (err) => {
|
||||
console.error('PasskeyCreate: Error storing passkey', err);
|
||||
setError(t('common.errors.unknownError'));
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('PasskeyCreate: Error creating passkey', error);
|
||||
setError(t('common.errors.unknownError'));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle fallback - show bypass dialog first
|
||||
*/
|
||||
const handleFallback = async () : Promise<void> => {
|
||||
setShowBypassDialog(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle bypass choice
|
||||
*/
|
||||
const handleBypassChoice = async (choice: 'once' | 'always') : Promise<void> => {
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (choice === 'always') {
|
||||
// Add to permanent disabled list
|
||||
const hostname = new URL(request.origin).hostname;
|
||||
const baseDomain = extractRootDomain(extractDomain(hostname));
|
||||
|
||||
const disabledSites = await storage.getItem(PASSKEY_DISABLED_SITES_KEY) as string[] ?? [];
|
||||
if (!disabledSites.includes(baseDomain)) {
|
||||
disabledSites.push(baseDomain);
|
||||
await storage.setItem(PASSKEY_DISABLED_SITES_KEY, disabledSites);
|
||||
}
|
||||
}
|
||||
// For 'once', we don't store anything - just bypass this one time
|
||||
|
||||
/*
|
||||
* Tell background to use native implementation
|
||||
* The background script will close the window (Safari-compatible)
|
||||
*/
|
||||
await sendMessage('PASSKEY_POPUP_RESPONSE', {
|
||||
requestId: request.requestId,
|
||||
fallback: true
|
||||
}, 'background');
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle cancel
|
||||
*/
|
||||
const handleCancel = async () : Promise<void> => {
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Tell background user cancelled
|
||||
* The background script will close the window (Safari-compatible)
|
||||
*/
|
||||
await sendMessage('PASSKEY_POPUP_RESPONSE', {
|
||||
requestId: request.requestId,
|
||||
cancelled: true
|
||||
}, 'background');
|
||||
};
|
||||
|
||||
if (!request) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showBypassDialog && request && (
|
||||
<PasskeyBypassDialog
|
||||
origin={new URL(request.origin).hostname}
|
||||
onChoice={handleBypassChoice}
|
||||
onCancel={() => setShowBypassDialog(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(localLoading || isMutating) && (
|
||||
<div className="fixed inset-0 flex flex-col justify-center items-center bg-white dark:bg-gray-900 bg-opacity-90 dark:bg-opacity-90 z-50">
|
||||
<LoadingSpinner />
|
||||
<div className="text-sm text-gray-500 mt-2">
|
||||
{syncStatus}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('passkeys.create.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('passkeys.create.createFor')} <strong>{request.origin}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="error">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Step 1: Show existing passkeys selection or create new option */}
|
||||
{!showCreateForm && existingPasskeys.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreateNew}
|
||||
ref={createNewButtonRef}
|
||||
>
|
||||
{t('passkeys.create.createNewPasskey')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleFallback}
|
||||
>
|
||||
{t('passkeys.create.useBrowserPasskey')}
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300 dark:border-gray-600" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">
|
||||
{t('common.or')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('passkeys.create.selectPasskeyToReplace')}
|
||||
</label>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto border rounded-lg p-2 bg-gray-50 dark:bg-gray-800">
|
||||
{existingPasskeys.map((passkey) => (
|
||||
<button
|
||||
key={passkey.Id}
|
||||
onClick={() => handleSelectReplace(passkey.Id)}
|
||||
className="w-full p-3 text-left rounded-lg border cursor-pointer transition-colors bg-white border-gray-200 hover:bg-gray-100 hover:border-gray-300 dark:bg-gray-700 dark:border-gray-600 dark:hover:bg-gray-600 dark:hover:border-gray-500 focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900 dark:text-white text-sm truncate">
|
||||
{passkey.ServiceName}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="truncate">{passkey.DisplayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Show create form with display name */}
|
||||
{showCreateForm && (
|
||||
<div className="space-y-4">
|
||||
{selectedPasskeyToReplace && (
|
||||
<Alert variant="warning">
|
||||
{t('passkeys.create.replacingPasskey', {
|
||||
displayName: existingPasskeys.find(p => p.Id === selectedPasskeyToReplace)?.DisplayName || ''
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormInput
|
||||
id="displayName"
|
||||
label={t('passkeys.create.titleLabel')}
|
||||
value={displayName}
|
||||
onChange={setDisplayName}
|
||||
placeholder={t('passkeys.create.titlePlaceholder')}
|
||||
ref={displayNameInputRef}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{selectedPasskeyToReplace ? t('passkeys.create.confirmReplace') : t('passkeys.create.createButton')}
|
||||
</Button>
|
||||
|
||||
{existingPasskeys.length > 0 ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setSelectedPasskeyToReplace(null);
|
||||
}}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleFallback}
|
||||
>
|
||||
{t('passkeys.create.useBrowserPasskey')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasskeyCreate;
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import HelpModal from '@/entrypoints/popup/components/HelpModal';
|
||||
import HelpModal from '@/entrypoints/popup/components/Dialogs/HelpModal';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
|
||||
import { AUTO_LOCK_TIMEOUT_KEY } from '@/utils/Constants';
|
||||
@@ -50,8 +50,8 @@ const AutoLockSettings: React.FC = () => {
|
||||
<div className="flex items-center mb-2">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{t('settings.autoLockTimeout')}</p>
|
||||
<HelpModal
|
||||
titleKey="settings.autoLockTimeout"
|
||||
contentKey="settings.autoLockTimeoutHelp"
|
||||
title={t('settings.autoLockTimeout')}
|
||||
content={t('settings.autoLockTimeoutHelp')}
|
||||
className="ml-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AutofillMatchingMode } from '@/entrypoints/contentScript/Filter';
|
||||
import { AutofillMatchingMode } from '@/entrypoints/contentScript/CredentialMatcher';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
|
||||
import {
|
||||
DISABLED_SITES_KEY,
|
||||
GLOBAL_AUTOFILL_POPUP_ENABLED_KEY,
|
||||
import {
|
||||
DISABLED_SITES_KEY,
|
||||
GLOBAL_AUTOFILL_POPUP_ENABLED_KEY,
|
||||
TEMPORARY_DISABLED_SITES_KEY,
|
||||
AUTOFILL_MATCHING_MODE_KEY
|
||||
AUTOFILL_MATCHING_MODE_KEY
|
||||
} from '@/utils/Constants';
|
||||
|
||||
import { storage, browser } from "#imports";
|
||||
@@ -180,7 +180,7 @@ const AutofillSettings: React.FC = () => {
|
||||
: 'bg-red-500 hover:bg-red-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{settings.isGloballyEnabled ? t('settings.enabled') : t('settings.disabled')}
|
||||
{settings.isGloballyEnabled ? t('common.enabled') : t('common.disabled')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,7 +214,7 @@ const AutofillSettings: React.FC = () => {
|
||||
: 'bg-red-500 hover:bg-red-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{settings.isEnabled ? t('settings.enabled') : t('settings.disabled')}
|
||||
{settings.isEnabled ? t('common.enabled') : t('common.disabled')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@ const ContextMenuSettings: React.FC = () => {
|
||||
: 'bg-red-500 hover:bg-red-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{isContextMenuEnabled ? t('settings.enabled') : t('settings.disabled')}
|
||||
{isContextMenuEnabled ? t('common.enabled') : t('common.disabled')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||