mirror of
https://github.com/aliasvault/aliasvault.git
synced 2025-12-24 06:39:12 -05:00
Compare commits
806 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b19d27902 | ||
|
|
812302b9bc | ||
|
|
4581dc8fd9 | ||
|
|
42ba9d2869 | ||
|
|
773e6569c2 | ||
|
|
c24671ffb1 | ||
|
|
cd87692588 | ||
|
|
15dc89ac07 | ||
|
|
a95757e982 | ||
|
|
6061511d3c | ||
|
|
cc873fd483 | ||
|
|
8caa69e130 | ||
|
|
c45d0c8f56 | ||
|
|
6c0fc44a66 | ||
|
|
3b88cb5b50 | ||
|
|
7314dc3d1d | ||
|
|
2c98b81111 | ||
|
|
fe7da551a4 | ||
|
|
c4c29b11f3 | ||
|
|
ab740c093f | ||
|
|
056f8e97e9 | ||
|
|
819924c6e2 | ||
|
|
c6203b9e19 | ||
|
|
347a72e55d | ||
|
|
30a2b1326c | ||
|
|
4d66ea9694 | ||
|
|
1cf28c43fb | ||
|
|
6a75e56123 | ||
|
|
ef72abceb4 | ||
|
|
19406cf58d | ||
|
|
9fda76a5ff | ||
|
|
610d1b4654 | ||
|
|
602d59d268 | ||
|
|
edae632025 | ||
|
|
2c3d2379ee | ||
|
|
70ed03e1b3 | ||
|
|
bf1a235dd2 | ||
|
|
2bb7f0a742 | ||
|
|
8cd5118749 | ||
|
|
2fccb162e6 | ||
|
|
ad3c0323b9 | ||
|
|
9e859f6dc0 | ||
|
|
5f70912b7a | ||
|
|
dcc45eb5b6 | ||
|
|
340d3943a2 | ||
|
|
64a879f72d | ||
|
|
0f8e1f7e15 | ||
|
|
f86400fa50 | ||
|
|
047b0723b3 | ||
|
|
f785063065 | ||
|
|
3720ad1961 | ||
|
|
fe617fc024 | ||
|
|
1138b16daa | ||
|
|
108a6855c2 | ||
|
|
fb002e54b7 | ||
|
|
58ae63c74b | ||
|
|
51287c85dc | ||
|
|
b638e3375d | ||
|
|
5d827bb7ac | ||
|
|
666b3ccada | ||
|
|
87a62000d3 | ||
|
|
54c6e94751 | ||
|
|
54a5584baf | ||
|
|
ff48f1882f | ||
|
|
0b95203aac | ||
|
|
3f5328ab3c | ||
|
|
f913d84557 | ||
|
|
9a9752c557 | ||
|
|
82458f74e3 | ||
|
|
71633b166e | ||
|
|
3305958e60 | ||
|
|
4ae1f6ec35 | ||
|
|
4498833b4e | ||
|
|
7054593c07 | ||
|
|
6d197fe870 | ||
|
|
d70eb0a447 | ||
|
|
aecb52de3c | ||
|
|
cd6ea06430 | ||
|
|
0d13440821 | ||
|
|
8e3da4b381 | ||
|
|
81538d4666 | ||
|
|
634b7cada1 | ||
|
|
bed2c78964 | ||
|
|
a75392c573 | ||
|
|
7b10665488 | ||
|
|
ddf995db1d | ||
|
|
8d9d55ce82 | ||
|
|
ccf473635e | ||
|
|
56c8b61e9e | ||
|
|
69234de51c | ||
|
|
893c06cc00 | ||
|
|
b2c07f6de6 | ||
|
|
229fbd4824 | ||
|
|
48c5a5e38a | ||
|
|
5b3f36936a | ||
|
|
b4c696c89b | ||
|
|
d53c133812 | ||
|
|
cbbfe1c611 | ||
|
|
437c7bb807 | ||
|
|
03faee8d3a | ||
|
|
e66a87e8df | ||
|
|
11f1daa08b | ||
|
|
784e64ece8 | ||
|
|
4da1333aa5 | ||
|
|
65413c7ab7 | ||
|
|
290e5329f8 | ||
|
|
ec060d1392 | ||
|
|
293501405f | ||
|
|
783b2d44ef | ||
|
|
29d38759eb | ||
|
|
97f30ad9ba | ||
|
|
c728d71868 | ||
|
|
27fc298b5e | ||
|
|
6eb8266d05 | ||
|
|
f22cac70e9 | ||
|
|
f1c94ea145 | ||
|
|
d587f3fd5c | ||
|
|
db874d3799 | ||
|
|
3f5b731703 | ||
|
|
258981b2e4 | ||
|
|
34b3545168 | ||
|
|
c37dafd228 | ||
|
|
dbe15bdc51 | ||
|
|
9eb4a3136a | ||
|
|
747596615e | ||
|
|
60221cf0e8 | ||
|
|
d9aa765284 | ||
|
|
b7a916e414 | ||
|
|
110c0d2628 | ||
|
|
ecfc6f948d | ||
|
|
990d94397b | ||
|
|
b861a30596 | ||
|
|
583534fae9 | ||
|
|
8136eb379d | ||
|
|
9f5c1b35c4 | ||
|
|
7bd51fa2fe | ||
|
|
4340ed48e6 | ||
|
|
2fabc8c4dc | ||
|
|
99884b9761 | ||
|
|
c80a9c1b32 | ||
|
|
3c993fe875 | ||
|
|
ca1f3c3f64 | ||
|
|
728b5c2a9c | ||
|
|
73600a49f8 | ||
|
|
8a2e806311 | ||
|
|
9c8462f9ce | ||
|
|
e2fc9878b0 | ||
|
|
f5f05703a0 | ||
|
|
b30f8853aa | ||
|
|
d85d62f3b4 | ||
|
|
8bd8d688ef | ||
|
|
c174a6bfb4 | ||
|
|
3125eb3751 | ||
|
|
1e5a84b392 | ||
|
|
180977b833 | ||
|
|
2d40e424e8 | ||
|
|
af0b5ff5f8 | ||
|
|
1b8e6cc6a1 | ||
|
|
eb04263751 | ||
|
|
daccab9bcc | ||
|
|
6577021bd7 | ||
|
|
de6ae7f7e1 | ||
|
|
a272aa11f2 | ||
|
|
6cc77adbab | ||
|
|
b6b476f9c8 | ||
|
|
86aef6961c | ||
|
|
542f99c484 | ||
|
|
6ce666a35d | ||
|
|
0ddd47b0e7 | ||
|
|
f55d7717f8 | ||
|
|
1eaacd1ed0 | ||
|
|
4b385e0ea2 | ||
|
|
ff90cc2937 | ||
|
|
8bb6ec2b7c | ||
|
|
7a4e55912c | ||
|
|
a1f97cd709 | ||
|
|
dbb2aa5610 | ||
|
|
3af46c80fa | ||
|
|
e10ef4bd75 | ||
|
|
54853c7a4d | ||
|
|
1dde9ab4b4 | ||
|
|
3585e20354 | ||
|
|
c926933804 | ||
|
|
5a43f7142c | ||
|
|
a15138afc8 | ||
|
|
bd62ecd8bd | ||
|
|
f48591685a | ||
|
|
cae1813084 | ||
|
|
74e18a8fb1 | ||
|
|
a89546200c | ||
|
|
a40f29d467 | ||
|
|
bcda120351 | ||
|
|
ad1ffd63d5 | ||
|
|
4b55a21d33 | ||
|
|
183548616e | ||
|
|
4938129367 | ||
|
|
984f5a2c52 | ||
|
|
5969a9d437 | ||
|
|
efbb64637d | ||
|
|
b460023911 | ||
|
|
c0e869a586 | ||
|
|
cd306ef878 | ||
|
|
1a40e31470 | ||
|
|
30f9199a7e | ||
|
|
e830b9c482 | ||
|
|
bc6b9da10b | ||
|
|
40991d879e | ||
|
|
2949978a11 | ||
|
|
9715be40f3 | ||
|
|
a1d146c517 | ||
|
|
b729efbcfb | ||
|
|
ac0b7c4be8 | ||
|
|
865d5c8fce | ||
|
|
f154d8afe7 | ||
|
|
df6bcff8b3 | ||
|
|
3fbfca6163 | ||
|
|
d86ad136f7 | ||
|
|
a3e51409cf | ||
|
|
a11052bc77 | ||
|
|
de4b102397 | ||
|
|
ec66e7c339 | ||
|
|
59b118b35d | ||
|
|
db9ba0eac3 | ||
|
|
215e7b0eff | ||
|
|
d7b97a7139 | ||
|
|
8b23bc6142 | ||
|
|
49eae07bce | ||
|
|
8a2aafacfb | ||
|
|
23c386003e | ||
|
|
16e03d4dbc | ||
|
|
3616afa625 | ||
|
|
2fd8ade738 | ||
|
|
e10a37328a | ||
|
|
4811eb9ebe | ||
|
|
ba65e0c8ff | ||
|
|
1150614722 | ||
|
|
d10cc79148 | ||
|
|
ec833cb430 | ||
|
|
751f8b6afd | ||
|
|
68f351cfc5 | ||
|
|
b2177f5d98 | ||
|
|
d43efb0273 | ||
|
|
490861016a | ||
|
|
fa6ff5153a | ||
|
|
8ddefa56af | ||
|
|
0dac97f4ff | ||
|
|
7da8189789 | ||
|
|
a674baa6d6 | ||
|
|
9e04e54b43 | ||
|
|
7cb789ce9d | ||
|
|
c0a5a7db03 | ||
|
|
ccb84780eb | ||
|
|
25acce3ae0 | ||
|
|
1d29c3338d | ||
|
|
1c95a86c51 | ||
|
|
78052e74d6 | ||
|
|
70cc2b4985 | ||
|
|
5050fdc95d | ||
|
|
4da10bbfba | ||
|
|
7844f411ef | ||
|
|
cca687b61f | ||
|
|
8e6d125700 | ||
|
|
19fe4121ad | ||
|
|
6178303418 | ||
|
|
d563bd5c02 | ||
|
|
47f55ea08f | ||
|
|
07bad37568 | ||
|
|
b0dda6cb77 | ||
|
|
7a179fcde0 | ||
|
|
53decce407 | ||
|
|
dfb8c86366 | ||
|
|
cec6e7c303 | ||
|
|
1993d08487 | ||
|
|
6c54c270fa | ||
|
|
c92c8fc663 | ||
|
|
b0d03d6bb1 | ||
|
|
68895a7834 | ||
|
|
d183a406ac | ||
|
|
55b22dcaa8 | ||
|
|
44ff1b0118 | ||
|
|
ddd7b0a4ab | ||
|
|
bd564a1cd9 | ||
|
|
c7aa98a172 | ||
|
|
553e716c31 | ||
|
|
1e50b7b6bc | ||
|
|
3fce102471 | ||
|
|
297a7b4824 | ||
|
|
9dc80be72a | ||
|
|
c585bd83d2 | ||
|
|
0b81554b38 | ||
|
|
c93884c306 | ||
|
|
e8a40ea18e | ||
|
|
80a9996a23 | ||
|
|
7a300d5a46 | ||
|
|
3985a9e5ab | ||
|
|
214c76b446 | ||
|
|
30a2b0557a | ||
|
|
e63c198cce | ||
|
|
1e33c22d32 | ||
|
|
e253646c30 | ||
|
|
6303924d01 | ||
|
|
42fff611d8 | ||
|
|
4837d3d855 | ||
|
|
0b461bd015 | ||
|
|
a2c69bf36c | ||
|
|
bfe08eada7 | ||
|
|
8361860db5 | ||
|
|
5a1e859185 | ||
|
|
f9aa9005da | ||
|
|
de785d7e82 | ||
|
|
3aac3d9088 | ||
|
|
ca2088fd7a | ||
|
|
4e08d3f01c | ||
|
|
3bc2e47d76 | ||
|
|
9546327575 | ||
|
|
9dbfd3ea2b | ||
|
|
b3101c5336 | ||
|
|
454e005127 | ||
|
|
4bde61a70f | ||
|
|
bda0b11729 | ||
|
|
4cc0e66d93 | ||
|
|
fc091c441c | ||
|
|
d6e510fad3 | ||
|
|
a3cad05cd3 | ||
|
|
b9f3995f5d | ||
|
|
1b1a5924c3 | ||
|
|
7b820ccda1 | ||
|
|
459616880e | ||
|
|
62c23d34cf | ||
|
|
74e7635705 | ||
|
|
0a943a5066 | ||
|
|
67d3519ff8 | ||
|
|
02f4b53670 | ||
|
|
3bed56231a | ||
|
|
5204726bec | ||
|
|
c5a0bad44d | ||
|
|
f74a09e4bb | ||
|
|
99e17d0792 | ||
|
|
5f7730a474 | ||
|
|
f9a4937a3a | ||
|
|
468e7c8b66 | ||
|
|
8d5d755fdf | ||
|
|
64857bcbb4 | ||
|
|
66db3e0571 | ||
|
|
4cbed21e67 | ||
|
|
16f8eced09 | ||
|
|
547fa57cb6 | ||
|
|
cbacd7486a | ||
|
|
3b413a79c9 | ||
|
|
a2b962bb44 | ||
|
|
95739f6758 | ||
|
|
cc95779f48 | ||
|
|
25ff5bf994 | ||
|
|
32e6ca597a | ||
|
|
a39340262e | ||
|
|
58ed0bf156 | ||
|
|
77994d221e | ||
|
|
519fc5fb24 | ||
|
|
accc76d8a2 | ||
|
|
0c2de27f1a | ||
|
|
53047cf3ad | ||
|
|
0b7cdbce02 | ||
|
|
a963064dc8 | ||
|
|
f4c4962cb8 | ||
|
|
3c36020812 | ||
|
|
9892430e59 | ||
|
|
1e3e542f92 | ||
|
|
c90c5a9f2f | ||
|
|
7621be4cbe | ||
|
|
31868b7099 | ||
|
|
8213a81321 | ||
|
|
df2ae22a99 | ||
|
|
9999529d60 | ||
|
|
1df4884301 | ||
|
|
185b7a0ad6 | ||
|
|
c3dd77d6f8 | ||
|
|
c3ae769d11 | ||
|
|
fc7f12471a | ||
|
|
d36a3dba42 | ||
|
|
9556e6dca9 | ||
|
|
c0a63be92b | ||
|
|
2cf1ea2065 | ||
|
|
df7d1560be | ||
|
|
a6a56ec9fb | ||
|
|
3675454737 | ||
|
|
da21565f1b | ||
|
|
5b6a80a7b1 | ||
|
|
cb5cd1006c | ||
|
|
ca9b9e465c | ||
|
|
9a6c86569d | ||
|
|
21177e9927 | ||
|
|
e7c79f2aa4 | ||
|
|
8e89673cc9 | ||
|
|
fc75532a0d | ||
|
|
9eb913c692 | ||
|
|
e1497b74aa | ||
|
|
2d85511ec5 | ||
|
|
7c26398e9c | ||
|
|
23052b375c | ||
|
|
406505035b | ||
|
|
371ed93819 | ||
|
|
e715454acb | ||
|
|
28c1869048 | ||
|
|
bde0877168 | ||
|
|
2f11b5507c | ||
|
|
149a85dde9 | ||
|
|
cdfe7c5a99 | ||
|
|
23378368fb | ||
|
|
27fad07f92 | ||
|
|
29b5501a01 | ||
|
|
988c43ae20 | ||
|
|
f9e94c3059 | ||
|
|
1969dd0b48 | ||
|
|
f7a0f3d29a | ||
|
|
2464858b4e | ||
|
|
f793510b1e | ||
|
|
e7644dc3fb | ||
|
|
67d4a0b8ff | ||
|
|
0e37616ced | ||
|
|
182e5d8d8d | ||
|
|
f19e288196 | ||
|
|
8bff55414c | ||
|
|
63b18acbac | ||
|
|
49676bf1f4 | ||
|
|
db39a18ab5 | ||
|
|
4d57f8dea3 | ||
|
|
3160ad202a | ||
|
|
946a44a9a1 | ||
|
|
4bba4c5911 | ||
|
|
50c401cee4 | ||
|
|
4e09912420 | ||
|
|
6c8843dc5b | ||
|
|
4c4aa4ba26 | ||
|
|
5ac5f54f78 | ||
|
|
d488107b75 | ||
|
|
fe30116b33 | ||
|
|
77ced32206 | ||
|
|
299d1f6075 | ||
|
|
9811e32a73 | ||
|
|
7655773fa3 | ||
|
|
7a5afcac9c | ||
|
|
1ab736fd03 | ||
|
|
018895e8e9 | ||
|
|
0b07a37d73 | ||
|
|
5c0d7fc571 | ||
|
|
d9d84dd90f | ||
|
|
70b7063af2 | ||
|
|
87287e0237 | ||
|
|
477e786454 | ||
|
|
361ea77ab7 | ||
|
|
36237176fd | ||
|
|
e15ecaf793 | ||
|
|
4422ddcaa3 | ||
|
|
e34e96746f | ||
|
|
4c4d51d78e | ||
|
|
e4b12c4617 | ||
|
|
1cf9b5e93c | ||
|
|
6664266c3f | ||
|
|
79af285124 | ||
|
|
66928f74b7 | ||
|
|
c8599ccd9e | ||
|
|
53f69c97af | ||
|
|
11d8c941d2 | ||
|
|
e31f3df45b | ||
|
|
e2aafa3704 | ||
|
|
c2290f3ba4 | ||
|
|
b134ef3aee | ||
|
|
912c486266 | ||
|
|
51901e6ce3 | ||
|
|
0dbe417636 | ||
|
|
6f9528ea2d | ||
|
|
3266f7394e | ||
|
|
9fd5848029 | ||
|
|
0e2d7cabe8 | ||
|
|
2e5b00ea2c | ||
|
|
ff535188da | ||
|
|
bb41207cfe | ||
|
|
5944cd3248 | ||
|
|
0f02412db2 | ||
|
|
db479182f0 | ||
|
|
d5f8516abc | ||
|
|
1682304ae7 | ||
|
|
d0bbf3ac9f | ||
|
|
12492c922d | ||
|
|
3240c3760a | ||
|
|
58801926cc | ||
|
|
39b5c03ae1 | ||
|
|
b01cdc1f52 | ||
|
|
ce0f466f01 | ||
|
|
80e40b3ceb | ||
|
|
70bb8ef3e4 | ||
|
|
00fb290598 | ||
|
|
9d8a2e784f | ||
|
|
e57cb01164 | ||
|
|
6f421bbdc1 | ||
|
|
eaa42196f8 | ||
|
|
e844e20322 | ||
|
|
b53a4334ca | ||
|
|
afe2ba52b5 | ||
|
|
3e82c6e5d0 | ||
|
|
68dbecd536 | ||
|
|
c0c1b75e73 | ||
|
|
8510648b5f | ||
|
|
0e803205c0 | ||
|
|
2fc7ffa509 | ||
|
|
b16fd8e157 | ||
|
|
effeb211ff | ||
|
|
bfc15fcea6 | ||
|
|
6bb204efb9 | ||
|
|
dbc9724377 | ||
|
|
71783f1af2 | ||
|
|
7ead1d270b | ||
|
|
19b89cbfda | ||
|
|
0617ccb42e | ||
|
|
a3d702f2e5 | ||
|
|
3967b0f832 | ||
|
|
867dd90000 | ||
|
|
6ed1be3b91 | ||
|
|
56e065feea | ||
|
|
3b27e647ef | ||
|
|
62732a71f0 | ||
|
|
f3ad61a77a | ||
|
|
0d878f669f | ||
|
|
6fba784cfe | ||
|
|
c46a95cf82 | ||
|
|
bba16e6e14 | ||
|
|
b4c4603868 | ||
|
|
925455b5d6 | ||
|
|
6aa0c2b9df | ||
|
|
1799a2f580 | ||
|
|
615b5b2883 | ||
|
|
006f89b6b7 | ||
|
|
76c60ad200 | ||
|
|
1830dc0ca1 | ||
|
|
c3599c9f26 | ||
|
|
5d050cd278 | ||
|
|
ff57091eef | ||
|
|
64ef5837c0 | ||
|
|
771f372434 | ||
|
|
7690355434 | ||
|
|
822b95d940 | ||
|
|
41b2a959ed | ||
|
|
3e82f78fe9 | ||
|
|
421884e301 | ||
|
|
d149e5aeec | ||
|
|
8b2702cbe3 | ||
|
|
7b1cfd363c | ||
|
|
5e965d7b3f | ||
|
|
d8ac05f325 | ||
|
|
a1c13a15f9 | ||
|
|
f285b36c61 | ||
|
|
c6fa90e00c | ||
|
|
cb8de80f08 | ||
|
|
15bb7f6593 | ||
|
|
516dd524df | ||
|
|
87e58f8546 | ||
|
|
3baaf78689 | ||
|
|
336bbafe27 | ||
|
|
83d9eadeea | ||
|
|
1cdd8f456e | ||
|
|
395f881bd0 | ||
|
|
293ae102c5 | ||
|
|
8f5852bb86 | ||
|
|
9ccaff74cd | ||
|
|
ee6b40dd3d | ||
|
|
3ca4c0a78d | ||
|
|
b246def212 | ||
|
|
1eecb8be38 | ||
|
|
9a7fbe7d2a | ||
|
|
7776fb6d82 | ||
|
|
0eebaddf04 | ||
|
|
8b145e66b5 | ||
|
|
4e3c992c24 | ||
|
|
65944b1523 | ||
|
|
d05114fddc | ||
|
|
8e0fef4b16 | ||
|
|
1bf8b7ee04 | ||
|
|
8545b2c1fd | ||
|
|
2f22e4db56 | ||
|
|
54bbbb0647 | ||
|
|
0b127a4a3e | ||
|
|
241f17868b | ||
|
|
be536741c5 | ||
|
|
7638879aa9 | ||
|
|
499f6e451e | ||
|
|
73ad8f6acd | ||
|
|
c5ea7d0143 | ||
|
|
0473ec21bf | ||
|
|
0eb7e97383 | ||
|
|
7d35777c93 | ||
|
|
08e39ef3e9 | ||
|
|
fe10acb925 | ||
|
|
061f846b66 | ||
|
|
eb64d86c78 | ||
|
|
ef2a58f784 | ||
|
|
a43d50f047 | ||
|
|
0d5fd55133 | ||
|
|
d9942844e2 | ||
|
|
15a1276d42 | ||
|
|
37d6ead41d | ||
|
|
fa99cb77d7 | ||
|
|
f9987b5e2a | ||
|
|
ec11ab0817 | ||
|
|
ecd592e74f | ||
|
|
a3208e72bf | ||
|
|
d66dee3583 | ||
|
|
68471b7c88 | ||
|
|
3d8c2b7086 | ||
|
|
a93a7f7fff | ||
|
|
1b84fd1dad | ||
|
|
c673a20fd1 | ||
|
|
7e81e70ec4 | ||
|
|
c688764831 | ||
|
|
3da40f42c9 | ||
|
|
fd74b7b056 | ||
|
|
0ccbeb683d | ||
|
|
34d00dc7d6 | ||
|
|
ffe1a36df3 | ||
|
|
0f9c2d1f7c | ||
|
|
19499f02d6 | ||
|
|
330a92fbb3 | ||
|
|
5ca29a33d0 | ||
|
|
ab6191ac62 | ||
|
|
f8bf575ab5 | ||
|
|
3576b32821 | ||
|
|
4619fe615c | ||
|
|
e8ba964064 | ||
|
|
4af1a127cf | ||
|
|
22acea0e35 | ||
|
|
c6d7d16b27 | ||
|
|
aba377ac65 | ||
|
|
5a0d1eabb7 | ||
|
|
eb2c4c1cd3 | ||
|
|
62224c86cd | ||
|
|
6ab20501e9 | ||
|
|
dd82803f87 | ||
|
|
27d19759c8 | ||
|
|
c6faa4db97 | ||
|
|
f35d46256f | ||
|
|
4683d6bea6 | ||
|
|
566d4259bd | ||
|
|
afee07885d | ||
|
|
8e8ef8fd5d | ||
|
|
5589042606 | ||
|
|
cbe8b2c471 | ||
|
|
4c7bef2a5a | ||
|
|
bc6479bf5e | ||
|
|
845f780707 | ||
|
|
1089e8299f | ||
|
|
ce9b37d299 | ||
|
|
538675f391 | ||
|
|
260aec34ce | ||
|
|
a7ffc33d56 | ||
|
|
89a57b6047 | ||
|
|
a66e8b6b0d | ||
|
|
5de0806bcc | ||
|
|
a1d2bcbe3b | ||
|
|
fbc085439c | ||
|
|
4a35a1a7d3 | ||
|
|
bd82037d8c | ||
|
|
9615634bf9 | ||
|
|
dfd2b534e6 | ||
|
|
314c757fe6 | ||
|
|
771abe9cc1 | ||
|
|
22aaf17cd1 | ||
|
|
2134b61a78 | ||
|
|
0059e31892 | ||
|
|
2f7a4370b7 | ||
|
|
5fc2889a03 | ||
|
|
f43bc402ba | ||
|
|
2e6d4fbe20 | ||
|
|
38db3c5054 | ||
|
|
971a21a16a | ||
|
|
8058912eee | ||
|
|
8a9e1dc9a3 | ||
|
|
cde78650b9 | ||
|
|
4ef9e58665 | ||
|
|
b6b1d9dec9 | ||
|
|
fa2dedb05a | ||
|
|
f148ccdeba | ||
|
|
9b038cb76c | ||
|
|
aa726706a4 | ||
|
|
d0017d9207 | ||
|
|
cde4b87371 | ||
|
|
431d8d4fca | ||
|
|
9fddb5f450 | ||
|
|
dbb6cf5b94 | ||
|
|
bd41507ef9 | ||
|
|
ebb0e7cf68 | ||
|
|
4603051a91 | ||
|
|
f66fb53706 | ||
|
|
b603160d99 | ||
|
|
096b0277f3 | ||
|
|
f271040ff4 | ||
|
|
f313950112 | ||
|
|
ef1ad127e3 | ||
|
|
cac691a43d | ||
|
|
4efe201224 | ||
|
|
ca477c310c | ||
|
|
77189373ba | ||
|
|
1aaa5c2d55 | ||
|
|
163e5c51c2 | ||
|
|
29895f375f | ||
|
|
2803dcf02c | ||
|
|
a8e075d932 | ||
|
|
49ba704135 | ||
|
|
9669307480 | ||
|
|
343ced5b38 | ||
|
|
8f66670804 | ||
|
|
c2d1fcfcd4 | ||
|
|
e5a340b67d | ||
|
|
6a0e8909a8 | ||
|
|
5a90b4271c | ||
|
|
f0bd837d5e | ||
|
|
de45c286b1 | ||
|
|
fac0fd5f32 | ||
|
|
5a8b6b7f29 | ||
|
|
c864bfcab5 | ||
|
|
c9c692ce6e | ||
|
|
a640e4d280 | ||
|
|
2f03db7951 | ||
|
|
9e5b733c8a | ||
|
|
09c380afdd | ||
|
|
7d9cc6118e | ||
|
|
c7ab42e9f2 | ||
|
|
1b07c5de9f | ||
|
|
84df5b7d98 | ||
|
|
347721a575 | ||
|
|
463c31641d | ||
|
|
67759a814e | ||
|
|
763a859e22 | ||
|
|
d7db5a4e76 | ||
|
|
85bb5cf944 | ||
|
|
cdc59e43a9 | ||
|
|
9d0a003b2d | ||
|
|
e430ae9f4f | ||
|
|
41ba1260d7 | ||
|
|
c7572ac3f7 | ||
|
|
fe5c50b3c4 | ||
|
|
2a8ed28ff9 | ||
|
|
f6764b2f33 | ||
|
|
1afa153381 | ||
|
|
ac59273161 | ||
|
|
551fc42de1 | ||
|
|
4b844189bc | ||
|
|
5c277e747f | ||
|
|
8cbd275134 | ||
|
|
765625b163 | ||
|
|
b3df153128 | ||
|
|
604cffc622 | ||
|
|
3b114445a3 | ||
|
|
e8942c9833 | ||
|
|
b1da32ceae | ||
|
|
ef58217ed3 | ||
|
|
e0dd04263c | ||
|
|
29c52c844f | ||
|
|
b99025c48a | ||
|
|
8ba8eb684e | ||
|
|
b736edbb68 | ||
|
|
1fa0d275cc | ||
|
|
4a05cd00e3 | ||
|
|
574b5ff693 | ||
|
|
e6b7d1afa1 | ||
|
|
cbe224385d | ||
|
|
adb2f9a3d6 | ||
|
|
6790391d37 | ||
|
|
2a7855e1dc | ||
|
|
f3e47d7e67 | ||
|
|
bc76e85a9c | ||
|
|
890025cd49 | ||
|
|
1868370d8f | ||
|
|
9a4fc7fb37 | ||
|
|
199fdebd5d | ||
|
|
d5f17ef99c | ||
|
|
3b1e039d75 | ||
|
|
01cdd28e32 | ||
|
|
95a71f6ab2 | ||
|
|
41cb92befd | ||
|
|
2cfd1a922f | ||
|
|
511ec31d17 | ||
|
|
080e505991 | ||
|
|
461c1a042d | ||
|
|
f30fcf4624 | ||
|
|
522eeefda4 | ||
|
|
94656c4d14 | ||
|
|
bbba8d1393 | ||
|
|
680f5ba926 | ||
|
|
04d3f80019 | ||
|
|
a4d78cf7fc | ||
|
|
9713c8ed11 | ||
|
|
2f4dbf34ba | ||
|
|
232d110e49 | ||
|
|
0af1507686 | ||
|
|
e481769198 | ||
|
|
830c390b95 | ||
|
|
c733a60571 | ||
|
|
d164d8e785 | ||
|
|
826bd23767 | ||
|
|
a70f6fca56 | ||
|
|
1480fd88d1 | ||
|
|
11a5e10f4b | ||
|
|
eecf61b8b2 | ||
|
|
6c620e34e6 | ||
|
|
aa99bbc111 | ||
|
|
e34b5f586c | ||
|
|
80c0992eb4 |
97
.env.example
97
.env.example
@@ -14,91 +14,60 @@
|
||||
# Docker containers to apply the changes.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# Set the ports that your AliasVault will be accessible at.
|
||||
# These are the default ports that will be used by the `reverse-proxy` and `smtp` containers.
|
||||
# You can change these to any other ports that are available on your system.
|
||||
# ===========================================
|
||||
# NETWORK PORTS
|
||||
# ===========================================
|
||||
|
||||
# Configure the network ports used by AliasVault by the `reverse-proxy` and `smtp` containers.
|
||||
# You can change these if the defaults are already in use on your system.
|
||||
# Requires a restart before taking effect.
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
SMTP_PORT=25
|
||||
SMTP_TLS_PORT=587
|
||||
|
||||
# Set the hostname that your AliasVault will be accessible at.
|
||||
# E.g. `aliasvault.mydomain.com` or if you're running it on your local machine, choose `localhost`.
|
||||
HOSTNAME=
|
||||
# ===========================================
|
||||
# EMAIL SERVER CONFIGURATION
|
||||
# ===========================================
|
||||
|
||||
# Set a random 32 character string for the JWT key.
|
||||
# This can be generated using the following command:
|
||||
# $ openssl rand -base64 32
|
||||
JWT_KEY=
|
||||
|
||||
# Set the password for the data protection certificate.
|
||||
# This can be generated using the following command:
|
||||
# $ openssl rand -base64 32
|
||||
DATA_PROTECTION_CERT_PASS=
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Database configuration
|
||||
# ----------------------------------------------------------------------------
|
||||
# These are the credentials that are used by the PostgreSQL container
|
||||
# on startup to create the database and user, and for the application to
|
||||
# connect to the database.
|
||||
POSTGRES_DB=aliasvault
|
||||
POSTGRES_USER=aliasvault
|
||||
|
||||
# Set the password for the database user.
|
||||
# This can be generated using the following command:
|
||||
# $ openssl rand -base64 32
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# Note: in order to change the password for an existing installation
|
||||
# refer to https://docs.aliasvault.net/misc/dev/database-operations.html
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Admin user configuration
|
||||
# ----------------------------------------------------------------------------
|
||||
# Set the password for the admin user. This is an encrypted hash that needs
|
||||
# to be generated using the `aliasvault-cli` tool. This allows you to login
|
||||
# to the admin panel at https://your-hostname/admin.
|
||||
#
|
||||
# For example:
|
||||
# docker run --rm ghcr.io/lanedirt/aliasvault-installcli:latest hash-password "my-password"
|
||||
#
|
||||
# Then copy the output and paste it into the ADMIN_PASSWORD_HASH variable below.
|
||||
# When changing the hash, update the ADMIN_PASSWORD_GENERATED variable to the current date and time
|
||||
# and then restart the AliasVault docker containers to apply the changes.
|
||||
ADMIN_PASSWORD_HASH=
|
||||
|
||||
# Set the date and time the admin password was last generated. When changing the
|
||||
# admin password hash manually, make sure to increase this value so the system
|
||||
# knows that the password has been changed and should be overwritten with the new hash.
|
||||
ADMIN_PASSWORD_GENERATED=2024-01-01T00:00:00Z
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Email server configuration for email aliases
|
||||
# ----------------------------------------------------------------------------
|
||||
# In order to use AliasVault's private email domains feature, you need to configure
|
||||
# your DNS. Please refer to the full documentation for more instructions on DNS:
|
||||
# https://docs.aliasvault.net/installation/install.html#3-email-server-setup
|
||||
#
|
||||
# Set the private email domains below that are allowed to be used (comma separated values).
|
||||
# Example: PRIVATE_EMAIL_DOMAINS=example.com,example2.org
|
||||
# To disable the private email domains feature, set this to "DISABLED.TLD"
|
||||
PRIVATE_EMAIL_DOMAINS=DISABLED.TLD
|
||||
# To disable the private email domains feature, keep this empty.
|
||||
PRIVATE_EMAIL_DOMAINS=
|
||||
|
||||
# Set whether TLS is enabled for SMTP.
|
||||
# Enable TLS for SMTP.
|
||||
# ⚠️ Requires valid TLS certificates on your mail server (not provided by the AliasVault installer).
|
||||
# If set to true without proper certificates, the SMTP service will fail to start.
|
||||
# For self-hosted setups, we recommend keeping this **false** unless you're sure how to configure it.
|
||||
# Note: Disabling TLS does **not** impact email deliverability.
|
||||
SMTP_TLS_ENABLED=false
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# ===========================================
|
||||
# Let's Encrypt configuration
|
||||
# ----------------------------------------------------------------------------
|
||||
# ===========================================
|
||||
# Set whether Let's Encrypt is enabled. This is only supported through
|
||||
# the install.sh script.
|
||||
# the install.sh script and should be set to false for manual installations.
|
||||
LETSENCRYPT_ENABLED=false
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Set the hostname that your AliasVault will be accessible at in order for LetsEncrypt
|
||||
# to do its validation. This value is only required when LETSENCRYPT_ENABLED
|
||||
# is set to true.
|
||||
# Example: `aliasvault.mydomain.net`.
|
||||
HOSTNAME=
|
||||
|
||||
# ===========================================
|
||||
# Optional configuration settings
|
||||
# ----------------------------------------------------------------------------
|
||||
# ===========================================
|
||||
# Enable or disable ability for new users to create an account via the web interface.
|
||||
# Note: make sure you have created your (own) accounts before setting this to false.
|
||||
PUBLIC_REGISTRATION_ENABLED=true
|
||||
|
||||
# Whether to enable IP logging for auth attempts. When set to true the last octet is
|
||||
# always still anonymized, e.g. "127.0.0.1" becomes "127.0.0.xxx".
|
||||
IP_LOGGING_ENABLED=true
|
||||
|
||||
# Set the support email address which is shown to users in the main web app.
|
||||
|
||||
132
.github/actions/build-android-app/action.yml
vendored
Normal file
132
.github/actions/build-android-app/action.yml
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
name: "Build Android App"
|
||||
description: "Builds Android APK/AAB, optionally signs and uploads to GitHub Release"
|
||||
inputs:
|
||||
run_tests:
|
||||
description: "Whether to run Android unit tests"
|
||||
required: false
|
||||
default: "false"
|
||||
signed:
|
||||
description: "Whether to sign the Android build"
|
||||
required: false
|
||||
default: "false"
|
||||
upload_to_release:
|
||||
description: "Whether to upload the APK to GitHub Release"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: apps/mobile-app/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
shell: bash
|
||||
working-directory: apps/mobile-app
|
||||
|
||||
- name: Extract version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./app.json').expo.version")
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
working-directory: apps/mobile-app
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Build JS bundle (Expo)
|
||||
run: |
|
||||
mkdir -p build
|
||||
npx expo export \
|
||||
--dev \
|
||||
--output-dir ./build \
|
||||
--platform android
|
||||
shell: bash
|
||||
working-directory: apps/mobile-app
|
||||
|
||||
- name: Run Android Unit Tests
|
||||
if: ${{ inputs.run_tests == 'true' }}
|
||||
run: |
|
||||
cd android
|
||||
./gradlew :app:testDebugUnitTest --tests "net.aliasvault.app.*"
|
||||
shell: bash
|
||||
working-directory: apps/mobile-app
|
||||
|
||||
- name: Upload Android Test Reports
|
||||
if: ${{ inputs.run_tests == 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-test-reports
|
||||
path: apps/mobile-app/android/app/build/reports/tests/testDebugUnitTest/
|
||||
retention-days: 7
|
||||
|
||||
- name: Decode keystore
|
||||
if: ${{ inputs.signed == 'true' }}
|
||||
run: echo "${{ env.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
|
||||
shell: bash
|
||||
working-directory: apps/mobile-app
|
||||
|
||||
- name: Configure signing
|
||||
if: ${{ inputs.signed == 'true' }}
|
||||
run: |
|
||||
cat >> android/gradle.properties <<EOF
|
||||
ALIASVAULT_UPLOAD_STORE_FILE=keystore.jks
|
||||
ALIASVAULT_UPLOAD_KEY_ALIAS=${{ env.ANDROID_KEY_ALIAS }}
|
||||
ALIASVAULT_UPLOAD_STORE_PASSWORD=${{ env.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ALIASVAULT_UPLOAD_KEY_PASSWORD=${{ env.ANDROID_KEY_PASSWORD }}
|
||||
EOF
|
||||
shell: bash
|
||||
working-directory: apps/mobile-app
|
||||
|
||||
- name: Build APK & AAB (Release only if signed)
|
||||
if: ${{ inputs.signed == 'true' }}
|
||||
run: |
|
||||
cd android
|
||||
./gradlew bundleRelease
|
||||
./gradlew assembleRelease
|
||||
shell: bash
|
||||
working-directory: apps/mobile-app
|
||||
|
||||
- name: Rename APK and AAB files
|
||||
if: ${{ inputs.signed == 'true' }}
|
||||
run: |
|
||||
mv android/app/build/outputs/apk/release/app-release.apk android/app/build/outputs/apk/release/aliasvault-${VERSION}-android.apk
|
||||
mv android/app/build/outputs/bundle/release/app-release.aab android/app/build/outputs/bundle/release/aliasvault-${VERSION}-android.aab
|
||||
shell: bash
|
||||
working-directory: apps/mobile-app
|
||||
|
||||
- name: Upload AAB as artifact
|
||||
if: ${{ inputs.signed == 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aliasvault-${{ env.VERSION }}-android.aab
|
||||
path: apps/mobile-app/android/app/build/outputs/bundle/release/aliasvault-${{ env.VERSION }}-android.aab
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload APK as artifact
|
||||
if: ${{ inputs.signed == 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aliasvault-${{ env.VERSION }}-android.apk
|
||||
path: apps/mobile-app/android/app/build/outputs/apk/release/aliasvault-${{ env.VERSION }}-android.apk
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload APK to release
|
||||
if: ${{ inputs.upload_to_release == 'true' }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: apps/mobile-app/android/app/build/outputs/apk/release/aliasvault-${{ env.VERSION }}-android.apk
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
104
.github/actions/build-browser-extension/action.yml
vendored
Normal file
104
.github/actions/build-browser-extension/action.yml
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
name: "Build Browser Extension"
|
||||
description: "Builds, tests, lints, zips, and optionally uploads a browser extension"
|
||||
inputs:
|
||||
browser:
|
||||
description: "Target browser to build for (chrome, firefox, edge)"
|
||||
required: true
|
||||
upload_to_release:
|
||||
description: "Whether to upload the resulting zip to GitHub Release"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: apps/browser-extension/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
shell: bash
|
||||
working-directory: apps/browser-extension
|
||||
|
||||
- name: Build extension
|
||||
run: npm run build:${{ inputs.browser }}
|
||||
shell: bash
|
||||
working-directory: apps/browser-extension
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
shell: bash
|
||||
working-directory: apps/browser-extension
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
shell: bash
|
||||
working-directory: apps/browser-extension
|
||||
|
||||
- name: Zip Extension
|
||||
run: npm run zip:${{ inputs.browser }}
|
||||
shell: bash
|
||||
working-directory: apps/browser-extension
|
||||
|
||||
- name: Extract version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
working-directory: apps/browser-extension
|
||||
|
||||
- name: Unzip extension
|
||||
run: |
|
||||
mkdir -p dist/${{ inputs.browser }}-unpacked
|
||||
unzip dist/aliasvault-browser-extension-${{ env.VERSION }}-${{ inputs.browser }}.zip -d dist/${{ inputs.browser }}-unpacked
|
||||
shell: bash
|
||||
working-directory: apps/browser-extension
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aliasvault-${{ env.VERSION }}-${{ inputs.browser }}
|
||||
path: apps/browser-extension/dist/${{ inputs.browser }}-unpacked
|
||||
|
||||
- name: Unzip and upload Firefox sources
|
||||
if: ${{ inputs.browser == 'firefox' }}
|
||||
run: |
|
||||
mkdir -p dist/sources-unpacked
|
||||
unzip dist/aliasvault-browser-extension-${{ env.VERSION }}-sources.zip -d dist/sources-unpacked
|
||||
shell: bash
|
||||
working-directory: apps/browser-extension
|
||||
|
||||
- name: Upload Firefox sources artifact
|
||||
if: ${{ inputs.browser == 'firefox' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aliasvault-${{ env.VERSION }}-browser-extension-sources
|
||||
path: apps/browser-extension/dist/sources-unpacked
|
||||
|
||||
- name: Rename zip files
|
||||
run: |
|
||||
mv apps/browser-extension/dist/aliasvault-browser-extension-${{ env.VERSION }}-${{ inputs.browser }}.zip apps/browser-extension/dist/aliasvault-${{ env.VERSION }}-${{ inputs.browser }}.zip
|
||||
if [ -f apps/browser-extension/dist/aliasvault-browser-extension-${{ env.VERSION }}-sources.zip ]; then
|
||||
mv apps/browser-extension/dist/aliasvault-browser-extension-${{ env.VERSION }}-sources.zip apps/browser-extension/dist/aliasvault-${{ env.VERSION }}-browser-extension-sources.zip
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
if: ${{ inputs.upload_to_release == 'true' }}
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
apps/browser-extension/dist/aliasvault-${{ env.VERSION }}-${{ inputs.browser }}.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload Firefox sources to Release
|
||||
if: ${{ inputs.upload_to_release == 'true' && inputs.browser == 'firefox' }}
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: apps/browser-extension/dist/aliasvault-${{ env.VERSION }}-browser-extension-sources.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }}
|
||||
118
.github/actions/build-ios-app/action.yml
vendored
Normal file
118
.github/actions/build-ios-app/action.yml
vendored
Normal file
@@ -0,0 +1,118 @@
|
||||
name: "Build iOS App"
|
||||
description: "Builds iOS App, optionally signs and uploads to App Store Connect"
|
||||
inputs:
|
||||
run_tests:
|
||||
description: "Whether to run iOS unit tests"
|
||||
required: false
|
||||
default: "false"
|
||||
signed:
|
||||
description: "Whether to sign the iOS build"
|
||||
required: false
|
||||
default: "false"
|
||||
upload_to_app_store_connect:
|
||||
description: "Whether to upload the iOS App to App Store Connect"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: apps/mobile-app/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
shell: bash
|
||||
working-directory: apps/mobile-app
|
||||
|
||||
- name: Extract version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./app.json').expo.version")
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
working-directory: apps/mobile-app
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.2'
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: gem install fastlane
|
||||
shell: bash
|
||||
|
||||
- name: Install CocoaPods
|
||||
run: |
|
||||
sudo gem install cocoapods
|
||||
shell: bash
|
||||
|
||||
- name: Create ASC private key file
|
||||
if: ${{ inputs.signed == 'true' }}
|
||||
run: |
|
||||
mkdir -p $RUNNER_TEMP/asc
|
||||
echo "${{ env.ASC_PRIVATE_KEY_BASE64 }}" | base64 --decode > $RUNNER_TEMP/asc/AuthKey.p8
|
||||
shell: bash
|
||||
|
||||
- name: Install CocoaPods
|
||||
run: |
|
||||
cd ios
|
||||
pod install
|
||||
shell: bash
|
||||
working-directory: apps/mobile-app
|
||||
|
||||
- name: Build iOS IPA
|
||||
if: ${{ inputs.signed == 'true' }}
|
||||
env:
|
||||
IDEFileSystemSynchronizedGroupsAreEnabled: NO
|
||||
XCODE_WORKSPACE: AliasVault.xcworkspace
|
||||
XCODE_SCHEME: AliasVault
|
||||
XCODE_CONFIGURATION: Release
|
||||
XCODE_ARCHIVE_PATH: AliasVault.xcarchive
|
||||
XCODE_EXPORT_PATH: ./build
|
||||
XCODE_SKIP_FILESYSTEM_SYNC: true
|
||||
run: |
|
||||
cd ios
|
||||
xcodebuild clean -workspace "$XCODE_WORKSPACE" \
|
||||
-scheme "$XCODE_SCHEME" \
|
||||
-configuration "$XCODE_CONFIGURATION"
|
||||
xcodebuild -workspace "$XCODE_WORKSPACE" \
|
||||
-scheme "$XCODE_SCHEME" \
|
||||
-configuration "$XCODE_CONFIGURATION" \
|
||||
-archivePath "$XCODE_ARCHIVE_PATH" \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath $RUNNER_TEMP/asc/AuthKey.p8 \
|
||||
-authenticationKeyID ${{ env.ASC_KEY_ID }} \
|
||||
-authenticationKeyIssuerID ${{ env.ASC_ISSUER_ID }} \
|
||||
archive
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$XCODE_ARCHIVE_PATH" \
|
||||
-exportOptionsPlist ../exportOptions.plist \
|
||||
-exportPath "$XCODE_EXPORT_PATH"
|
||||
shell: bash
|
||||
working-directory: apps/mobile-app
|
||||
|
||||
- name: Upload IPA as artifact
|
||||
if: ${{ inputs.signed == 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aliasvault-${{ env.VERSION }}-ios.ipa
|
||||
path: apps/mobile-app/ios/build/AliasVault.ipa
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload to App Store Connect via Fastlane
|
||||
if: ${{ inputs.upload_to_app_store_connect == 'true' }}
|
||||
env:
|
||||
ASC_KEY_ID: ${{ env.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ env.ASC_ISSUER_ID }}
|
||||
run: |
|
||||
cd apps/mobile-app/ios
|
||||
fastlane pilot upload \
|
||||
--ipa "./build/AliasVault.ipa" \
|
||||
--api_key_path "$RUNNER_TEMP/asc/AuthKey.p8" \
|
||||
--skip_waiting_for_build_processing true
|
||||
shell: bash
|
||||
158
.github/workflows/browser-extension-build.yml
vendored
158
.github/workflows/browser-extension-build.yml
vendored
@@ -32,8 +32,10 @@ jobs:
|
||||
run: |
|
||||
# Check if files exist and were recently modified
|
||||
TARGET_DIRS=(
|
||||
"apps/browser-extension/src/utils/shared/identity-generator"
|
||||
"apps/browser-extension/src/utils/shared/password-generator"
|
||||
"apps/browser-extension/src/utils/dist/shared/identity-generator"
|
||||
"apps/browser-extension/src/utils/dist/shared/password-generator"
|
||||
"apps/browser-extension/src/utils/dist/shared/models"
|
||||
"apps/browser-extension/src/utils/dist/shared/vault-sql"
|
||||
)
|
||||
|
||||
for dir in "${TARGET_DIRS[@]}"; do
|
||||
@@ -42,15 +44,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for required files
|
||||
REQUIRED_FILES=("index.js" "index.mjs" "index.d.ts" "index.js.map" "index.mjs.map")
|
||||
for file in "${REQUIRED_FILES[@]}"; do
|
||||
if [ ! -f "$dir/$file" ]; then
|
||||
echo "❌ Required file $dir/$file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check if files were modified in the last 5 minutes
|
||||
find "$dir" -type f -mmin -5 | grep -q . || {
|
||||
echo "❌ Files in $dir were not recently modified"
|
||||
@@ -63,157 +56,32 @@ jobs:
|
||||
build-chrome-extension:
|
||||
needs: build-shared-libraries
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/browser-extension
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get short SHA
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Build Chrome Extension
|
||||
uses: ./.github/actions/build-browser-extension
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: apps/browser-extension/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build extension
|
||||
run: npm run build:chrome
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Zip Chrome Extension
|
||||
run: npm run zip:chrome
|
||||
|
||||
- name: Unzip for artifact
|
||||
run: |
|
||||
mkdir -p dist/chrome-unpacked
|
||||
unzip dist/aliasvault-browser-extension-*-chrome.zip -d dist/chrome-unpacked
|
||||
|
||||
- name: Upload dist artifact Chrome
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', steps.vars.outputs.sha_short) || steps.vars.outputs.sha_short) }}-chrome
|
||||
path: apps/browser-extension/dist/chrome-unpacked
|
||||
|
||||
outputs:
|
||||
sha_short: ${{ steps.vars.outputs.sha_short }}
|
||||
browser: chrome
|
||||
|
||||
build-firefox-extension:
|
||||
needs: build-shared-libraries
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/browser-extension
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get short SHA
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Build Firefox Extension
|
||||
uses: ./.github/actions/build-browser-extension
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: apps/browser-extension/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build extension
|
||||
run: npm run build:firefox
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Zip Firefox Extension
|
||||
run: npm run zip:firefox
|
||||
|
||||
- name: Unzip for artifact
|
||||
run: |
|
||||
mkdir -p dist/firefox-unpacked
|
||||
unzip dist/aliasvault-browser-extension-*-firefox.zip -d dist/firefox-unpacked
|
||||
mkdir -p dist/sources-unpacked
|
||||
unzip dist/aliasvault-browser-extension-*-sources.zip -d dist/sources-unpacked
|
||||
|
||||
- name: Upload dist artifact Firefox
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', steps.vars.outputs.sha_short) || steps.vars.outputs.sha_short) }}-firefox
|
||||
path: apps/browser-extension/dist/firefox-unpacked
|
||||
|
||||
- name: Upload dist artifact Firefox sources
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', steps.vars.outputs.sha_short) || steps.vars.outputs.sha_short) }}-sources
|
||||
path: apps/browser-extension/dist/sources-unpacked
|
||||
|
||||
outputs:
|
||||
sha_short: ${{ steps.vars.outputs.sha_short }}
|
||||
browser: firefox
|
||||
|
||||
build-edge-extension:
|
||||
needs: build-shared-libraries
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/browser-extension
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get short SHA
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Build Edge Extension
|
||||
uses: ./.github/actions/build-browser-extension
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: apps/browser-extension/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build extension
|
||||
run: npm run build:edge
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Zip Edge Extension
|
||||
run: npm run zip:edge
|
||||
|
||||
- name: Unzip for artifact
|
||||
run: |
|
||||
mkdir -p dist/edge-unpacked
|
||||
unzip dist/aliasvault-browser-extension-*-edge.zip -d dist/edge-unpacked
|
||||
|
||||
- name: Upload dist artifact Edge
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aliasvault-browser-extension-${{ github.event_name == 'release' && github.ref_name || (github.ref_name == 'main' && format('main-{0}', steps.vars.outputs.sha_short) || steps.vars.outputs.sha_short) }}-edge
|
||||
path: apps/browser-extension/dist/edge-unpacked
|
||||
|
||||
outputs:
|
||||
sha_short: ${{ steps.vars.outputs.sha_short }}
|
||||
browser: edge
|
||||
|
||||
244
.github/workflows/docker-build.yml
vendored
244
.github/workflows/docker-build.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Docker Pull and Build
|
||||
name: Docker Build Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -11,125 +11,153 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
docker-compose-pull:
|
||||
name: Docker Compose Pull Test
|
||||
docker-all-in-one-build:
|
||||
name: Docker All-in-One Build Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
docker:
|
||||
image: docker:26.0.0
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- name: Get repository and branch information
|
||||
id: repo-info
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Build all-in-one Docker image
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.pull_request.head.repo.fork }}" = "true" ]; then
|
||||
echo "REPO_FULL_NAME=lanedirt/AliasVault" >> $GITHUB_ENV
|
||||
echo "BRANCH_NAME=main" >> $GITHUB_ENV
|
||||
else
|
||||
echo "REPO_FULL_NAME=${GITHUB_REPOSITORY}" >> $GITHUB_ENV
|
||||
echo "BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_ENV
|
||||
docker build -f dockerfiles/all-in-one/Dockerfile -t aliasvault-allinone:test .
|
||||
echo "✅ All-in-one Docker image built successfully"
|
||||
|
||||
- name: Run all-in-one container
|
||||
run: |
|
||||
docker run -d \
|
||||
--name aliasvault-test \
|
||||
-p 8080:80 \
|
||||
-p 8443:443 \
|
||||
-p 2525:25 \
|
||||
-p 2587:587 \
|
||||
-v "$(pwd)/database:/database" \
|
||||
-v "$(pwd)/certificates:/certificates" \
|
||||
-v "$(pwd)/logs:/logs" \
|
||||
-v "$(pwd)/secrets:/secrets" \
|
||||
aliasvault-allinone:test
|
||||
|
||||
- name: Wait for services to be ready
|
||||
run: |
|
||||
echo "Waiting for services to initialize..."
|
||||
for i in {1..60}; do
|
||||
if docker exec aliasvault-test curl -f http://localhost:3001/api 2>/dev/null; then
|
||||
echo "✅ API service is ready"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for services... ($i/60)"
|
||||
sleep 5
|
||||
done
|
||||
|
||||
- name: Check container logs if needed
|
||||
if: failure()
|
||||
run: docker logs aliasvault-test
|
||||
|
||||
- name: Test root endpoint
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 2
|
||||
max_attempts: 3
|
||||
command: |
|
||||
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" https://localhost:8443/)
|
||||
if [ "$http_code" -ne 200 ]; then
|
||||
echo "❌ Root endpoint (/) failed with HTTP $http_code"
|
||||
docker logs aliasvault-test
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Root endpoint (/) returned HTTP 200"
|
||||
|
||||
- name: Test API endpoint
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 2
|
||||
max_attempts: 3
|
||||
command: |
|
||||
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" https://localhost:8443/api)
|
||||
if [ "$http_code" -ne 200 ]; then
|
||||
echo "❌ API endpoint (/api) failed with HTTP $http_code"
|
||||
docker logs aliasvault-test
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ API endpoint (/api) returned HTTP 200"
|
||||
|
||||
- name: Test Admin endpoint
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 2
|
||||
max_attempts: 3
|
||||
command: |
|
||||
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" https://localhost:8443/admin/user/login)
|
||||
if [ "$http_code" -ne 200 ]; then
|
||||
echo "❌ Admin endpoint (/admin) failed with HTTP $http_code"
|
||||
docker logs aliasvault-test
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Admin endpoint (/admin) returned HTTP 200"
|
||||
|
||||
- name: Verify admin password hash file does not exist initially
|
||||
run: |
|
||||
if [ -f "./secrets/admin_password_hash" ]; then
|
||||
echo "❌ Admin password hash file should not exist initially"
|
||||
cat ./secrets/admin_password_hash
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Admin password hash file correctly does not exist initially"
|
||||
|
||||
- name: Download install script from current branch
|
||||
- name: Test admin password reset flow
|
||||
run: |
|
||||
INSTALL_SCRIPT_URL="https://raw.githubusercontent.com/$REPO_FULL_NAME/$BRANCH_NAME/install.sh"
|
||||
echo "Downloading install script from: $INSTALL_SCRIPT_URL"
|
||||
curl -f -o install.sh "$INSTALL_SCRIPT_URL"
|
||||
echo "🔧 Testing admin password reset flow..."
|
||||
|
||||
- name: Create .env file with custom SMTP port
|
||||
run: echo "SMTP_PORT=2525" > .env
|
||||
# Run the reset password script with auto-confirm
|
||||
echo "Running reset-admin-password.sh script..."
|
||||
password_output=$(docker exec aliasvault-test reset-admin-password.sh -y 2>&1)
|
||||
echo "Script output:"
|
||||
echo "$password_output"
|
||||
|
||||
- name: Set permissions and run install.sh (install)
|
||||
id: install_script
|
||||
run: |
|
||||
chmod +x install.sh
|
||||
{
|
||||
./install.sh install --verbose
|
||||
exit_code=$?
|
||||
if [ $exit_code -eq 2 ]; then
|
||||
echo "skip_remaining=true" >> $GITHUB_OUTPUT
|
||||
true
|
||||
elif [ $exit_code -ne 0 ]; then
|
||||
false
|
||||
fi
|
||||
} || {
|
||||
if [ $exit_code -eq 2 ]; then
|
||||
echo "skip_remaining=true" >> $GITHUB_OUTPUT
|
||||
true
|
||||
else
|
||||
exit $exit_code
|
||||
fi
|
||||
}
|
||||
# Extract the generated password from the output
|
||||
generated_password=$(echo "$password_output" | grep -E "^Password: " | sed 's/Password: //')
|
||||
if [ -z "$generated_password" ]; then
|
||||
echo "❌ Failed to extract generated password from script output"
|
||||
echo "Full output was:"
|
||||
echo "$password_output"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Generated password extracted: $generated_password"
|
||||
|
||||
- name: Run docker compose up
|
||||
if: ${{ !steps.install_script.outputs.skip_remaining }}
|
||||
run: docker compose -f docker-compose.yml up -d
|
||||
# Verify that the admin_password_hash file now exists in the container
|
||||
if ! docker exec aliasvault-test test -f /secrets/admin_password_hash; then
|
||||
echo "❌ Admin password hash file was not created in container"
|
||||
docker exec aliasvault-test ls -la /secrets/
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Admin password hash file created in container"
|
||||
|
||||
- name: Wait for services
|
||||
if: ${{ !steps.install_script.outputs.skip_remaining }}
|
||||
run: sleep 10
|
||||
# Verify that the admin_password_hash file exists locally (mounted volume)
|
||||
if [ ! -f "./secrets/admin_password_hash" ]; then
|
||||
echo "❌ Admin password hash file not found in local secrets folder"
|
||||
ls -la ./secrets/
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Admin password hash file exists in local secrets folder"
|
||||
|
||||
- name: Test WASM App
|
||||
if: ${{ !steps.install_script.outputs.skip_remaining }}
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 2
|
||||
max_attempts: 3
|
||||
command: |
|
||||
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" https://localhost:443)
|
||||
if [ "$http_code" -ne 200 ]; then
|
||||
echo "WASM app failed with $http_code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Test WebApi
|
||||
if: ${{ !steps.install_script.outputs.skip_remaining }}
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 2
|
||||
max_attempts: 3
|
||||
command: |
|
||||
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" https://localhost:443/api)
|
||||
if [ "$http_code" -ne 200 ]; then
|
||||
echo "WebApi failed with $http_code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Test Admin App
|
||||
if: ${{ !steps.install_script.outputs.skip_remaining }}
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 2
|
||||
max_attempts: 3
|
||||
command: |
|
||||
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" https://localhost:443/admin/user/login)
|
||||
if [ "$http_code" -ne 200 ]; then
|
||||
echo "Admin app failed with $http_code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Test SMTP
|
||||
if: ${{ !steps.install_script.outputs.skip_remaining }}
|
||||
- name: Test SMTP port
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 2
|
||||
max_attempts: 3
|
||||
command: |
|
||||
if ! nc -zv localhost 2525 2>&1 | grep -q 'succeeded'; then
|
||||
echo "SMTP failed"
|
||||
echo "❌ SMTP port 2525 is not accessible"
|
||||
docker logs aliasvault-test
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ SMTP port 2525 is accessible"
|
||||
|
||||
- name: Test reset-admin-password output
|
||||
if: ${{ !steps.install_script.outputs.skip_remaining }}
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
output=$(./install.sh reset-admin-password)
|
||||
if ! echo "$output" | grep -E '.*New admin password: [A-Za-z0-9+/=]{8,}.*'; then
|
||||
echo "Invalid reset-admin-password output"
|
||||
exit 1
|
||||
fi
|
||||
docker stop aliasvault-test || true
|
||||
docker rm aliasvault-test || true
|
||||
|
||||
docker-compose-build:
|
||||
name: Docker Compose Build Test
|
||||
@@ -143,6 +171,21 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Check local docker-compose.yml for :latest tags
|
||||
run: |
|
||||
# Check for explicit version tags instead of :latest
|
||||
if grep -E "ghcr\.io/lanedirt/aliasvault-[^:]+:[0-9]+\.[0-9]+\.[0-9]+" docker-compose.yml; then
|
||||
echo "❌ Error: docker-compose.yml contains explicit version tags instead of :latest"
|
||||
echo "Found the following explicit versions:"
|
||||
grep -E "ghcr\.io/lanedirt/aliasvault-[^:]+:[0-9]+\.[0-9]+\.[0-9]+" docker-compose.yml
|
||||
echo ""
|
||||
echo "All AliasVault images in docker-compose.yml must use ':latest' tags, not explicit versions."
|
||||
echo "Please update docker-compose.yml to use ':latest' for all AliasVault images."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ docker-compose.yml correctly uses :latest tags for all AliasVault images"
|
||||
|
||||
- name: Create .env file with custom SMTP port
|
||||
run: echo "SMTP_PORT=2525" > .env
|
||||
|
||||
@@ -197,9 +240,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Test reset-admin-password output
|
||||
if: ${{ !steps.install_script.outputs.skip_remaining }}
|
||||
run: |
|
||||
output=$(./install.sh reset-admin-password)
|
||||
if ! echo "$output" | grep -E '.*New admin password: [A-Za-z0-9+/=]{8,}.*'; then
|
||||
output=$(./install.sh reset-admin-password | sed 's/\x1b\[[0-9;]*m//g')
|
||||
if ! echo "$output" | grep -Eq '^\s*Password: [A-Za-z0-9+/=]{8,}'; then
|
||||
echo "Invalid reset-admin-password output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
118
.github/workflows/mobile-app-build.yml
vendored
118
.github/workflows/mobile-app-build.yml
vendored
@@ -6,18 +6,33 @@ on:
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_android_signed:
|
||||
description: 'Build signed Android APK/AAB'
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
build_ios_signed:
|
||||
description: 'Build signed iOS IPA'
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
upload_to_app_store_connect:
|
||||
description: 'Upload iOS IPA to App Store Connect'
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-react-native-app:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/mobile-app
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -38,8 +53,10 @@ jobs:
|
||||
run: |
|
||||
# Check if files exist and were recently modified
|
||||
TARGET_DIRS=(
|
||||
"utils/shared/identity-generator"
|
||||
"utils/shared/password-generator"
|
||||
"utils/dist/shared/identity-generator"
|
||||
"utils/dist/shared/password-generator"
|
||||
"utils/dist/shared/models"
|
||||
"utils/dist/shared/vault-sql"
|
||||
)
|
||||
|
||||
for dir in "${TARGET_DIRS[@]}"; do
|
||||
@@ -48,15 +65,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for required files
|
||||
REQUIRED_FILES=("index.js" "index.mjs" "index.d.ts" "index.js.map" "index.mjs.map")
|
||||
for file in "${REQUIRED_FILES[@]}"; do
|
||||
if [ ! -f "$dir/$file" ]; then
|
||||
echo "❌ Required file $dir/$file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check if files were modified in the last 5 minutes
|
||||
find "$dir" -type f -mmin -5 | grep -q . || {
|
||||
echo "❌ Files in $dir were not recently modified"
|
||||
@@ -69,6 +77,31 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
|
||||
build-ios:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/mobile-app
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: apps/mobile-app/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build JS bundle (iOS - Expo)
|
||||
run: |
|
||||
mkdir -p build
|
||||
@@ -77,8 +110,59 @@ jobs:
|
||||
--output-dir ./build \
|
||||
--platform ios
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
build-android:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
- name: Build Android App
|
||||
uses: ./.github/actions/build-android-app
|
||||
with:
|
||||
run_tests: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
||||
build-android-signed:
|
||||
needs: setup
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.build_android_signed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build Android App
|
||||
uses: ./.github/actions/build-android-app
|
||||
with:
|
||||
signed: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
||||
build-ios-signed:
|
||||
needs: setup
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.build_ios_signed == 'true'
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build iOS App
|
||||
uses: ./.github/actions/build-ios-app
|
||||
with:
|
||||
signed: true
|
||||
upload_to_app_store_connect: ${{ github.event.inputs.upload_to_app_store_connect }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ASC_PRIVATE_KEY_BASE64: ${{ secrets.ASC_PRIVATE_KEY_BASE64 }}
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_TEAM_ID: ${{ secrets.ASC_TEAM_ID }}
|
||||
215
.github/workflows/release.yml
vendored
215
.github/workflows/release.yml
vendored
@@ -4,6 +4,27 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_browser_extensions:
|
||||
description: 'Build browser extensions'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
build_mobile_apps:
|
||||
description: 'Build mobile apps'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
build_multi_container:
|
||||
description: 'Build and push multi-container images'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
build_all_in_one:
|
||||
description: 'Build and push all-in-one image'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
@@ -19,47 +40,78 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Upload install.sh to release
|
||||
if: github.event_name == 'release'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: install.sh
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
package-browser-extensions:
|
||||
build-chrome-extension:
|
||||
if: github.event_name == 'release' || inputs.build_browser_extensions
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/browser-extension
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Build Chrome Extension
|
||||
uses: ./.github/actions/build-browser-extension
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: apps/browser-extension/package-lock.json
|
||||
browser: chrome
|
||||
upload_to_release: ${{ github.event_name == 'release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
build-firefox-extension:
|
||||
if: github.event_name == 'release' || inputs.build_browser_extensions
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Zip extensions
|
||||
run: |
|
||||
npm run zip:chrome
|
||||
npm run zip:firefox
|
||||
npm run zip:edge
|
||||
|
||||
- name: Upload extensions to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
- name: Build Firefox Extension
|
||||
uses: ./.github/actions/build-browser-extension
|
||||
with:
|
||||
files: |
|
||||
apps/browser-extension/dist/aliasvault-browser-extension-*-chrome.zip
|
||||
apps/browser-extension/dist/aliasvault-browser-extension-*-firefox.zip
|
||||
apps/browser-extension/dist/aliasvault-browser-extension-*-edge.zip
|
||||
apps/browser-extension/dist/aliasvault-browser-extension-*-sources.zip
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
browser: firefox
|
||||
upload_to_release: ${{ github.event_name == 'release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-and-push-docker:
|
||||
needs: [upload-install-script, package-browser-extensions]
|
||||
build-edge-extension:
|
||||
if: github.event_name == 'release' || inputs.build_browser_extensions
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build Edge Extension
|
||||
uses: ./.github/actions/build-browser-extension
|
||||
with:
|
||||
browser: edge
|
||||
upload_to_release: ${{ github.event_name == 'release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-android-release:
|
||||
if: github.event_name == 'release' || inputs.build_mobile_apps
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build Android App
|
||||
uses: ./.github/actions/build-android-app
|
||||
with:
|
||||
signed: true
|
||||
upload_to_release: ${{ github.event_name == 'release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
||||
build-and-push-docker-multi-container:
|
||||
if: github.event_name == 'release' || inputs.build_multi_container
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -86,11 +138,32 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
- name: Extract metadata for multi-container images
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
|
||||
type=ref,event=branch,enable=${{ github.ref_type == 'branch' }}
|
||||
type=raw,value={{branch}}-{{sha}},enable=${{ github.ref_type == 'branch' }}
|
||||
|
||||
- name: Generate tags for containers
|
||||
id: tags
|
||||
run: |
|
||||
# Transform base tags to include suffixes for each container
|
||||
TAGS="${{ steps.meta.outputs.tags }}"
|
||||
|
||||
# Generate tags for each container by replacing the base image name with suffixed versions
|
||||
echo "postgres=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-postgres|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
|
||||
echo "api=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-api|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
|
||||
echo "client=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-client|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
|
||||
echo "admin=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-admin|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
|
||||
echo "reverse-proxy=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-reverse-proxy|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
|
||||
echo "smtp=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-smtp|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
|
||||
echo "task-runner=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-task-runner|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
|
||||
echo "installcli=$(echo "$TAGS" | sed "s|${{ env.REPO_LOWER }}|${{ env.REPO_LOWER }}-installcli|g" | tr '\n' ',')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push Postgres image
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -99,7 +172,8 @@ jobs:
|
||||
file: apps/server/Databases/AliasServerDb/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-postgres:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-postgres:${{ github.ref_name }}
|
||||
tags: ${{ steps.tags.outputs.postgres }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Build and push API image
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -108,7 +182,8 @@ jobs:
|
||||
file: apps/server/AliasVault.Api/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-api:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-api:${{ github.ref_name }}
|
||||
tags: ${{ steps.tags.outputs.api }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Build and push Client image
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -117,7 +192,8 @@ jobs:
|
||||
file: apps/server/AliasVault.Client/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-client:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-client:${{ github.ref_name }}
|
||||
tags: ${{ steps.tags.outputs.client }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Build and push Admin image
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -126,7 +202,8 @@ jobs:
|
||||
file: apps/server/AliasVault.Admin/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-admin:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-admin:${{ github.ref_name }}
|
||||
tags: ${{ steps.tags.outputs.admin }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Build and push Reverse Proxy image
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -135,7 +212,8 @@ jobs:
|
||||
file: apps/server/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-reverse-proxy:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-reverse-proxy:${{ github.ref_name }}
|
||||
tags: ${{ steps.tags.outputs.reverse-proxy }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Build and push SMTP image
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -144,7 +222,8 @@ jobs:
|
||||
file: apps/server/Services/AliasVault.SmtpService/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-smtp:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-smtp:${{ github.ref_name }}
|
||||
tags: ${{ steps.tags.outputs.smtp }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Build and push TaskRunner image
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -153,7 +232,8 @@ jobs:
|
||||
file: apps/server/Services/AliasVault.TaskRunner/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-task-runner:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-task-runner:${{ github.ref_name }}
|
||||
tags: ${{ steps.tags.outputs.task-runner }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Build and push InstallCli image
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -162,4 +242,65 @@ jobs:
|
||||
file: apps/server/Utilities/AliasVault.InstallCli/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-installcli:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-installcli:${{ github.ref_name }}
|
||||
tags: ${{ steps.tags.outputs.installcli }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-docker-all-in-one:
|
||||
if: github.event_name == 'release' || inputs.build_all_in_one
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Convert repository name to lowercase
|
||||
run: |
|
||||
echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV}
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for all-in-one image
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ env.REPO_LOWER }}
|
||||
aliasvault/aliasvault
|
||||
tags: |
|
||||
# For release events with latest tag (only for non-prerelease)
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
|
||||
# semver tags for releases (works for prerelease and normal release)
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
|
||||
# For branches, use branch name and branch name + short SHA for uniqueness
|
||||
type=ref,event=branch,enable=${{ github.ref_type == 'branch' }}
|
||||
type=raw,value={{branch}}-{{sha}},enable=${{ github.ref_type == 'branch' }}
|
||||
|
||||
- name: Build and push all-in-one image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: dockerfiles/all-in-one/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -10,6 +10,11 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
# Cancel in-progress jobs when new commits are pushed
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and analyze
|
||||
@@ -66,9 +71,9 @@ jobs:
|
||||
run: |
|
||||
$scanner = "${{ github.workspace }}\.sonar\scanner\dotnet-sonarscanner"
|
||||
if ('${{ github.event_name }}' -eq 'pull_request_target') {
|
||||
& $scanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.pullrequest.key=${{ github.event.pull_request.number }} /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs" /d:sonar.exclusions="**/__tests__/test-forms/*.html"
|
||||
& $scanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.pullrequest.key=${{ github.event.pull_request.number }} /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs" /d:sonar.exclusions="**/__tests__/test-forms/*.html,**/dist/shared/**,**/*.sql"
|
||||
} else {
|
||||
& $scanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs" /d:sonar.exclusions="**/__tests__/test-forms/*.html"
|
||||
& $scanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs" /d:sonar.exclusions="**/__tests__/test-forms/*.html,**/dist/shared/**,**/*.sql"
|
||||
}
|
||||
dotnet build
|
||||
dotnet test -c Release /p:CollectCoverage=true /p:CoverletOutput=coverage /p:CoverletOutputFormat=opencover --filter 'FullyQualifiedName!~AliasVault.E2ETests'
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -9,7 +9,6 @@
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
*.code-workspace
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
@@ -379,6 +378,10 @@ FodyWeavers.xsd
|
||||
# Codebuddy Rider plugin
|
||||
.codebuddy
|
||||
|
||||
# Claude Code
|
||||
.claude
|
||||
CLAUDE.md
|
||||
|
||||
# -------------------
|
||||
# AliasVault specifics
|
||||
# -------------------
|
||||
@@ -403,6 +406,9 @@ certificates/**/*.pfx
|
||||
certificates/**/*.pem
|
||||
certificates/letsencrypt/**
|
||||
|
||||
# Secrets
|
||||
secrets/**
|
||||
|
||||
# Docs
|
||||
docs/_site
|
||||
docs/vendor
|
||||
@@ -414,9 +420,13 @@ database/postgres-dev
|
||||
|
||||
# Temp files
|
||||
temp
|
||||
*.zip
|
||||
|
||||
# Don't check in .js.map or .mjs.map files. These are generated by the build process in the shared
|
||||
# libraries and copied to the application so they can be used for debugging, but we don't need
|
||||
# to check them in as it's not needed for the applications to actually run.
|
||||
**/*.js.map
|
||||
**/*.mjs.map
|
||||
**/*.mjs.map
|
||||
|
||||
# Android keystore file (for publishing to Google Play)
|
||||
*.keystore
|
||||
|
||||
27
.vscode/AliasVault.code-workspace
vendored
Normal file
27
.vscode/AliasVault.code-workspace
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"name": "AliasVault",
|
||||
"path": "../"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"path": "../apps/server"
|
||||
},
|
||||
{
|
||||
"name": "browser-extension",
|
||||
"path": "../apps/browser-extension"
|
||||
},
|
||||
{
|
||||
"name": "mobile-app",
|
||||
"path": "../apps/mobile-app"
|
||||
},
|
||||
{
|
||||
"path": "../docs"
|
||||
},
|
||||
{
|
||||
"path": "../shared"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
32
.vscode/tasks.json
vendored
32
.vscode/tasks.json
vendored
@@ -43,6 +43,34 @@
|
||||
"cwd": "${workspaceFolder}/apps/server/AliasVault.Admin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Build and watch SMTP Service",
|
||||
"type": "shell",
|
||||
"command": "dotnet watch",
|
||||
"args": [],
|
||||
"problemMatcher": [],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/server/Services/AliasVault.SmtpService"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Build and watch TaskRunner",
|
||||
"type": "shell",
|
||||
"command": "dotnet watch",
|
||||
"args": [],
|
||||
"problemMatcher": [],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/server/Services/AliasVault.TaskRunner"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Build and watch Client CSS",
|
||||
"type": "shell",
|
||||
@@ -155,10 +183,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Run Android App",
|
||||
"label": "Run release Android App (device)",
|
||||
"type": "shell",
|
||||
"command": "npx",
|
||||
"args": ["expo", "run:android"],
|
||||
"args": ["expo", "run:android", "--device", "--variant", "release"],
|
||||
"problemMatcher": [],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
|
||||
@@ -1,26 +1,74 @@
|
||||
# Contributing to the source code
|
||||
We welcome contributions to AliasVault. Please read the guidelines on the official AliasVault docs website on how to get your local development environment setup and the general contribution guidelines:
|
||||
# Contributing to AliasVault
|
||||
|
||||
Thanks for your interest in contributing to the AliasVault project! There are a lot of ways to help out.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Help spread the word](#1-help-spread-the-word)
|
||||
2. [Contributing to Translations](#2-contributing-to-translations)
|
||||
3. [Contributing to the Documentation](#3-contributing-to-the-documentation)
|
||||
4. [Contributing to the Main Codebase](#4-contributing-to-the-main-codebase)
|
||||
- [4.1 Get in contact](#41-get-in-contact)
|
||||
- [4.2 Set up your local development environment](#42-set-up-your-local-development-environment)
|
||||
5. [License and Contributions](#5-license-and-contributions)
|
||||
|
||||
---
|
||||
|
||||
## 1. Help spread the word
|
||||
|
||||
Help grow the AliasVault community by:
|
||||
|
||||
- Answering questions and helping users in our [Discord](https://discord.gg/DsaXMTEtpF)
|
||||
- Reporting bugs and suggesting improvements
|
||||
- Sharing on social media and writing about your experience
|
||||
- Creating tutorials and documentation
|
||||
- Spreading the word about privacy and self-hosting
|
||||
|
||||
## 2. Contributing to Translations
|
||||
|
||||
Help make AliasVault accessible to users worldwide by contributing translations! AliasVault is currently available in English and Dutch, but we're looking for volunteers to help translate it into other languages such as German, French, Spanish, Ukrainian, Italian, and more.
|
||||
|
||||
AliasVault translations are managed through [Crowdin](https://crowdin.com/), an online translation platform. If you’d like to help translate AliasVault into your native language, please [request access to the Crowdin project](https://crowdin.com/project/aliasvault).
|
||||
|
||||
If you're willing to help, we also encourage you to get in contact via [Discord](https://discord.gg/DsaXMTEtpF) to chat (quickest), or contact us via email at [contact@support.aliasvault.net](mailto:contact@support.aliasvault.net) to discuss the language(s) you are willing to contribute to, and so we can answer any technical questions you might have.
|
||||
|
||||
Your translation contributions will help make AliasVault more accessible to privacy-conscious users around the world!
|
||||
|
||||
## 3. Contributing to the Documentation
|
||||
|
||||
The docs are built using Jekyll and automatically deploy to GitHub Pages via GitHub Actions. You can build the docs locally by running `docker compose up` in the `./docs` folder.
|
||||
|
||||
The docs site is based on the open-source template called Just The Docs. Find more information about how this template works in the [official docs](https://just-the-docs.github.io/just-the-docs/).
|
||||
|
||||
To make changes to the AliasVault documentation please make a PR that directly edits the `docs` markdown files in this repository.
|
||||
|
||||
## 4. Contributing to the Main Codebase
|
||||
|
||||
### 4.1 Get in contact
|
||||
If you're planning to work on a new feature or improvement for AliasVault, we strongly encourage you to get in touch with us first. This ensures that your proposed changes align with the project's direction and increases the likelihood of your work being accepted into the official repository. You can reach us through:
|
||||
|
||||
- Opening an issue on GitHub to discuss your proposed changes
|
||||
- Reaching out via Discord or email
|
||||
- Contacting the maintainers directly
|
||||
|
||||
### 4.2 Set up your local development environment
|
||||
You can find instructions on how to get your local development environment setup for the different parts of the AliasVault codebase here:
|
||||
|
||||
https://docs.aliasvault.net/misc/dev/
|
||||
|
||||
> Tip: if the URL above is not available, the raw doc pages can also be found in the `docs` folder in this repository.
|
||||
|
||||
## Contributing to the documentation
|
||||
The docs are built using Jekyll and automatically deploy to GitHub Pages via GitHub Actions. You can build the docs locally by running `docker compose up` in the `./docs` folder.
|
||||
If you run into any issues, feel free to join our [Discord](https://discord.gg/DsaXMTEtpF) to chat with the maintainers and author.
|
||||
|
||||
The docs site is based on the open-source template called Just The Docs. Find more information about how this template works in the [official docs](https://just-the-docs.github.io/just-the-docs/).
|
||||
## 5. License and Contributions
|
||||
|
||||
AliasVault is licensed under the GNU Affero General Public License v3.0 (AGPLv3). By submitting code, documentation, or other contributions to this project, you agree that:
|
||||
|
||||
To make changes to the AliasVault documentation please make a PR that directly edits the `docs` markdown files in this repository.
|
||||
1. Your contribution will be licensed under the same AGPLv3 license as the project
|
||||
2. You have the legal right to grant this license (e.g., you are the author, or have permission)
|
||||
3. You understand that your contribution will be made public under the AGPLv3 terms
|
||||
4. You are not expected to provide support or warranties for your contribution
|
||||
|
||||
## Contributor License Agreement (CLA)
|
||||
Thank you for your interest in contributing to AliasVault (“Project”).
|
||||
✅ There is no Contributor License Agreement (CLA) required. We believe in a balanced open source model where all contributors are treated equally under the terms of the AGPLv3.
|
||||
|
||||
By submitting code, documentation, or other contributions to this Project, you agree to the following:
|
||||
|
||||
1. You are legally entitled to grant this license (e.g., you are the author, or have permission).
|
||||
2. You grant the Project maintainers a perpetual, worldwide, non-exclusive, royalty-free license to use, modify, distribute, and sublicense your contribution as part of the Project and any derivative works.
|
||||
3. You understand that your contribution will be made public and licensed under the same terms as the Project (e.g., AGPLv3), or any later version the maintainers may release.
|
||||
4. You are not expected to provide support or warranties for your contribution.
|
||||
|
||||
> All contributors must accept the CLA as a condition of contributing. By opening a pull request, you agree to these terms. We may enforce this automatically via GitHub if needed.
|
||||
> By opening a pull request, you agree to these terms. Your contributions will be published under the AGPLv3 license.
|
||||
|
||||
43
README.md
43
README.md
@@ -1,19 +1,22 @@
|
||||
# <img src="https://github.com/user-attachments/assets/933c8b45-a190-4df6-913e-b7c64ad9938b" width="35" alt="AliasVault"> AliasVault
|
||||
End-to-end encrypted password manager with built-in alias and email generation — giving you full control over your online identity and safeguarding your privacy. AliasVault: the privacy toolbox that you control.
|
||||
AliasVault is a privacy-first password and email alias manager. Create unique identities, strong passwords, and random email aliases for every website you use. Fully end-to-end encrypted, with a built-in email server and zero third-party dependencies.
|
||||
|
||||
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github&label=Release">](https://github.com/lanedirt/AliasVault/releases)
|
||||
[](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-e2e-tests.yml)
|
||||
[<img src="https://img.shields.io/sonar/quality_gate/lanedirt_AliasVault?server=https%3A%2F%2Fsonarcloud.io&label=Sonarcloud&logo=sonarcloud">](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault)
|
||||
[<img src="https://badges.crowdin.net/aliasvault/localized.svg">](https://crowdin.com/project/aliasvault)
|
||||
[<img alt="Discord" src="https://img.shields.io/discord/1309300619026235422?logo=discord&logoColor=%237289da&label=Discord&color=%237289da">](https://discord.gg/DsaXMTEtpF)
|
||||
|
||||
<a href="https://app.aliasvault.net">Try the cloud version 🔥</a> | <a href="https://aliasvault.net?utm_source=gh-readme">Website </a> | <a href="https://docs.aliasvault.net?utm_source=gh-readme">Documentation </a> | <a href="#self-hosting">Self-host instructions</a>
|
||||
|
||||
**⭐ Star us on GitHub, it motivates us a lot!**
|
||||
|
||||
If you enjoy using AliasVault, please also consider leaving a review on our apps or browser extensions, and share it with your friends or colleagues. Your support helps others discover a privacy-first alternative to traditional & closed-source password managers.
|
||||
|
||||
## About
|
||||
AliasVault helps protect your privacy online by generating a unique password, identity, and email alias for every service you use. Everything is end-to-end encrypted and under your control — whether in the cloud or self-hosted.
|
||||
Built on 15 years of experience, AliasVault is independent, open-source, self-hostable and community-driven. It’s the response to a web that tracks everything: a way to take back control of your digital privacy and help you stay secure online.
|
||||
|
||||
Built on 15 years of experience, AliasVault is open-source, self-hostable and community-driven. It’s the response to a web that tracks everything: a way to take back control of your digital privacy and help you stay secure online.
|
||||
|
||||
– Leendert de Borst (@lanedirt), Creator of AliasVault
|
||||
– Leendert de Borst ([@lanedirt](https://github.com/lanedirt)), Creator of AliasVault
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -47,7 +50,18 @@ Built on 15 years of experience, AliasVault is open-source, self-hostable and co
|
||||
## Cloud-hosted
|
||||
Use the official cloud version of AliasVault at [app.aliasvault.net](https://app.aliasvault.net). This fully supported platform is always up to date with our latest release.
|
||||
|
||||
AliasVault is available on: [Web](https://app.aliasvault.net) | [iOS](https://apps.apple.com/app/id6745490915) | [Chrome](https://chromewebstore.google.com/detail/aliasvault/bmoggiinmnodjphdjnmpcnlleamkfedj) | [Firefox](https://addons.mozilla.org/en-US/firefox/addon/aliasvault/) | [Edge](https://microsoftedge.microsoft.com/addons/detail/aliasvault/kabaanafahnjkfkplbnllebdmppdemfo) | [Safari](https://apps.apple.com/app/id6743163173)
|
||||
AliasVault is available on:
|
||||
- [Web (universal)](https://app.aliasvault.net)
|
||||
- [Chrome](https://chromewebstore.google.com/detail/aliasvault/bmoggiinmnodjphdjnmpcnlleamkfedj)
|
||||
- [Firefox](https://addons.mozilla.org/en-US/firefox/addon/aliasvault/)
|
||||
- [Edge](https://microsoftedge.microsoft.com/addons/detail/aliasvault/kabaanafahnjkfkplbnllebdmppdemfo)
|
||||
- [Safari](https://apps.apple.com/app/id6743163173)
|
||||
|
||||
<p>
|
||||
<a href="https://apps.apple.com/app/id6745490915" style="display: inline-block; margin-right: 20px;"><img src="https://github.com/user-attachments/assets/bad09b85-2635-4e3e-b154-9f348b88f6d6" style="height: 40px;margin-right:10px;" alt="Download on the App Store"></a>
|
||||
<a href="https://play.google.com/store/apps/details?id=net.aliasvault.app" style="display: inline-block;"><img src="https://github.com/user-attachments/assets/b28979c9-f4b8-4090-8735-e384a7fdaa47" style="height: 40px;" alt="Get it on Google Play"></a>
|
||||
<a href="https://f-droid.org/packages/net.aliasvault.app" style="display: inline-block;"><img src="https://github.com/user-attachments/assets/0fb25df1-0ea2-46a6-bfee-a9d70f22a02a" style="height: 40px;" alt="Get it on F-Droid"></a>
|
||||
</p>
|
||||
|
||||
[<img width="700" alt="Screenshot of AliasVault" src="docs/assets/img/screenshot.png">](https://app.aliasvault.net)
|
||||
|
||||
@@ -58,11 +72,9 @@ For full control over your own data you can self-host and install AliasVault on
|
||||
|
||||
This method uses pre-built Docker images and works on minimal hardware specifications:
|
||||
|
||||
- Linux VM with root access (Ubuntu/AlmaLinux recommended) or Raspberry Pi
|
||||
- 1 vCPU
|
||||
- 1GB RAM
|
||||
- 16GB disk space
|
||||
- Docker installed
|
||||
- 64-bit Linux VM (Ubuntu/AlmaLinux) or Raspberry Pi, with root access
|
||||
- Minimum: 1 vCPU, 1GB RAM, 16GB disk
|
||||
- Docker ≥ 20.10 and Docker Compose ≥ 2.0
|
||||
|
||||
```bash
|
||||
# Download install script from latest stable release
|
||||
@@ -114,8 +126,10 @@ Core features that are being worked on:
|
||||
- [x] Built-in TOTP authenticator
|
||||
- [x] Import passwords from traditional password managers
|
||||
- [x] iOS native app
|
||||
- [ ] Android native app
|
||||
- [ ] Data model improvements to support reusable identities in combination with aliases
|
||||
- [x] Android native app
|
||||
- [x] Editing in browser extension
|
||||
- [x] Multi-language support across all client applications
|
||||
- [ ] Data model and usability improvements (more flexible aliases and credential types, folder support, bulk selecting etc.)
|
||||
- [ ] Support for FIDO2/WebAuthn hardware keys and passkeys
|
||||
- [ ] Adding support for family/team sharing (organization features)
|
||||
|
||||
@@ -127,5 +141,4 @@ Feel free to open an issue or join our [Discord](https://discord.gg/DsaXMTEtpF)!
|
||||
### Support the mission
|
||||
Your donation helps me dedicate more time and resources to improving AliasVault, making the internet safer for everyone!
|
||||
|
||||
<a href="https://www.buymeacoffee.com/lanedirt" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
||||
|
||||
<a href="https://www.buymeacoffee.com/lanedirt" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 50px !important;" ></a>
|
||||
|
||||
1
apps/browser-extension/.gitignore
vendored
1
apps/browser-extension/.gitignore
vendored
@@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
node_modules
|
||||
.output
|
||||
dist
|
||||
!src/utils/dist
|
||||
stats.html
|
||||
stats-*.json
|
||||
.wxt
|
||||
|
||||
@@ -12,7 +12,7 @@ export default [
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
"src/utils/shared/**",
|
||||
"src/utils/dist/**",
|
||||
]
|
||||
},
|
||||
js.configs.recommended,
|
||||
@@ -105,8 +105,57 @@ export default [
|
||||
],
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react/jsx-no-constructed-context-values": "error",
|
||||
},
|
||||
"import/no-unresolved": [
|
||||
"error",
|
||||
{
|
||||
ignore: ['^#imports$'] // Ignore virtual imports from WXT which are not resolved by the typescript compiler
|
||||
}
|
||||
],
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
"groups": [
|
||||
"builtin", // Node "fs", "path", etc.
|
||||
"external", // "react", "lodash", etc.
|
||||
"internal", // Aliased paths like "@/utils"
|
||||
"parent", // "../"
|
||||
"sibling", // "./"
|
||||
"index", // "./index"
|
||||
"object", // import 'foo'
|
||||
"type" // import type ...
|
||||
],
|
||||
"pathGroups": [
|
||||
{
|
||||
pattern: "@/entrypoints/**",
|
||||
group: "internal",
|
||||
position: "before"
|
||||
},
|
||||
{
|
||||
pattern: "@/utils/**",
|
||||
group: "internal",
|
||||
position: "before"
|
||||
},
|
||||
{
|
||||
pattern: "@/hooks/**",
|
||||
group: "internal",
|
||||
position: "before"
|
||||
}
|
||||
],
|
||||
"pathGroupsExcludedImportTypes": ["builtin"],
|
||||
"newlines-between": "always",
|
||||
"alphabetize": {
|
||||
order: "asc",
|
||||
caseInsensitive: true
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: './tsconfig.json',
|
||||
},
|
||||
},
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
|
||||
650
apps/browser-extension/package-lock.json
generated
650
apps/browser-extension/package-lock.json
generated
@@ -1,25 +1,30 @@
|
||||
{
|
||||
"name": "aliasvault-browser-extension",
|
||||
"version": "0.0.0",
|
||||
"version": "0.21.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aliasvault-browser-extension",
|
||||
"version": "0.0.0",
|
||||
"version": "0.21.2",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"argon2-browser": "^1.18.0",
|
||||
"buffer": "^6.0.3",
|
||||
"globals": "^16.0.0",
|
||||
"i18next": "^25.3.1",
|
||||
"otpauth": "^9.3.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"react-i18next": "^15.6.0",
|
||||
"react-router-dom": "^7.5.2",
|
||||
"secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0",
|
||||
"sql.js": "^1.12.0",
|
||||
"vitest": "^3.0.8",
|
||||
"webext-bridge": "^6.0.1"
|
||||
"webext-bridge": "^6.0.1",
|
||||
"yup": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.280",
|
||||
@@ -28,12 +33,14 @@
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/sql.js": "^1.4.9",
|
||||
"@types/yup": "^0.29.14",
|
||||
"@typescript-eslint/eslint-plugin": "^8.21.0",
|
||||
"@typescript-eslint/parser": "^8.21.0",
|
||||
"@vitest/coverage-v8": "^3.0.8",
|
||||
"@wxt-dev/module-react": "^1.1.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.3",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsdoc": "^50.6.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
@@ -42,7 +49,7 @@
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.6.3",
|
||||
"vite-plugin-static-copy": "^2.2.0",
|
||||
"vite-plugin-static-copy": "^2.3.2",
|
||||
"wxt": "^0.20.6"
|
||||
}
|
||||
},
|
||||
@@ -699,6 +706,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
|
||||
"integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.0.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz",
|
||||
"integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz",
|
||||
"integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@es-joy/jsdoccomment": {
|
||||
"version": "0.49.0",
|
||||
"resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.49.0.tgz",
|
||||
@@ -1310,6 +1351,18 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.1.1.tgz",
|
||||
"integrity": "sha512-J/NVING3LMAEvexJkyTLjruSm7aOFx7QX21pzkiJfMoNG0wl5aFEjLTl7ay7IQb9EWY6AkrBy7tHL2Alijpdcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/utils": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-hook-form": "^7.55.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -1478,6 +1531,19 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz",
|
||||
"integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.3",
|
||||
"@emnapi/runtime": "^1.4.3",
|
||||
"@tybys/wasm-util": "^0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz",
|
||||
@@ -1894,6 +1960,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
|
||||
"integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -2085,6 +2168,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yup": {
|
||||
"version": "0.29.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/yup/-/yup-0.29.14.tgz",
|
||||
"integrity": "sha512-Ynb/CjHhE/Xp/4bhHmQC4U1Ox+I2OpfRYF3dnNgQqn1cHa6LK3H1wJMNPT02tSVZA6FYuXE2ITORfbnb6zBCSA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.32.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz",
|
||||
@@ -2278,6 +2368,247 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-darwin-arm64": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.7.11.tgz",
|
||||
"integrity": "sha512-i3/wlWjQJXMh1uiGtiv7k1EYvrrS3L1hdwmWJJiz1D8jWy726YFYPIxQWbEIVPVAgrfRR0XNlLrTQwq17cuCGw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-darwin-x64": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.7.11.tgz",
|
||||
"integrity": "sha512-8XXyFvc6w6kmMmi6VYchZhjd5CDcp+Lv6Cn1YmUme0ypsZ/0Kzd+9ESrWtDrWibKPTgSteDTxp75cvBOY64FQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-freebsd-x64": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.7.11.tgz",
|
||||
"integrity": "sha512-0qJBYzP8Qk24CZ05RSWDQUjdiQUeIJGfqMMzbtXgCKl/a5xa6thfC0MQkGIr55LCLd6YmMyO640ifYUa53lybQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.7.11.tgz",
|
||||
"integrity": "sha512-1sGwpgvx+WZf0GFT6vkkOm6UJ+mlsVnjw+Yv9esK71idWeRAG3bbpkf3AoY8KIqKqmnzJExi0uKxXpakQ5Pcbg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.7.11.tgz",
|
||||
"integrity": "sha512-D/1F/2lTe+XAl3ohkYj51NjniVly8sIqkA/n1aOND3ZMO418nl2JNU95iVa1/RtpzaKcWEsNTtHRogykrUflJg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.7.11.tgz",
|
||||
"integrity": "sha512-7vFWHLCCNFLEQlmwKQfVy066ohLLArZl+AV/AdmrD1/pD1FlmqM+FKbtnONnIwbHtgetFUCV/SRi1q4D49aTlw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-arm64-musl": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.7.11.tgz",
|
||||
"integrity": "sha512-tYkGIx8hjWPh4zcn17jLEHU8YMmdP2obRTGkdaB3BguGHh31VCS3ywqC4QjTODjmhhNyZYkj/1Dz/+0kKvg9YA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.7.11.tgz",
|
||||
"integrity": "sha512-6F328QIUev29vcZeRX6v6oqKxfUoGwIIAhWGD8wSysnBYFY0nivp25jdWmAb1GildbCCaQvOKEhCok7YfWkj4Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.7.11.tgz",
|
||||
"integrity": "sha512-NqhWmiGJGdzbZbeucPZIG9Iav4lyYLCarEnxAceguMx9qlpeEF7ENqYKOwB8Zqk7/CeuYMEcLYMaW2li6HyDzQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.7.11.tgz",
|
||||
"integrity": "sha512-J2RPIFKMdTrLtBdfR1cUMKl8Gcy05nlQ+bEs/6al7EdWLk9cs3tnDREHZ7mV9uGbeghpjo4i8neNZNx3PYUY9w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.7.11.tgz",
|
||||
"integrity": "sha512-bDpGRerHvvHdhun7MmFUNDpMiYcJSqWckwAVVRTJf8F+RyqYJOp/mx04PDc7DhpNPeWdnTMu91oZRMV+gGaVcQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-x64-gnu": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.7.11.tgz",
|
||||
"integrity": "sha512-G9U7bVmylzRLma3cK39RBm3guoD1HOvY4o0NS4JNm37AD0lS7/xyMt7kn0JejYyc0Im8J+rH69/dXGM9DAJcSQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-linux-x64-musl": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.7.11.tgz",
|
||||
"integrity": "sha512-7qL20SBKomekSunm7M9Fe5L93bFbn+FbHiGJbfTlp0RKhPVoJDP73vOxf1QrmJHyDPECsGWPFnKa/f8fO2FsHw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-wasm32-wasi": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.7.11.tgz",
|
||||
"integrity": "sha512-jisvIva8MidjI+B1lFRZZMfCPaCISePgTyR60wNT1MeQvIh5Ksa0G3gvI+Iqyj3jqYbvOHByenpa5eDGcSdoSg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@napi-rs/wasm-runtime": "^0.2.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.7.11.tgz",
|
||||
"integrity": "sha512-G+H5nQZ8sRZ8ebMY6mRGBBvTEzMYEcgVauLsNHpvTUavZoCCRVP1zWkCZgOju2dW3O22+8seTHniTdl1/uLz3g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.7.11.tgz",
|
||||
"integrity": "sha512-Hfy46DBfFzyv0wgR0MMOwFFib2W2+Btc8oE5h4XlPhpelnSyA6nFxkVIyTgIXYGTdFaLoZFNn62fmqx3rjEg3A==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-win32-x64-msvc": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.7.11.tgz",
|
||||
"integrity": "sha512-7L8NdsQlCJ8T106Gbz/AjzM4QKWVsoQbKpB9bMBGcIZswUuAnJMHpvbqGW3RBqLHCIwX4XZ5fxSBHEFcK2h9wA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz",
|
||||
@@ -4421,9 +4752,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -5191,6 +5522,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-import-context": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.8.tgz",
|
||||
"integrity": "sha512-bq+F7nyc65sKpZGT09dY0S0QrOnQtuDVIfyTGQ8uuvtMIF7oHp6CEP3mouN0rrnYF3Jqo6Ke0BfU/5wASZue1w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-tsconfig": "^4.10.1",
|
||||
"stable-hash-x": "^0.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint-import-context"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"unrs-resolver": "^1.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"unrs-resolver": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-import-resolver-node": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
|
||||
@@ -5213,6 +5569,41 @@
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-import-resolver-typescript": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.3.tgz",
|
||||
"integrity": "sha512-elVDn1eWKFrWlzxlWl9xMt8LltjKl161Ix50JFC50tHXI5/TRP32SNEqlJ/bo/HV+g7Rou/tlPQU2AcRtIhrOg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.1",
|
||||
"eslint-import-context": "^0.1.8",
|
||||
"get-tsconfig": "^4.10.1",
|
||||
"is-bun-module": "^2.0.0",
|
||||
"stable-hash-x": "^0.1.1",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"unrs-resolver": "^1.7.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.17.0 || >=18.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint-import-resolver-typescript"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "*",
|
||||
"eslint-plugin-import": "*",
|
||||
"eslint-plugin-import-x": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"eslint-plugin-import": {
|
||||
"optional": true
|
||||
},
|
||||
"eslint-plugin-import-x": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-module-utils": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz",
|
||||
@@ -6279,6 +6670,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.10.1",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
|
||||
"integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/giget": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/giget/-/giget-1.2.5.tgz",
|
||||
@@ -6612,6 +7016,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz",
|
||||
@@ -6677,6 +7090,46 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz",
|
||||
"integrity": "sha512-S4CPAx8LfMOnURnnJa8jFWvur+UX/LWcl6+61p9VV7SK2m0445JeBJ6tLD0D5SR0H29G4PYfWkEhivKG5p4RDg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com/i18next.html"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/i18next/node_modules/@babel/runtime": {
|
||||
"version": "7.27.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
||||
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -6911,6 +7364,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-bun-module": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz",
|
||||
"integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/is-callable": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
||||
@@ -8569,6 +9032,22 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-postinstall": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.4.tgz",
|
||||
"integrity": "sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"napi-postinstall": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/napi-postinstall"
|
||||
}
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
@@ -9702,6 +10181,12 @@
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/property-expr": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
|
||||
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proto-list": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
|
||||
@@ -10340,6 +10825,57 @@
|
||||
"react": "^19.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.57.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.57.0.tgz",
|
||||
"integrity": "sha512-RbEks3+cbvTP84l/VXGUZ+JMrKOS8ykQCRYdm5aYsxnDquL0vspsyNhGRO7pcH6hsZqWlPOjLye7rJqdtdAmlg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "15.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.0.tgz",
|
||||
"integrity": "sha512-W135dB0rDfiFmbMipC17nOhGdttO5mzH8BivY+2ybsQBbXvxWIwl3cmeH3T9d+YPBSJu/ouyJKFJTtkK7rJofw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"html-parse-stringify": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 23.2.3",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next/node_modules/@babel/runtime": {
|
||||
"version": "7.27.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
||||
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@@ -10588,6 +11124,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||
@@ -11333,6 +11879,16 @@
|
||||
"integrity": "sha512-RJbVP1HRDlUUXahJ7VMTcu9Rm1Nzw+EBpoPr94vnbD4LwR715F3CcxE2G2k45PewcaZ57pjetYa+LoSJLAASgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stable-hash-x": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.1.1.tgz",
|
||||
"integrity": "sha512-l0x1D6vhnsNUGPFVDx45eif0y6eedVC8nm5uACTrVFJFtl2mLRW17aWtVyxFCpn5t94VUPkjU8vSLwIuwwqtJQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -11833,6 +12389,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-case": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
|
||||
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-uid": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tiny-uid/-/tiny-uid-1.1.2.tgz",
|
||||
@@ -11852,9 +12414,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
|
||||
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
|
||||
"version": "0.2.14",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.4.4",
|
||||
@@ -11986,6 +12548,12 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/toposort": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
|
||||
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
@@ -12194,7 +12762,7 @@
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -12346,6 +12914,39 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
"version": "1.7.11",
|
||||
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.7.11.tgz",
|
||||
"integrity": "sha512-OhuAzBImFPjKNgZ2JwHMfGFUA6NSbRegd1+BPjC1Y0E6X9Y/vJ4zKeGmIMqmlYboj6cMNEwKI+xQisrg4J0HaQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"napi-postinstall": "^0.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/unrs-resolver"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@unrs/resolver-binding-darwin-arm64": "1.7.11",
|
||||
"@unrs/resolver-binding-darwin-x64": "1.7.11",
|
||||
"@unrs/resolver-binding-freebsd-x64": "1.7.11",
|
||||
"@unrs/resolver-binding-linux-arm-gnueabihf": "1.7.11",
|
||||
"@unrs/resolver-binding-linux-arm-musleabihf": "1.7.11",
|
||||
"@unrs/resolver-binding-linux-arm64-gnu": "1.7.11",
|
||||
"@unrs/resolver-binding-linux-arm64-musl": "1.7.11",
|
||||
"@unrs/resolver-binding-linux-ppc64-gnu": "1.7.11",
|
||||
"@unrs/resolver-binding-linux-riscv64-gnu": "1.7.11",
|
||||
"@unrs/resolver-binding-linux-riscv64-musl": "1.7.11",
|
||||
"@unrs/resolver-binding-linux-s390x-gnu": "1.7.11",
|
||||
"@unrs/resolver-binding-linux-x64-gnu": "1.7.11",
|
||||
"@unrs/resolver-binding-linux-x64-musl": "1.7.11",
|
||||
"@unrs/resolver-binding-wasm32-wasi": "1.7.11",
|
||||
"@unrs/resolver-binding-win32-arm64-msvc": "1.7.11",
|
||||
"@unrs/resolver-binding-win32-ia32-msvc": "1.7.11",
|
||||
"@unrs/resolver-binding-win32-x64-msvc": "1.7.11"
|
||||
}
|
||||
},
|
||||
"node_modules/untildify": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz",
|
||||
@@ -12559,9 +13160,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-2.3.1.tgz",
|
||||
"integrity": "sha512-EfsPcBm3ewg3UMG8RJaC0ADq6/qnUZnokXx4By4+2cAcipjT9i0Y0owIJGqmZI7d6nxk4qB1q5aXOwNuSyPdyA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-2.3.2.tgz",
|
||||
"integrity": "sha512-iwrrf+JupY4b9stBttRWzGHzZbeMjAHBhkrn67MNACXJVjEMRpCI10Q3AkxdBkl45IHaTfw/CNVevzQhP7yTwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -12674,6 +13275,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
@@ -13472,6 +14082,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yup": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz",
|
||||
"integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"property-expr": "^2.0.5",
|
||||
"tiny-case": "^1.0.3",
|
||||
"toposort": "^2.0.2",
|
||||
"type-fest": "^2.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zip-dir": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/zip-dir/-/zip-dir-2.0.0.tgz",
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
"name": "aliasvault-browser-extension",
|
||||
"description": "AliasVault Browser Extension",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.22.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:chrome": "wxt -b chrome",
|
||||
"dev:firefox": "wxt -b firefox",
|
||||
"dev:edge": "wxt -b edge",
|
||||
"dev:safari": "wxt -b safari",
|
||||
"build:chrome": "wxt build -b chrome",
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"build:edge": "wxt build -b edge",
|
||||
@@ -25,17 +26,22 @@
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"argon2-browser": "^1.18.0",
|
||||
"buffer": "^6.0.3",
|
||||
"globals": "^16.0.0",
|
||||
"i18next": "^25.3.1",
|
||||
"otpauth": "^9.3.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"react-i18next": "^15.6.0",
|
||||
"react-router-dom": "^7.5.2",
|
||||
"secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0",
|
||||
"sql.js": "^1.12.0",
|
||||
"vitest": "^3.0.8",
|
||||
"webext-bridge": "^6.0.1"
|
||||
"webext-bridge": "^6.0.1",
|
||||
"yup": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.280",
|
||||
@@ -44,12 +50,14 @@
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/sql.js": "^1.4.9",
|
||||
"@types/yup": "^0.29.14",
|
||||
"@typescript-eslint/eslint-plugin": "^8.21.0",
|
||||
"@typescript-eslint/parser": "^8.21.0",
|
||||
"@vitest/coverage-v8": "^3.0.8",
|
||||
"@wxt-dev/module-react": "^1.1.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.3",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsdoc": "^50.6.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
@@ -58,7 +66,7 @@
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.6.3",
|
||||
"vite-plugin-static-copy": "^2.2.0",
|
||||
"vite-plugin-static-copy": "^2.3.2",
|
||||
"wxt": "^0.20.6"
|
||||
}
|
||||
}
|
||||
|
||||
12
apps/browser-extension/public/offscreen.html
Normal file
12
apps/browser-extension/public/offscreen.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>AliasVault Offscreen Document</title>
|
||||
<!-- The offscreen.html is used for clipboard clearing. It is a hidden document that runs in a hidden context with access to clipboard operations. -->
|
||||
</head>
|
||||
<body>
|
||||
<textarea id="text"></textarea>
|
||||
<script src="offscreen.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
apps/browser-extension/public/offscreen.js
Normal file
37
apps/browser-extension/public/offscreen.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Offscreen document for clipboard operations.
|
||||
* This document runs in a hidden context with access to clipboard operations.
|
||||
*/
|
||||
|
||||
// Listen for messages from the service worker
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'CLEAR_CLIPBOARD') {
|
||||
clearClipboard()
|
||||
.then(() => {
|
||||
sendResponse({ success: true, message: 'Clipboard cleared successfully' });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[OFFSCREEN] Failed to clear clipboard:', error);
|
||||
sendResponse({ success: false, message: error.message });
|
||||
});
|
||||
// Return true to indicate we'll send response asynchronously
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
const textEl = document.querySelector('#text');
|
||||
|
||||
/**
|
||||
* Clear the clipboard by writing a space using execCommand.
|
||||
*/
|
||||
async function clearClipboard() {
|
||||
try {
|
||||
// Use execCommand to clear clipboard
|
||||
textEl.value = '\n';
|
||||
textEl.select();
|
||||
document.execCommand('copy');
|
||||
} catch (error) {
|
||||
console.error('[OFFSCREEN] Error clearing clipboard:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -447,7 +447,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 220001;
|
||||
DEVELOPMENT_TEAM = 8PHW4HN3F7;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -460,7 +460,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 0.22.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -479,7 +479,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = "AliasVault Extension/AliasVault_Extension.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 220001;
|
||||
DEVELOPMENT_TEAM = 8PHW4HN3F7;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -492,7 +492,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 0.22.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -515,7 +515,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
CURRENT_PROJECT_VERSION = 220001;
|
||||
DEVELOPMENT_TEAM = 8PHW4HN3F7;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -530,7 +530,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 0.17.2;
|
||||
MARKETING_VERSION = 0.22.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -554,7 +554,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = AliasVault/AliasVault.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
CURRENT_PROJECT_VERSION = 220001;
|
||||
DEVELOPMENT_TEAM = 8PHW4HN3F7;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -569,7 +569,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 0.17.2;
|
||||
MARKETING_VERSION = 0.22.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@tailwind utilities;
|
||||
|
||||
@media (max-width: 380px) {
|
||||
html, body {
|
||||
width: 350px;
|
||||
max-width: 350px;
|
||||
height: 600px;
|
||||
max-height: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { defineBackground } from '#imports';
|
||||
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 { handleCheckAuthStatus, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDefaultIdentityLanguage, handleGetDerivedKey, handleGetPasswordSettings, handleGetVault, handleStoreVault, handleSyncVault } from '@/entrypoints/background/VaultMessageHandler';
|
||||
import { handleOpenPopup, handlePopupWithCredential, handleToggleContextMenu } from '@/entrypoints/background/PopupMessageHandler';
|
||||
import { storage, browser } from '#imports';
|
||||
import { handleCheckAuthStatus, handleClearPersistedFormValues, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDefaultIdentitySettings, handleGetEncryptionKey, handleGetEncryptionKeyDerivationParams, handleGetPasswordSettings, handleGetPersistedFormValues, handleGetVault, handlePersistFormValues, handleStoreEncryptionKey, handleStoreEncryptionKeyDerivationParams, handleStoreVault, handleSyncVault, handleUploadVault } from '@/entrypoints/background/VaultMessageHandler';
|
||||
|
||||
import { GLOBAL_CONTEXT_MENU_ENABLED_KEY } from '@/utils/Constants';
|
||||
import type { EncryptionKeyDerivationParams } from '@/utils/dist/shared/models/metadata';
|
||||
|
||||
import { defineBackground, storage, browser } from '#imports';
|
||||
|
||||
export default defineBackground({
|
||||
/**
|
||||
@@ -13,26 +18,55 @@ export default defineBackground({
|
||||
async main() {
|
||||
// Listen for messages using webext-bridge
|
||||
onMessage('CHECK_AUTH_STATUS', () => handleCheckAuthStatus());
|
||||
onMessage('STORE_VAULT', ({ data }) => handleStoreVault(data));
|
||||
onMessage('SYNC_VAULT', () => handleSyncVault());
|
||||
|
||||
onMessage('GET_ENCRYPTION_KEY', () => handleGetEncryptionKey());
|
||||
onMessage('GET_ENCRYPTION_KEY_DERIVATION_PARAMS', () => handleGetEncryptionKeyDerivationParams());
|
||||
onMessage('GET_VAULT', () => handleGetVault());
|
||||
onMessage('CLEAR_VAULT', () => handleClearVault());
|
||||
onMessage('GET_CREDENTIALS', () => handleGetCredentials());
|
||||
onMessage('CREATE_IDENTITY', ({ data }) => handleCreateIdentity(data));
|
||||
|
||||
onMessage('GET_DEFAULT_EMAIL_DOMAIN', () => handleGetDefaultEmailDomain());
|
||||
onMessage('GET_DEFAULT_IDENTITY_LANGUAGE', () => handleGetDefaultIdentityLanguage());
|
||||
onMessage('GET_DEFAULT_IDENTITY_SETTINGS', () => handleGetDefaultIdentitySettings());
|
||||
onMessage('GET_PASSWORD_SETTINGS', () => handleGetPasswordSettings());
|
||||
onMessage('GET_DERIVED_KEY', () => handleGetDerivedKey());
|
||||
|
||||
onMessage('STORE_VAULT', ({ data }) => handleStoreVault(data));
|
||||
onMessage('STORE_ENCRYPTION_KEY', ({ data }) => handleStoreEncryptionKey(data as string));
|
||||
onMessage('STORE_ENCRYPTION_KEY_DERIVATION_PARAMS', ({ data }) => handleStoreEncryptionKeyDerivationParams(data as EncryptionKeyDerivationParams));
|
||||
|
||||
onMessage('CREATE_IDENTITY', ({ data }) => handleCreateIdentity(data));
|
||||
onMessage('UPLOAD_VAULT', ({ data }) => handleUploadVault(data));
|
||||
onMessage('SYNC_VAULT', () => handleSyncVault());
|
||||
|
||||
onMessage('CLEAR_VAULT', () => handleClearVault());
|
||||
|
||||
onMessage('OPEN_POPUP', () => handleOpenPopup());
|
||||
onMessage('OPEN_POPUP_WITH_CREDENTIAL', ({ data }) => handlePopupWithCredential(data));
|
||||
onMessage('TOGGLE_CONTEXT_MENU', ({ data }) => handleToggleContextMenu(data));
|
||||
|
||||
onMessage('PERSIST_FORM_VALUES', ({ data }) => handlePersistFormValues(data));
|
||||
onMessage('GET_PERSISTED_FORM_VALUES', () => handleGetPersistedFormValues());
|
||||
onMessage('CLEAR_PERSISTED_FORM_VALUES', () => handleClearPersistedFormValues());
|
||||
|
||||
// Clipboard management messages
|
||||
onMessage('CLIPBOARD_COPIED', () => handleClipboardCopied());
|
||||
onMessage('CANCEL_CLIPBOARD_CLEAR', () => handleCancelClipboardClear());
|
||||
onMessage('GET_CLIPBOARD_CLEAR_TIMEOUT', () => handleGetClipboardClearTimeout());
|
||||
onMessage('SET_CLIPBOARD_CLEAR_TIMEOUT', ({ data }) => handleSetClipboardClearTimeout(data as number));
|
||||
onMessage('GET_CLIPBOARD_COUNTDOWN_STATE', () => handleGetClipboardCountdownState());
|
||||
|
||||
// Auto-lock management messages
|
||||
onMessage('RESET_AUTO_LOCK_TIMER', () => handleResetAutoLockTimer());
|
||||
onMessage('SET_AUTO_LOCK_TIMEOUT', ({ data }) => handleSetAutoLockTimeout(data as number));
|
||||
onMessage('POPUP_HEARTBEAT', () => handlePopupHeartbeat());
|
||||
|
||||
// Handle clipboard copied from context menu
|
||||
onMessage('CLIPBOARD_COPIED_FROM_CONTEXT', () => handleClipboardCopied());
|
||||
|
||||
// Setup context menus
|
||||
const isContextMenuEnabled = await storage.getItem(GLOBAL_CONTEXT_MENU_ENABLED_KEY) ?? true;
|
||||
if (isContextMenuEnabled) {
|
||||
setupContextMenus();
|
||||
await setupContextMenus();
|
||||
}
|
||||
|
||||
|
||||
// Listen for custom commands
|
||||
try {
|
||||
browser.commands.onCommand.addListener(async (command) => {
|
||||
@@ -70,4 +104,4 @@ function getActiveElementIdentifier() : string {
|
||||
return target.id || target.name || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { storage } from 'wxt/utils/storage';
|
||||
|
||||
import { handleClearVault } from '@/entrypoints/background/VaultMessageHandler';
|
||||
|
||||
import { AUTO_LOCK_TIMEOUT_KEY } from '@/utils/Constants';
|
||||
|
||||
let autoLockTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Reset the auto-lock timer.
|
||||
*/
|
||||
export function handleResetAutoLockTimer(): void {
|
||||
resetAutoLockTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle popup heartbeat - extend auto-lock timer.
|
||||
*/
|
||||
export function handlePopupHeartbeat(): void {
|
||||
extendAutoLockTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the auto-lock timeout setting.
|
||||
*/
|
||||
export async function handleSetAutoLockTimeout(timeout: number): Promise<boolean> {
|
||||
await storage.setItem(AUTO_LOCK_TIMEOUT_KEY, timeout);
|
||||
resetAutoLockTimer();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the auto-lock timer based on current settings.
|
||||
*/
|
||||
async function resetAutoLockTimer(): Promise<void> {
|
||||
// Clear existing timer
|
||||
if (autoLockTimer) {
|
||||
clearTimeout(autoLockTimer);
|
||||
autoLockTimer = null;
|
||||
}
|
||||
|
||||
// Get timeout setting
|
||||
const timeout = await storage.getItem(AUTO_LOCK_TIMEOUT_KEY) as number ?? 0;
|
||||
|
||||
// Don't set timer if timeout is 0 (disabled) or if vault is already locked
|
||||
if (timeout === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if vault is unlocked before setting timer
|
||||
const encryptionKey = await storage.getItem('session:encryptionKey') as string | null;
|
||||
|
||||
if (!encryptionKey) {
|
||||
// Vault is already locked, don't start timer
|
||||
return;
|
||||
}
|
||||
|
||||
// Set new timer
|
||||
autoLockTimer = setTimeout(async () => {
|
||||
try {
|
||||
// Lock the vault using the existing handler
|
||||
handleClearVault();
|
||||
|
||||
console.info('[AUTO_LOCK] Vault locked due to inactivity');
|
||||
autoLockTimer = null;
|
||||
} catch (error) {
|
||||
console.error('[AUTO_LOCK] Error locking vault:', error);
|
||||
}
|
||||
}, timeout * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the auto-lock timer by the full timeout period.
|
||||
* This is called by popup heartbeats to prevent locking while popup is active.
|
||||
*/
|
||||
async function extendAutoLockTimer(): Promise<void> {
|
||||
// Get timeout setting
|
||||
const timeout = await storage.getItem(AUTO_LOCK_TIMEOUT_KEY) as number ?? 0;
|
||||
|
||||
// Don't extend timer if timeout is 0 (disabled)
|
||||
if (timeout === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if vault is unlocked
|
||||
const encryptionKey = await storage.getItem('session:encryptionKey') as string | null;
|
||||
|
||||
if (!encryptionKey) {
|
||||
// Vault is already locked, don't extend timer
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing timer and start a new one
|
||||
if (autoLockTimer) {
|
||||
clearTimeout(autoLockTimer);
|
||||
autoLockTimer = null;
|
||||
}
|
||||
|
||||
// Set new timer
|
||||
autoLockTimer = setTimeout(async () => {
|
||||
try {
|
||||
// Lock the vault using the existing handler
|
||||
handleClearVault();
|
||||
|
||||
console.info('[AUTO_LOCK] Vault locked due to inactivity');
|
||||
autoLockTimer = null;
|
||||
} catch (error) {
|
||||
console.error('[AUTO_LOCK] Error locking vault:', error);
|
||||
}
|
||||
}, timeout * 1000);
|
||||
|
||||
console.info(`[AUTO_LOCK] Timer extended (popup heartbeat)`);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { sendMessage } from 'webext-bridge/background';
|
||||
import { storage } from 'wxt/utils/storage';
|
||||
|
||||
import { CLIPBOARD_CLEAR_TIMEOUT_KEY } from '@/utils/Constants';
|
||||
|
||||
let clipboardClearTimer: NodeJS.Timeout | null = null;
|
||||
let countdownInterval: NodeJS.Timeout | null = null;
|
||||
let remainingTime = 0;
|
||||
let currentCountdownId = 0;
|
||||
let totalCountdownTime = 0;
|
||||
let countdownStartTime = 0;
|
||||
let offscreenDocumentCreated = false;
|
||||
|
||||
/**
|
||||
* Create offscreen document if it doesn't exist.
|
||||
*/
|
||||
async function createOffscreenDocument(): Promise<void> {
|
||||
if (offscreenDocumentCreated) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if chrome.offscreen API is available (Chrome 109+)
|
||||
if (!chrome.offscreen) {
|
||||
console.warn('[CLIPBOARD] Offscreen API not available, falling back to direct clipboard access');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if offscreen document already exists
|
||||
if (chrome.runtime.getContexts) {
|
||||
const existingContexts = await chrome.runtime.getContexts({
|
||||
contextTypes: ['OFFSCREEN_DOCUMENT'],
|
||||
documentUrls: [chrome.runtime.getURL('offscreen.html')]
|
||||
});
|
||||
|
||||
if (existingContexts.length > 0) {
|
||||
offscreenDocumentCreated = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create offscreen document
|
||||
await chrome.offscreen.createDocument({
|
||||
url: 'offscreen.html',
|
||||
reasons: [chrome.offscreen.Reason.CLIPBOARD],
|
||||
justification: 'Clear clipboard after timeout for security'
|
||||
});
|
||||
|
||||
offscreenDocumentCreated = true;
|
||||
} catch (error) {
|
||||
console.error('[CLIPBOARD] Failed to create offscreen document:', error);
|
||||
offscreenDocumentCreated = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear clipboard using offscreen document or fallback method.
|
||||
*/
|
||||
async function clearClipboardContent(): Promise<void> {
|
||||
if (import.meta.env.CHROME || import.meta.env.EDGE) {
|
||||
/*
|
||||
* Chrome and Edge use mv3 and do not have direct access to clipboard
|
||||
* so we use an offscreen document to clear the clipboard.
|
||||
*/
|
||||
await createOffscreenDocument();
|
||||
|
||||
// Send message to offscreen document to clear clipboard
|
||||
const response = await chrome.runtime.sendMessage({ type: 'CLEAR_CLIPBOARD' });
|
||||
|
||||
if (response?.success) {
|
||||
console.info('[CLIPBOARD] Clipboard cleared via offscreen document');
|
||||
} else {
|
||||
throw new Error(response?.message || 'Failed to clear clipboard via offscreen');
|
||||
}
|
||||
} else {
|
||||
// Firefox and Safari use mv2 and can use direct clipboard access.
|
||||
await navigator.clipboard.writeText('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clipboard copied event - starts countdown and timer to clear clipboard.
|
||||
*/
|
||||
export async function handleClipboardCopied() : Promise<void> {
|
||||
const timeout = await storage.getItem(CLIPBOARD_CLEAR_TIMEOUT_KEY) as number ?? 10;
|
||||
|
||||
// Clear any existing timer
|
||||
if (clipboardClearTimer) {
|
||||
clearTimeout(clipboardClearTimer);
|
||||
clipboardClearTimer = null;
|
||||
}
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
|
||||
// Don't set timer if timeout is 0 (disabled)
|
||||
if (timeout === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate new countdown ID
|
||||
currentCountdownId = Date.now();
|
||||
const thisCountdownId = currentCountdownId;
|
||||
countdownStartTime = Date.now();
|
||||
totalCountdownTime = timeout;
|
||||
|
||||
remainingTime = timeout;
|
||||
|
||||
// Send initial countdown immediately with ID
|
||||
sendMessage('CLIPBOARD_COUNTDOWN', { remaining: remainingTime, total: timeout, id: thisCountdownId }, 'popup').catch(() => {});
|
||||
|
||||
// Send countdown updates to popup every 100ms for smooth animation
|
||||
let elapsed = 0;
|
||||
countdownInterval = setInterval(() => {
|
||||
// Check if this countdown is still active
|
||||
if (thisCountdownId !== currentCountdownId) {
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
elapsed += 0.1;
|
||||
remainingTime = Math.max(0, timeout - elapsed);
|
||||
sendMessage('CLIPBOARD_COUNTDOWN', { remaining: remainingTime, total: timeout, id: thisCountdownId }, 'popup').catch(() => {});
|
||||
|
||||
if (elapsed >= timeout && countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Set timer to clear clipboard
|
||||
clipboardClearTimer = setTimeout(async () => {
|
||||
try {
|
||||
// Clear clipboard using offscreen document or fallback
|
||||
await clearClipboardContent();
|
||||
|
||||
// Clean up regardless of success/failure
|
||||
clipboardClearTimer = null;
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
|
||||
// Reset countdown tracking
|
||||
currentCountdownId = 0;
|
||||
countdownStartTime = 0;
|
||||
totalCountdownTime = 0;
|
||||
|
||||
sendMessage('CLIPBOARD_CLEARED', {}, 'popup').catch(() => {});
|
||||
} catch (error) {
|
||||
console.error('[CLIPBOARD] Error during clipboard clear:', error);
|
||||
|
||||
// Clean up even on error
|
||||
clipboardClearTimer = null;
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
currentCountdownId = 0;
|
||||
countdownStartTime = 0;
|
||||
totalCountdownTime = 0;
|
||||
sendMessage('CLIPBOARD_CLEARED', {}, 'popup').catch(() => {});
|
||||
}
|
||||
}, timeout * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel clipboard clear countdown and timer.
|
||||
*/
|
||||
export function handleCancelClipboardClear(): void {
|
||||
if (clipboardClearTimer) {
|
||||
clearTimeout(clipboardClearTimer);
|
||||
clipboardClearTimer = null;
|
||||
}
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
sendMessage('CLIPBOARD_COUNTDOWN_CANCELLED', {}, 'popup').catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the clipboard clear timeout setting.
|
||||
*/
|
||||
export async function handleGetClipboardClearTimeout(): Promise<number> {
|
||||
const timeout = await storage.getItem(CLIPBOARD_CLEAR_TIMEOUT_KEY) as number ?? 10;
|
||||
return timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the clipboard clear timeout setting.
|
||||
*/
|
||||
export async function handleSetClipboardClearTimeout(data: number): Promise<boolean> {
|
||||
await storage.setItem(CLIPBOARD_CLEAR_TIMEOUT_KEY, data);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current clipboard countdown state.
|
||||
*/
|
||||
export function handleGetClipboardCountdownState(): { remaining: number; total: number; id: number } | null {
|
||||
// Calculate actual remaining time based on elapsed time
|
||||
if (currentCountdownId && countdownStartTime && totalCountdownTime) {
|
||||
const elapsed = (Date.now() - countdownStartTime) / 1000;
|
||||
const actualRemaining = Math.max(0, totalCountdownTime - elapsed);
|
||||
|
||||
if (actualRemaining > 0) {
|
||||
return {
|
||||
remaining: actualRemaining,
|
||||
total: totalCountdownTime,
|
||||
id: currentCountdownId
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { sendMessage } from 'webext-bridge/background';
|
||||
import { browser } from "#imports";
|
||||
import { type Browser } from '@wxt-dev/browser';
|
||||
import { PasswordGenerator } from '@/utils/shared/password-generator';
|
||||
import { sendMessage } from 'webext-bridge/background';
|
||||
|
||||
import { PasswordGenerator } from '@/utils/dist/shared/password-generator';
|
||||
|
||||
import { t } from '@/i18n/StandaloneI18n';
|
||||
|
||||
import { browser } from "#imports";
|
||||
|
||||
/**
|
||||
* Setup the context menus.
|
||||
*/
|
||||
export function setupContextMenus() : void {
|
||||
export async function setupContextMenus() : Promise<void> {
|
||||
// Create root menu
|
||||
browser.contextMenus.create({
|
||||
id: "aliasvault-root",
|
||||
@@ -18,7 +22,7 @@ export function setupContextMenus() : void {
|
||||
browser.contextMenus.create({
|
||||
id: "aliasvault-activate-form",
|
||||
parentId: "aliasvault-root",
|
||||
title: "Autofill with AliasVault",
|
||||
title: await t('content.autofillWithAliasVault'),
|
||||
contexts: ["editable"],
|
||||
});
|
||||
|
||||
@@ -34,7 +38,7 @@ export function setupContextMenus() : void {
|
||||
browser.contextMenus.create({
|
||||
id: "aliasvault-generate-password",
|
||||
parentId: "aliasvault-root",
|
||||
title: "Generate random password (copy to clipboard)",
|
||||
title: await t('content.generateRandomPassword'),
|
||||
contexts: ["all"]
|
||||
});
|
||||
|
||||
@@ -54,15 +58,16 @@ export function handleContextMenuClick(info: Browser.contextMenus.OnClickData, t
|
||||
|
||||
// Use browser.scripting to write password to clipboard from active tab
|
||||
if (tab?.id) {
|
||||
browser.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: copyPasswordToClipboard,
|
||||
args: [password]
|
||||
// Get confirm text translation.
|
||||
t('content.passwordCopiedToClipboard').then((message) => {
|
||||
browser.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: copyPasswordToClipboard,
|
||||
args: [message, password]
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (info.menuItemId === "aliasvault-activate-form" && tab?.id) {
|
||||
} else if (info.menuItemId === "aliasvault-activate-form" && tab?.id) {
|
||||
// First get the active element's identifier
|
||||
browser.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
@@ -80,9 +85,9 @@ export function handleContextMenuClick(info: Browser.contextMenus.OnClickData, t
|
||||
/**
|
||||
* Copy provided password to clipboard.
|
||||
*/
|
||||
function copyPasswordToClipboard(generatedPassword: string) : void {
|
||||
function copyPasswordToClipboard(message: string, generatedPassword: string) : void {
|
||||
navigator.clipboard.writeText(generatedPassword).then(() => {
|
||||
showToast('Password copied to clipboard');
|
||||
showToast(message);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { browser } from '#imports';
|
||||
import { setupContextMenus } from '@/entrypoints/background/ContextMenu';
|
||||
|
||||
import { BoolResponse } from '@/utils/types/messaging/BoolResponse';
|
||||
import { setupContextMenus } from './ContextMenu';
|
||||
|
||||
import { browser } from '#imports';
|
||||
|
||||
/**
|
||||
* Handle opening the popup.
|
||||
@@ -43,7 +45,7 @@ export function handleToggleContextMenu(message: any) : Promise<BoolResponse> {
|
||||
if (!message.enabled) {
|
||||
browser.contextMenus.removeAll();
|
||||
} else {
|
||||
setupContextMenus();
|
||||
await setupContextMenus();
|
||||
}
|
||||
return { success: true };
|
||||
})();
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { storage } from 'wxt/utils/storage';
|
||||
|
||||
import type { EncryptionKeyDerivationParams } from '@/utils/dist/shared/models/metadata';
|
||||
import type { Vault, VaultResponse, VaultPostResponse } from '@/utils/dist/shared/models/webapi';
|
||||
import { EncryptionUtility } from '@/utils/EncryptionUtility';
|
||||
import { SqliteClient } from '@/utils/SqliteClient';
|
||||
import { WebApiService } from '@/utils/WebApiService';
|
||||
import { Vault } from '@/utils/types/webapi/Vault';
|
||||
import { VaultResponse } from '@/utils/types/webapi/VaultResponse';
|
||||
import { VaultPostResponse } from '@/utils/types/webapi/VaultPostResponse';
|
||||
import { BoolResponse as messageBoolResponse } from '@/utils/types/messaging/BoolResponse';
|
||||
import { VaultResponse as messageVaultResponse } from '@/utils/types/messaging/VaultResponse';
|
||||
import { CredentialsResponse as messageCredentialsResponse } from '@/utils/types/messaging/CredentialsResponse';
|
||||
import { StringResponse as stringResponse } from '@/utils/types/messaging/StringResponse';
|
||||
import { IdentitySettingsResponse } from '@/utils/types/messaging/IdentitySettingsResponse';
|
||||
import { PasswordSettingsResponse as messagePasswordSettingsResponse } from '@/utils/types/messaging/PasswordSettingsResponse';
|
||||
import { StoreVaultRequest } from '@/utils/types/messaging/StoreVaultRequest';
|
||||
import { StringResponse as stringResponse } from '@/utils/types/messaging/StringResponse';
|
||||
import { VaultResponse as messageVaultResponse } from '@/utils/types/messaging/VaultResponse';
|
||||
import { VaultUploadResponse as messageVaultUploadResponse } from '@/utils/types/messaging/VaultUploadResponse';
|
||||
import { WebApiService } from '@/utils/WebApiService';
|
||||
|
||||
import { t } from '@/i18n/StandaloneI18n';
|
||||
|
||||
/**
|
||||
* Check if the user is logged in and if the vault is locked.
|
||||
* Check if the user is logged in and if the vault is locked, and also check for pending migrations.
|
||||
*/
|
||||
export async function handleCheckAuthStatus() : Promise<{ isLoggedIn: boolean, isVaultLocked: boolean }> {
|
||||
export async function handleCheckAuthStatus() : Promise<{ isLoggedIn: boolean, isVaultLocked: boolean, hasPendingMigrations: boolean, error?: string }> {
|
||||
const username = await storage.getItem('local:username');
|
||||
const accessToken = await storage.getItem('local:accessToken');
|
||||
const vaultData = await storage.getItem('session:encryptedVault');
|
||||
@@ -23,10 +28,42 @@ export async function handleCheckAuthStatus() : Promise<{ isLoggedIn: boolean, i
|
||||
const isLoggedIn = username !== null && accessToken !== null;
|
||||
const isVaultLocked = isLoggedIn && vaultData === null;
|
||||
|
||||
return {
|
||||
isLoggedIn,
|
||||
isVaultLocked
|
||||
};
|
||||
// If vault is locked, we can't check for pending migrations
|
||||
if (isVaultLocked) {
|
||||
return {
|
||||
isLoggedIn,
|
||||
isVaultLocked,
|
||||
hasPendingMigrations: false
|
||||
};
|
||||
}
|
||||
|
||||
// If not logged in, no need to check migrations
|
||||
if (!isLoggedIn) {
|
||||
return {
|
||||
isLoggedIn,
|
||||
isVaultLocked,
|
||||
hasPendingMigrations: false
|
||||
};
|
||||
}
|
||||
|
||||
// Vault is unlocked, check for pending migrations
|
||||
try {
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
const hasPendingMigrations = await sqliteClient.hasPendingMigrations();
|
||||
return {
|
||||
isLoggedIn,
|
||||
isVaultLocked,
|
||||
hasPendingMigrations
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking pending migrations:', error);
|
||||
return {
|
||||
isLoggedIn,
|
||||
isVaultLocked,
|
||||
hasPendingMigrations: false,
|
||||
error: error instanceof Error ? error.message : await t('common.errors.unknownError')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,22 +73,62 @@ export async function handleStoreVault(
|
||||
message: any,
|
||||
) : Promise<messageBoolResponse> {
|
||||
try {
|
||||
const vaultResponse = message.vaultResponse as VaultResponse;
|
||||
const encryptedVaultBlob = vaultResponse.vault.blob;
|
||||
const vaultRequest = message as StoreVaultRequest;
|
||||
|
||||
// Store encrypted vault and derived key in session storage.
|
||||
await storage.setItems([
|
||||
{ key: 'session:encryptedVault', value: encryptedVaultBlob },
|
||||
{ key: 'session:derivedKey', value: message.derivedKey },
|
||||
{ key: 'session:publicEmailDomains', value: vaultResponse.vault.publicEmailDomainList },
|
||||
{ key: 'session:privateEmailDomains', value: vaultResponse.vault.privateEmailDomainList },
|
||||
{ key: 'session:vaultRevisionNumber', value: vaultResponse.vault.currentRevisionNumber }
|
||||
]);
|
||||
// Store new encrypted vault in session storage.
|
||||
await storage.setItem('session:encryptedVault', vaultRequest.vaultBlob);
|
||||
|
||||
/*
|
||||
* For all other values, check if they have a value and store them in session storage if they do.
|
||||
* Some updates, e.g. when mutating local database, these values will not be set.
|
||||
*/
|
||||
|
||||
if (vaultRequest.publicEmailDomainList) {
|
||||
await storage.setItem('session:publicEmailDomains', vaultRequest.publicEmailDomainList);
|
||||
}
|
||||
|
||||
if (vaultRequest.privateEmailDomainList) {
|
||||
await storage.setItem('session:privateEmailDomains', vaultRequest.privateEmailDomainList);
|
||||
}
|
||||
|
||||
if (vaultRequest.vaultRevisionNumber) {
|
||||
await storage.setItem('session:vaultRevisionNumber', vaultRequest.vaultRevisionNumber);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to store vault:', error);
|
||||
return { success: false, error: 'Failed to store vault' };
|
||||
return { success: false, error: await t('common.errors.failedToStoreVault') };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the encryption key (derived key) in browser storage.
|
||||
*/
|
||||
export async function handleStoreEncryptionKey(
|
||||
encryptionKey: string,
|
||||
) : Promise<messageBoolResponse> {
|
||||
try {
|
||||
await storage.setItem('session:encryptionKey', encryptionKey);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to store encryption key:', error);
|
||||
return { success: false, error: await t('common.errors.failedToStoreEncryptionKey') };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the encryption key derivation parameters in browser storage.
|
||||
*/
|
||||
export async function handleStoreEncryptionKeyDerivationParams(
|
||||
params: EncryptionKeyDerivationParams,
|
||||
) : Promise<messageBoolResponse> {
|
||||
try {
|
||||
await storage.setItem('session:encryptionKeyDerivationParams', params);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to store encryption key derivation params:', error);
|
||||
return { success: false, error: await t('common.errors.failedToStoreEncryptionParams') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +141,7 @@ export async function handleSyncVault(
|
||||
const statusResponse = await webApi.getStatus();
|
||||
const statusError = webApi.validateStatusResponse(statusResponse);
|
||||
if (statusError !== null) {
|
||||
return { success: false, error: statusError };
|
||||
return { success: false, error: await t('common.errors.' + statusError) };
|
||||
}
|
||||
|
||||
const vaultRevisionNumber = await storage.getItem('session:vaultRevisionNumber') as number;
|
||||
@@ -90,20 +167,26 @@ export async function handleSyncVault(
|
||||
export async function handleGetVault(
|
||||
) : Promise<messageVaultResponse> {
|
||||
try {
|
||||
const encryptionKey = await handleGetEncryptionKey();
|
||||
|
||||
const encryptedVault = await storage.getItem('session:encryptedVault') as string;
|
||||
const derivedKey = await storage.getItem('session:derivedKey') as string;
|
||||
const publicEmailDomains = await storage.getItem('session:publicEmailDomains') as string[];
|
||||
const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[];
|
||||
const vaultRevisionNumber = await storage.getItem('session:vaultRevisionNumber') as number;
|
||||
|
||||
if (!encryptedVault) {
|
||||
console.error('Vault not available');
|
||||
return { success: false, error: 'Vault not available' };
|
||||
return { success: false, error: await t('common.errors.vaultNotAvailable') };
|
||||
}
|
||||
|
||||
if (!encryptionKey) {
|
||||
console.error('Encryption key not available');
|
||||
return { success: false, error: await t('common.errors.vaultIsLocked') };
|
||||
}
|
||||
|
||||
const decryptedVault = await EncryptionUtility.symmetricDecrypt(
|
||||
encryptedVault,
|
||||
derivedKey
|
||||
encryptionKey
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -115,7 +198,7 @@ export async function handleGetVault(
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get vault:', error);
|
||||
return { success: false, error: 'Failed to get vault' };
|
||||
return { success: false, error: await t('common.errors.failedToRetrieveData') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +209,10 @@ export function handleClearVault(
|
||||
) : messageBoolResponse {
|
||||
storage.removeItems([
|
||||
'session:encryptedVault',
|
||||
'session:encryptionKey',
|
||||
// TODO: the derivedKey clear can be removed some period of time after 0.22.0 is released.
|
||||
'session:derivedKey',
|
||||
'session:encryptionKeyDerivationParams',
|
||||
'session:publicEmailDomains',
|
||||
'session:privateEmailDomains',
|
||||
'session:vaultRevisionNumber'
|
||||
@@ -140,10 +226,10 @@ export function handleClearVault(
|
||||
*/
|
||||
export async function handleGetCredentials(
|
||||
) : Promise<messageCredentialsResponse> {
|
||||
const derivedKey = await storage.getItem('session:derivedKey') as string;
|
||||
const encryptionKey = await handleGetEncryptionKey();
|
||||
|
||||
if (!derivedKey) {
|
||||
return { success: false, error: 'Vault is locked' };
|
||||
if (!encryptionKey) {
|
||||
return { success: false, error: await t('common.errors.vaultIsLocked') };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -152,7 +238,7 @@ export async function handleGetCredentials(
|
||||
return { success: true, credentials: credentials };
|
||||
} catch (error) {
|
||||
console.error('Error getting credentials:', error);
|
||||
return { success: false, error: 'Failed to get credentials' };
|
||||
return { success: false, error: await t('common.errors.failedToRetrieveData') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,17 +248,17 @@ export async function handleGetCredentials(
|
||||
export async function handleCreateIdentity(
|
||||
message: any,
|
||||
) : Promise<messageBoolResponse> {
|
||||
const derivedKey = await storage.getItem('session:derivedKey') as string;
|
||||
const encryptionKey = await handleGetEncryptionKey();
|
||||
|
||||
if (!derivedKey) {
|
||||
return { success: false, error: 'Vault is locked' };
|
||||
if (!encryptionKey) {
|
||||
return { success: false, error: await t('common.errors.vaultIsLocked') };
|
||||
}
|
||||
|
||||
try {
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
|
||||
// Add the new credential to the vault/database.
|
||||
sqliteClient.createCredential(message.credential);
|
||||
await sqliteClient.createCredential(message.credential, message.attachments || []);
|
||||
|
||||
// Upload the new vault to the server.
|
||||
await uploadNewVaultToServer(sqliteClient);
|
||||
@@ -180,7 +266,7 @@ export async function handleCreateIdentity(
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to create identity:', error);
|
||||
return { success: false, error: 'Failed to create identity' };
|
||||
return { success: false, error: await t('common.errors.unknownError') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,68 +296,43 @@ export async function getEmailAddressesForVault(
|
||||
/**
|
||||
* Get default email domain for a vault.
|
||||
*/
|
||||
export function handleGetDefaultEmailDomain(
|
||||
) : Promise<stringResponse> {
|
||||
return (async () : Promise<stringResponse> => {
|
||||
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();
|
||||
const defaultEmailDomain = sqliteClient.getDefaultEmailDomain(privateEmailDomains, publicEmailDomains);
|
||||
|
||||
/**
|
||||
* Check if a domain is valid.
|
||||
*/
|
||||
const isValidDomain = (domain: string) : boolean => {
|
||||
const isValid = (domain &&
|
||||
domain !== 'DISABLED.TLD' &&
|
||||
(privateEmailDomains.includes(domain) || publicEmailDomains.includes(domain))) as boolean;
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
// First check if the default domain that is configured in the vault is still valid.
|
||||
if (defaultEmailDomain && isValidDomain(defaultEmailDomain)) {
|
||||
return { success: true, value: defaultEmailDomain };
|
||||
}
|
||||
|
||||
// If default domain is not valid, fall back to first available private domain.
|
||||
const firstPrivate = privateEmailDomains.find(isValidDomain);
|
||||
|
||||
if (firstPrivate) {
|
||||
return { success: true, value: firstPrivate };
|
||||
}
|
||||
|
||||
// Return first valid public domain if no private domains are available.
|
||||
const firstPublic = publicEmailDomains.find(isValidDomain);
|
||||
|
||||
if (firstPublic) {
|
||||
return { success: true, value: firstPublic };
|
||||
}
|
||||
|
||||
// Return null if no valid domains are found
|
||||
return { success: true };
|
||||
return { success: true, value: defaultEmailDomain ?? undefined };
|
||||
} catch (error) {
|
||||
console.error('Error getting default email domain:', error);
|
||||
return { success: false, error: 'Failed to get default email domain' };
|
||||
return { success: false, error: await t('common.errors.failedToRetrieveData') };
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default identity language.
|
||||
* Get the default identity settings.
|
||||
*/
|
||||
export async function handleGetDefaultIdentityLanguage(
|
||||
) : Promise<stringResponse> {
|
||||
export async function handleGetDefaultIdentitySettings(
|
||||
) : Promise<IdentitySettingsResponse> {
|
||||
try {
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
const settingValue = sqliteClient.getDefaultIdentityLanguage();
|
||||
const language = sqliteClient.getDefaultIdentityLanguage();
|
||||
const gender = sqliteClient.getDefaultIdentityGender();
|
||||
|
||||
return { success: true, value: settingValue };
|
||||
return {
|
||||
success: true,
|
||||
settings: {
|
||||
language,
|
||||
gender
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting default identity language:', error);
|
||||
return { success: false, error: 'Failed to get default identity language' };
|
||||
console.error('Error getting default identity settings:', error);
|
||||
return { success: false, error: await t('common.errors.failedToRetrieveData') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,29 +348,122 @@ export async function handleGetPasswordSettings(
|
||||
return { success: true, settings: passwordSettings };
|
||||
} catch (error) {
|
||||
console.error('Error getting password settings:', error);
|
||||
return { success: false, error: 'Failed to get password settings' };
|
||||
return { success: false, error: await t('common.errors.failedToRetrieveData') };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the derived key for the encrypted vault.
|
||||
* Get the encryption key for the encrypted vault.
|
||||
*/
|
||||
export async function handleGetDerivedKey(
|
||||
) : Promise<string> {
|
||||
const derivedKey = await storage.getItem('session:derivedKey') as string;
|
||||
return derivedKey;
|
||||
export async function handleGetEncryptionKey(
|
||||
) : Promise<string | null> {
|
||||
// Try the current key name first (since 0.22.0)
|
||||
let encryptionKey = await storage.getItem('session:encryptionKey') as string | null;
|
||||
|
||||
// Fall back to the legacy key name if not found
|
||||
if (!encryptionKey) {
|
||||
// TODO: this check can be removed some period of time after 0.22.0 is released.
|
||||
encryptionKey = await storage.getItem('session:derivedKey') as string | null;
|
||||
}
|
||||
|
||||
return encryptionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the encryption key derivation parameters for password change detection and offline mode.
|
||||
*/
|
||||
export async function handleGetEncryptionKeyDerivationParams(
|
||||
) : Promise<EncryptionKeyDerivationParams | null> {
|
||||
const params = await storage.getItem('session:encryptionKeyDerivationParams') as EncryptionKeyDerivationParams | null;
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the vault to the server.
|
||||
*/
|
||||
export async function handleUploadVault(
|
||||
message: any
|
||||
) : Promise<messageVaultUploadResponse> {
|
||||
try {
|
||||
// Store the new vault blob in session storage.
|
||||
await storage.setItem('session:encryptedVault', message.vaultBlob);
|
||||
|
||||
// Create new sqlite client which will use the new vault blob.
|
||||
const sqliteClient = await createVaultSqliteClient();
|
||||
|
||||
// Upload the new vault to the server.
|
||||
const response = await uploadNewVaultToServer(sqliteClient);
|
||||
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') };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle persisting form values to storage.
|
||||
* Data is encrypted using the derived key for additional security.
|
||||
*/
|
||||
export async function handlePersistFormValues(data: any): Promise<void> {
|
||||
const encryptionKey = await handleGetEncryptionKey();
|
||||
if (!encryptionKey) {
|
||||
throw new Error(await t('common.errors.unknownError'));
|
||||
}
|
||||
|
||||
// Always stringify the data properly
|
||||
const serializedData = JSON.stringify(data);
|
||||
const encryptedData = await EncryptionUtility.symmetricEncrypt(
|
||||
serializedData,
|
||||
encryptionKey
|
||||
);
|
||||
await storage.setItem('session:persistedFormValues', encryptedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle retrieving persisted form values from storage.
|
||||
* Data is decrypted using the derived key.
|
||||
*/
|
||||
export async function handleGetPersistedFormValues(): Promise<any | null> {
|
||||
const encryptionKey = await handleGetEncryptionKey();
|
||||
const encryptedData = await storage.getItem('session:persistedFormValues') as string | null;
|
||||
|
||||
if (!encryptedData || !encryptionKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decryptedData = await EncryptionUtility.symmetricDecrypt(
|
||||
encryptedData,
|
||||
encryptionKey
|
||||
);
|
||||
return JSON.parse(decryptedData);
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt or parse persisted form values:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clearing persisted form values from storage.
|
||||
*/
|
||||
export async function handleClearPersistedFormValues(): Promise<void> {
|
||||
await storage.removeItem('session:persistedFormValues');
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a new version of the vault to the server using the provided sqlite client.
|
||||
*/
|
||||
async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<void> {
|
||||
async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<VaultPostResponse> {
|
||||
const updatedVaultData = sqliteClient.exportToBase64();
|
||||
const derivedKey = await storage.getItem('session:derivedKey') as string;
|
||||
const encryptionKey = await handleGetEncryptionKey();
|
||||
|
||||
if (!encryptionKey) {
|
||||
throw new Error(await t('common.errors.vaultIsLocked'));
|
||||
}
|
||||
|
||||
const encryptedVault = await EncryptionUtility.symmetricEncrypt(
|
||||
updatedVaultData,
|
||||
derivedKey
|
||||
encryptionKey
|
||||
);
|
||||
|
||||
await storage.setItems([
|
||||
@@ -335,7 +489,7 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<void
|
||||
client: '', // Empty on purpose, API will not use this for vault updates.
|
||||
updatedAt: new Date().toISOString(),
|
||||
username: username,
|
||||
version: sqliteClient.getDatabaseVersion() ?? '0.0.0'
|
||||
version: sqliteClient.getDatabaseVersion().version
|
||||
};
|
||||
|
||||
const webApi = new WebApiService(() => {});
|
||||
@@ -345,8 +499,10 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<void
|
||||
if (response.status === 0) {
|
||||
await storage.setItem('session:vaultRevisionNumber', response.newRevisionNumber);
|
||||
} else {
|
||||
throw new Error('Failed to upload new vault to server');
|
||||
throw new Error(await t('common.errors.failedToUploadVault'));
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,16 +510,15 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<void
|
||||
*/
|
||||
async function createVaultSqliteClient() : Promise<SqliteClient> {
|
||||
const encryptedVault = await storage.getItem('session:encryptedVault') as string;
|
||||
const derivedKey = await storage.getItem('session:derivedKey') as string;
|
||||
|
||||
if (!encryptedVault || !derivedKey) {
|
||||
throw new Error('No vault or derived key found');
|
||||
const encryptionKey = await handleGetEncryptionKey();
|
||||
if (!encryptedVault || !encryptionKey) {
|
||||
throw new Error(await t('common.errors.unknownError'));
|
||||
}
|
||||
|
||||
// Decrypt the vault.
|
||||
const decryptedVault = await EncryptionUtility.symmetricDecrypt(
|
||||
encryptedVault,
|
||||
derivedKey
|
||||
encryptionKey
|
||||
);
|
||||
|
||||
// Initialize the SQLite client with the decrypted vault.
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import '@/entrypoints/contentScript/style.css';
|
||||
import { FormDetector } from '@/utils/formDetector/FormDetector';
|
||||
import { isAutoShowPopupEnabled, openAutofillPopup, removeExistingPopup } from '@/entrypoints/contentScript/Popup';
|
||||
import { injectIcon, popupDebounceTimeHasPassed, validateInputField } from '@/entrypoints/contentScript/Form';
|
||||
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 { FormDetector } from '@/utils/formDetector/FormDetector';
|
||||
import { BoolResponse as messageBoolResponse } from '@/utils/types/messaging/BoolResponse';
|
||||
import { defineContentScript } from '#imports';
|
||||
import { createShadowRootUi } from '#imports';
|
||||
|
||||
import { t } from '@/i18n/StandaloneI18n';
|
||||
|
||||
import { defineContentScript, createShadowRootUi } from '#imports';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
@@ -30,6 +34,7 @@ export default defineContentScript({
|
||||
name: 'aliasvault-ui',
|
||||
position: 'inline',
|
||||
anchor: 'body',
|
||||
mode: 'closed',
|
||||
/**
|
||||
* Handle mount.
|
||||
*/
|
||||
@@ -55,13 +60,18 @@ export default defineContentScript({
|
||||
return;
|
||||
}
|
||||
|
||||
// Only show popup for autofill-triggerable fields
|
||||
if (!formDetector.isAutofillTriggerableField()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only inject icon and show popup if autofill popup is enabled
|
||||
if (await isAutoShowPopupEnabled()) {
|
||||
injectIcon(inputElement, container);
|
||||
|
||||
// Only show popup if debounce time has passed
|
||||
if (popupDebounceTimeHasPassed()) {
|
||||
openAutofillPopup(inputElement, container);
|
||||
await showPopupWithAuthCheck(inputElement, container);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,13 +127,55 @@ export default defineContentScript({
|
||||
}
|
||||
|
||||
/**
|
||||
* By default we check if the popup is not disabled (for current site)
|
||||
* By default we check if the popup is not disabled (for current site) and if the field is autofill-triggerable
|
||||
* but if forceShow is true, we show the popup regardless.
|
||||
*/
|
||||
const canShowPopup = forceShow || (await isAutoShowPopupEnabled());
|
||||
const canShowPopup = forceShow || (await isAutoShowPopupEnabled() && formDetector.isAutofillTriggerableField());
|
||||
|
||||
if (canShowPopup) {
|
||||
injectIcon(inputElement, container);
|
||||
await showPopupWithAuthCheck(inputElement, container);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show popup with auth check.
|
||||
*/
|
||||
async function showPopupWithAuthCheck(inputElement: HTMLInputElement, container: HTMLElement) : Promise<void> {
|
||||
try {
|
||||
// Check auth status and pending migrations in a single call
|
||||
const { sendMessage } = await import('webext-bridge/content-script');
|
||||
const authStatus = await sendMessage('CHECK_AUTH_STATUS', {}, 'background') as {
|
||||
isLoggedIn: boolean,
|
||||
isVaultLocked: boolean,
|
||||
hasPendingMigrations: boolean,
|
||||
error?: string
|
||||
};
|
||||
|
||||
if (authStatus.isVaultLocked) {
|
||||
// Vault is locked, show vault locked popup
|
||||
const { createVaultLockedPopup } = await import('@/entrypoints/contentScript/Popup');
|
||||
createVaultLockedPopup(inputElement, container);
|
||||
return;
|
||||
}
|
||||
|
||||
if (authStatus.hasPendingMigrations) {
|
||||
// Show upgrade required popup
|
||||
await createUpgradeRequiredPopup(inputElement, container, await t('content.vaultUpgradeRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (authStatus.error) {
|
||||
// Show upgrade required popup for version-related errors
|
||||
await createUpgradeRequiredPopup(inputElement, container, authStatus.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// No upgrade required, show normal autofill popup
|
||||
openAutofillPopup(inputElement, container);
|
||||
} catch (error) {
|
||||
console.error('Error checking vault status:', error);
|
||||
// Fall back to normal autofill popup if check fails
|
||||
openAutofillPopup(inputElement, container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,108 +1,212 @@
|
||||
import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
import { CombinedStopWords } from '@/utils/formDetector/FieldPatterns';
|
||||
import { Credential } from '@/utils/types/Credential';
|
||||
|
||||
export enum AutofillMatchingMode {
|
||||
DEFAULT = 'default',
|
||||
URL_EXACT = 'url_exact',
|
||||
URL_SUBDOMAIN = 'url_subdomain'
|
||||
}
|
||||
|
||||
type CredentialWithPriority = Credential & {
|
||||
priority: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter credentials based on current URL and page context to determine which credentials to show
|
||||
* in the autofill popup. Credentials are sorted by priority:
|
||||
* 1. Exact URL match (highest priority)
|
||||
* 2. Base URL match AND page title word match
|
||||
* 3. Base URL match only
|
||||
* 4. Page title word match only (lowest priority)
|
||||
* Extract domain from URL, handling both full URLs and partial domains
|
||||
* @param url - URL or domain string
|
||||
* @returns Normalized domain without protocol or www
|
||||
*/
|
||||
export function filterCredentials(credentials: Credential[], currentUrl: string, pageTitle: string): Credential[] {
|
||||
const urlObject = new URL(currentUrl);
|
||||
const baseUrl = `${urlObject.protocol}//${urlObject.hostname}`;
|
||||
const filtered: CredentialWithPriority[] = [];
|
||||
|
||||
const sanitizedCurrentUrl = currentUrl.toLowerCase().replace('www.', '');
|
||||
|
||||
// 1. Exact URL match (priority 1)
|
||||
credentials.forEach(cred => {
|
||||
if (!cred.ServiceUrl || cred.ServiceUrl.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedCredUrl = cred.ServiceUrl.toLowerCase().replace('www.', '');
|
||||
|
||||
if (sanitizedCurrentUrl.startsWith(sanitizedCredUrl)) {
|
||||
filtered.push({ ...cred, priority: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
// If we have one or more exact matches, do not continue to other matches
|
||||
if (filtered.length > 0) {
|
||||
return filtered;
|
||||
function extractDomain(url: string): string {
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Prepare page title words for matching
|
||||
const titleWords = pageTitle.length > 0
|
||||
? pageTitle.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(word =>
|
||||
word.length > 3 &&
|
||||
!CombinedStopWords.has(word.toLowerCase())
|
||||
)
|
||||
: [];
|
||||
// Remove protocol if present
|
||||
let domain = url.toLowerCase().trim();
|
||||
domain = domain.replace(/^https?:\/\//, '');
|
||||
|
||||
// Check for base URL matches and page title matches
|
||||
// Remove www. prefix
|
||||
domain = domain.replace(/^www\./, '');
|
||||
|
||||
// Remove path, query, and fragment
|
||||
domain = domain.split('/')[0];
|
||||
domain = domain.split('?')[0];
|
||||
domain = domain.split('#')[0];
|
||||
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two domains match, supporting partial matches
|
||||
* @param domain1 - First domain
|
||||
* @param domain2 - Second domain
|
||||
* @returns True if domains match (including partial matches)
|
||||
*/
|
||||
function domainsMatch(domain1: string, domain2: string): boolean {
|
||||
if (!domain1 || !domain2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const d1 = extractDomain(domain1);
|
||||
const d2 = extractDomain(domain2);
|
||||
|
||||
// Exact match
|
||||
if (d1 === d2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if one domain contains the other (for subdomain matching)
|
||||
if (d1.includes(d2) || d2.includes(d1)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extract root domains for comparison
|
||||
const d1Parts = d1.split('.');
|
||||
const d2Parts = d2.split('.');
|
||||
|
||||
// Get the last 2 parts (domain.tld) for comparison
|
||||
const d1Root = d1Parts.slice(-2).join('.');
|
||||
const d2Root = d2Parts.slice(-2).join('.');
|
||||
|
||||
return d1Root === d2Root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful words from text, removing punctuation and filtering stop words
|
||||
* @param text - Text to extract words from
|
||||
* @returns Array of filtered words
|
||||
*/
|
||||
function extractWords(text: string): string[] {
|
||||
if (!text || text.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return text.toLowerCase()
|
||||
// Replace common separators and punctuation with spaces
|
||||
.replace(/[|,;:\-–—/\\()[\]{}'"`~!@#$%^&*+=<>?]/g, ' ')
|
||||
// Split on whitespace and filter
|
||||
.split(/\s+/)
|
||||
.filter(word =>
|
||||
word.length > 3 &&
|
||||
!CombinedStopWords.has(word)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter credentials based on current URL and page context with anti-phishing protection.
|
||||
*
|
||||
* **Security Note**: When searching with a URL, text search fallback only applies to
|
||||
* credentials with no service URL defined. This prevents phishing attacks where a
|
||||
* malicious site might match credentials intended for the legitimate site.
|
||||
*
|
||||
* Credentials are sorted by priority:
|
||||
* 1. Exact domain match (priority 1 - highest)
|
||||
* 2. Partial/subdomain match (priority 2)
|
||||
* 3. Service name fallback match (priority 5 - lowest, only for credentials without URLs)
|
||||
*/
|
||||
export function filterCredentials(credentials: Credential[], currentUrl: string, pageTitle: string, matchingMode: AutofillMatchingMode = AutofillMatchingMode.DEFAULT): Credential[] {
|
||||
const filtered: CredentialWithPriority[] = [];
|
||||
const currentDomain = extractDomain(currentUrl);
|
||||
|
||||
// Determine feature flags based on matching mode
|
||||
let enableExactMatch = false;
|
||||
let enableSubdomainMatch = false;
|
||||
let enableServiceNameFallback = false;
|
||||
|
||||
switch (matchingMode) {
|
||||
case AutofillMatchingMode.URL_EXACT:
|
||||
enableExactMatch = true;
|
||||
enableSubdomainMatch = false;
|
||||
enableServiceNameFallback = false;
|
||||
break;
|
||||
|
||||
case AutofillMatchingMode.URL_SUBDOMAIN:
|
||||
enableExactMatch = true;
|
||||
enableSubdomainMatch = true;
|
||||
enableServiceNameFallback = false;
|
||||
break;
|
||||
|
||||
case AutofillMatchingMode.DEFAULT:
|
||||
enableExactMatch = true;
|
||||
enableSubdomainMatch = true;
|
||||
enableServiceNameFallback = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Process credentials with service URLs
|
||||
credentials.forEach(cred => {
|
||||
if (!cred.ServiceUrl || filtered.some(f => f.Id === cred.Id)) {
|
||||
if (!cred.ServiceUrl || cred.ServiceUrl.length === 0) {
|
||||
return; // Handle these in service name fallback
|
||||
}
|
||||
|
||||
const credDomain = extractDomain(cred.ServiceUrl);
|
||||
|
||||
// Check for exact match (priority 1)
|
||||
if (enableExactMatch && currentDomain === credDomain) {
|
||||
filtered.push({ ...cred, priority: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
let hasBaseUrlMatch = false;
|
||||
let hasTitleMatch = false;
|
||||
|
||||
// Check base URL match
|
||||
try {
|
||||
const credUrlObject = new URL(cred.ServiceUrl);
|
||||
const currentUrlObject = new URL(baseUrl);
|
||||
|
||||
const credDomainParts = credUrlObject.hostname.toLowerCase().split('.');
|
||||
const currentDomainParts = currentUrlObject.hostname.toLowerCase().split('.');
|
||||
|
||||
const credRootDomain = credDomainParts.slice(-2).join('.');
|
||||
const currentRootDomain = currentDomainParts.slice(-2).join('.');
|
||||
|
||||
if (credUrlObject.protocol === currentUrlObject.protocol &&
|
||||
credRootDomain === currentRootDomain) {
|
||||
hasBaseUrlMatch = true;
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL, skip
|
||||
}
|
||||
|
||||
// Check page title match
|
||||
if (titleWords.length > 0) {
|
||||
const credNameWords = cred.ServiceName.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(word => word.length > 3 && !CombinedStopWords.has(word));
|
||||
hasTitleMatch = titleWords.some(word =>
|
||||
credNameWords.some(credWord => credWord.includes(word))
|
||||
);
|
||||
}
|
||||
|
||||
// Assign priority based on matches
|
||||
if (hasBaseUrlMatch && hasTitleMatch) {
|
||||
// Check for subdomain/partial match (priority 2)
|
||||
if (enableSubdomainMatch && domainsMatch(currentDomain, credDomain)) {
|
||||
filtered.push({ ...cred, priority: 2 });
|
||||
} else if (hasBaseUrlMatch) {
|
||||
filtered.push({ ...cred, priority: 3 });
|
||||
} else if (hasTitleMatch) {
|
||||
filtered.push({ ...cred, priority: 4 });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by priority and then take unique credentials
|
||||
// Service name fallback for credentials without URLs (priority 5)
|
||||
if (enableServiceNameFallback) {
|
||||
/*
|
||||
* SECURITY: Service name matching only applies to credentials with no service URL.
|
||||
* This prevents phishing attacks where a malicious site might match credentials
|
||||
* intended for a legitimate site.
|
||||
*/
|
||||
|
||||
// Extract words from page title
|
||||
const titleWords = extractWords(pageTitle);
|
||||
|
||||
if (titleWords.length > 0) {
|
||||
credentials.forEach(cred => {
|
||||
// CRITICAL: Only check credentials that have NO service URL defined
|
||||
if (cred.ServiceUrl && cred.ServiceUrl.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already in filtered list
|
||||
if (filtered.some(f => f.Id === cred.Id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check page title match with service name
|
||||
if (cred.ServiceName) {
|
||||
const credNameWords = extractWords(cred.ServiceName);
|
||||
|
||||
/*
|
||||
* Match only complete words, not substrings
|
||||
* For example: "Express" should match "My Express Account" but not "AliExpress"
|
||||
*/
|
||||
const hasTitleMatch = titleWords.some(titleWord =>
|
||||
credNameWords.some(credWord =>
|
||||
titleWord === credWord // Exact word match only
|
||||
)
|
||||
);
|
||||
|
||||
if (hasTitleMatch) {
|
||||
filtered.push({ ...cred, priority: 5 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority and return unique credentials (max 3)
|
||||
const uniqueCredentials = Array.from(
|
||||
new Map(filtered
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map(cred => [cred.Id, cred]))
|
||||
.values()
|
||||
new Map(
|
||||
filtered
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map(cred => [cred.Id, cred])
|
||||
).values()
|
||||
);
|
||||
// Show max 3 results
|
||||
|
||||
return uniqueCredentials.slice(0, 3);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { sendMessage } from 'webext-bridge/content-script';
|
||||
|
||||
import { openAutofillPopup } from '@/entrypoints/contentScript/Popup';
|
||||
|
||||
import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
import { FormDetector } from '@/utils/formDetector/FormDetector';
|
||||
import { FormFiller } from '@/utils/formDetector/FormFiller';
|
||||
import { Credential } from '@/utils/types/Credential';
|
||||
import { openAutofillPopup } from '@/entrypoints/contentScript/Popup';
|
||||
import { ClickValidator } from '@/utils/security/ClickValidator';
|
||||
|
||||
/**
|
||||
* Global timestamp to track popup debounce time.
|
||||
@@ -11,6 +15,11 @@ import { openAutofillPopup } from '@/entrypoints/contentScript/Popup';
|
||||
*/
|
||||
let popupDebounceTime = 0;
|
||||
|
||||
/**
|
||||
* ClickValidator instance for form security validation
|
||||
*/
|
||||
const clickValidator = ClickValidator.getInstance();
|
||||
|
||||
/**
|
||||
* Check if popup can be shown based on debounce time.
|
||||
*/
|
||||
@@ -31,6 +40,8 @@ export function hidePopupFor(ms: number) : void {
|
||||
|
||||
/**
|
||||
* Validates if an element is a supported input field that can be processed for autofill.
|
||||
* This function supports regular input elements, custom elements with type attributes,
|
||||
* and custom web components that may contain shadow DOM.
|
||||
* @param element The element to validate
|
||||
* @returns An object containing validation result and the element cast as HTMLInputElement if valid
|
||||
*/
|
||||
@@ -41,14 +52,30 @@ export function validateInputField(element: Element | null): { isValid: boolean;
|
||||
|
||||
const textInputTypes = ['text', 'email', 'tel', 'password', 'search', 'url', 'number'];
|
||||
const elementType = element.getAttribute('type');
|
||||
const isInputElement = element.tagName.toLowerCase() === 'input';
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const isInputElement = tagName === 'input';
|
||||
|
||||
// Check if element has shadow DOM with input elements
|
||||
const elementWithShadow = element as HTMLElement & { shadowRoot?: ShadowRoot };
|
||||
const hasShadowDOMInput = elementWithShadow.shadowRoot &&
|
||||
elementWithShadow.shadowRoot.querySelector('input, textarea');
|
||||
|
||||
// Check if it's a custom element that might be an input
|
||||
const isLikelyCustomInputElement = tagName.includes('-') && (
|
||||
tagName.includes('input') ||
|
||||
tagName.includes('field') ||
|
||||
tagName.includes('text') ||
|
||||
hasShadowDOMInput
|
||||
);
|
||||
|
||||
// Check if it's a valid input field we should process
|
||||
const isValid = (
|
||||
// Case 1: It's an input element (with either explicit type or defaulting to "text")
|
||||
(isInputElement && (!elementType || textInputTypes.includes(elementType?.toLowerCase() ?? ''))) ||
|
||||
// Case 2: Non-input element but has valid type attribute
|
||||
(!isInputElement && elementType && textInputTypes.includes(elementType.toLowerCase()))
|
||||
(!isInputElement && elementType && textInputTypes.includes(elementType.toLowerCase())) ||
|
||||
// Case 3: It's a custom element that likely contains an input
|
||||
(isLikelyCustomInputElement)
|
||||
) as boolean;
|
||||
|
||||
return {
|
||||
@@ -63,10 +90,15 @@ export function validateInputField(element: Element | null): { isValid: boolean;
|
||||
* @param credential - The credential to fill.
|
||||
* @param input - The input element that triggered the popup. Required when filling credentials to know which form to fill.
|
||||
*/
|
||||
export function fillCredential(credential: Credential, input: HTMLInputElement) : void {
|
||||
export async function fillCredential(credential: Credential, input: HTMLInputElement): Promise<void> {
|
||||
// Set debounce time to 300ms to prevent the popup from being shown again within 300ms because of autofill events.
|
||||
hidePopupFor(300);
|
||||
|
||||
// Reset auto-lock timer when autofilling
|
||||
sendMessage('RESET_AUTO_LOCK_TIMER', {}, 'background').catch(() => {
|
||||
// Ignore errors as background script might not be ready
|
||||
});
|
||||
|
||||
const formDetector = new FormDetector(document, input);
|
||||
const form = formDetector.getForm();
|
||||
|
||||
@@ -76,7 +108,7 @@ export function fillCredential(credential: Credential, input: HTMLInputElement)
|
||||
}
|
||||
|
||||
const formFiller = new FormFiller(form, triggerInputEvents);
|
||||
formFiller.fillFields(credential);
|
||||
await formFiller.fillFields(credential);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +129,7 @@ function findActualInput(element: HTMLElement): HTMLInputElement {
|
||||
return element as HTMLInputElement;
|
||||
}
|
||||
|
||||
// Try to find a visible child input
|
||||
// Try to find a visible child input in regular DOM
|
||||
const childInput = element.querySelector('input');
|
||||
if (childInput) {
|
||||
const style = window.getComputedStyle(childInput);
|
||||
@@ -106,6 +138,17 @@ function findActualInput(element: HTMLElement): HTMLInputElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find input in shadow DOM if element has shadowRoot
|
||||
if (element.shadowRoot) {
|
||||
const shadowInput = element.shadowRoot.querySelector('input');
|
||||
if (shadowInput) {
|
||||
const style = window.getComputedStyle(shadowInput);
|
||||
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
||||
return shadowInput as HTMLInputElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to the provided element if no child input found
|
||||
return element as HTMLInputElement;
|
||||
}
|
||||
@@ -178,9 +221,16 @@ export function injectIcon(input: HTMLInputElement, container: HTMLElement): voi
|
||||
window.addEventListener('resize', updateIconPosition);
|
||||
|
||||
// Add click event to trigger the autofill popup and refocus the input
|
||||
icon.addEventListener('click', (e: MouseEvent) => {
|
||||
icon.addEventListener('click', async (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Validate the click for security
|
||||
if (!await clickValidator.validateClick(e)) {
|
||||
console.warn('[AliasVault Security] Blocked autofill popup opening due to security validation failure');
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => actualInput.focus(), 0);
|
||||
openAutofillPopup(actualInput, container);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,347 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
|
||||
import { filterCredentials } from '../Filter';
|
||||
|
||||
describe('Filter - Credential URL Matching', () => {
|
||||
let testCredentials: Credential[];
|
||||
|
||||
beforeEach(() => {
|
||||
// Create test credentials using shared test data structure
|
||||
testCredentials = createSharedTestCredentials();
|
||||
});
|
||||
|
||||
// [#1] - Exact URL match
|
||||
it('should match exact URL', () => {
|
||||
const matches = filterCredentials(
|
||||
testCredentials,
|
||||
'www.coolblue.nl',
|
||||
''
|
||||
);
|
||||
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].ServiceName).toBe('Coolblue');
|
||||
});
|
||||
|
||||
// [#2] - Base URL with path match
|
||||
it('should match base URL with path', () => {
|
||||
const matches = filterCredentials(
|
||||
testCredentials,
|
||||
'https://gmail.com/signin',
|
||||
''
|
||||
);
|
||||
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].ServiceName).toBe('Gmail');
|
||||
});
|
||||
|
||||
// [#3] - Root domain with subdomain match
|
||||
it('should match root domain with subdomain', () => {
|
||||
const matches = filterCredentials(
|
||||
testCredentials,
|
||||
'https://mail.google.com',
|
||||
''
|
||||
);
|
||||
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].ServiceName).toBe('Google');
|
||||
});
|
||||
|
||||
// [#4] - No matches for non-existent domain
|
||||
it('should return empty array for no matches', () => {
|
||||
const matches = filterCredentials(
|
||||
testCredentials,
|
||||
'https://nonexistent.com',
|
||||
''
|
||||
);
|
||||
|
||||
expect(matches).toHaveLength(0);
|
||||
});
|
||||
|
||||
// [#5] - Partial URL stored matches full URL search
|
||||
it('should match partial URL with full URL - dumpert.nl case', () => {
|
||||
// Test case: stored URL is "dumpert.nl", search with full URL
|
||||
const matches = filterCredentials(
|
||||
testCredentials,
|
||||
'https://www.dumpert.nl',
|
||||
''
|
||||
);
|
||||
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].ServiceName).toBe('Dumpert');
|
||||
});
|
||||
|
||||
// [#6] - Full URL stored matches partial URL search
|
||||
it('should match full URL with partial URL', () => {
|
||||
const matches = filterCredentials(
|
||||
testCredentials,
|
||||
'coolblue.nl',
|
||||
''
|
||||
);
|
||||
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].ServiceName).toBe('Coolblue');
|
||||
});
|
||||
|
||||
// [#7] - Protocol variations (http/https/none) match
|
||||
it('should handle protocol variations correctly', () => {
|
||||
// Test that http and https variations match
|
||||
const httpsMatches = filterCredentials(
|
||||
testCredentials,
|
||||
'https://github.com',
|
||||
''
|
||||
);
|
||||
const httpMatches = filterCredentials(
|
||||
testCredentials,
|
||||
'http://github.com',
|
||||
''
|
||||
);
|
||||
const noProtocolMatches = filterCredentials(
|
||||
testCredentials,
|
||||
'https://github.com', // Converting no-protocol to https for test
|
||||
''
|
||||
);
|
||||
|
||||
expect(httpsMatches).toHaveLength(1);
|
||||
expect(httpMatches).toHaveLength(1);
|
||||
expect(noProtocolMatches).toHaveLength(1);
|
||||
expect(httpsMatches[0].ServiceName).toBe('GitHub');
|
||||
expect(httpMatches[0].ServiceName).toBe('GitHub');
|
||||
expect(noProtocolMatches[0].ServiceName).toBe('GitHub');
|
||||
});
|
||||
|
||||
// [#8] - WWW prefix variations match
|
||||
it('should handle www variations correctly', () => {
|
||||
// Test that www variations match
|
||||
const withWww = filterCredentials(
|
||||
testCredentials,
|
||||
'https://www.dumpert.nl',
|
||||
''
|
||||
);
|
||||
const withoutWww = filterCredentials(
|
||||
testCredentials,
|
||||
'https://dumpert.nl',
|
||||
''
|
||||
);
|
||||
|
||||
expect(withWww).toHaveLength(1);
|
||||
expect(withoutWww).toHaveLength(1);
|
||||
expect(withWww[0].ServiceName).toBe('Dumpert');
|
||||
expect(withoutWww[0].ServiceName).toBe('Dumpert');
|
||||
});
|
||||
|
||||
// [#9] - Subdomain matching
|
||||
it('should handle subdomain matching', () => {
|
||||
// Test subdomain matching
|
||||
const appSubdomain = filterCredentials(
|
||||
testCredentials,
|
||||
'https://app.example.com',
|
||||
''
|
||||
);
|
||||
const wwwSubdomain = filterCredentials(
|
||||
testCredentials,
|
||||
'https://www.example.com',
|
||||
''
|
||||
);
|
||||
const noSubdomain = filterCredentials(
|
||||
testCredentials,
|
||||
'https://example.com',
|
||||
''
|
||||
);
|
||||
|
||||
expect(appSubdomain).toHaveLength(1);
|
||||
expect(appSubdomain[0].ServiceName).toBe('Subdomain Example');
|
||||
expect(wwwSubdomain).toHaveLength(1);
|
||||
expect(wwwSubdomain[0].ServiceName).toBe('Subdomain Example');
|
||||
expect(noSubdomain).toHaveLength(1);
|
||||
expect(noSubdomain[0].ServiceName).toBe('Subdomain Example');
|
||||
});
|
||||
|
||||
// [#10] - Paths and query strings ignored
|
||||
it('should ignore paths and query strings', () => {
|
||||
// Test that paths and query strings are ignored
|
||||
const withPath = filterCredentials(
|
||||
testCredentials,
|
||||
'https://github.com/user/repo',
|
||||
''
|
||||
);
|
||||
const withQuery = filterCredentials(
|
||||
testCredentials,
|
||||
'https://stackoverflow.com/questions?tab=newest',
|
||||
''
|
||||
);
|
||||
const withFragment = filterCredentials(
|
||||
testCredentials,
|
||||
'https://gmail.com#inbox',
|
||||
''
|
||||
);
|
||||
|
||||
expect(withPath).toHaveLength(1);
|
||||
expect(withPath[0].ServiceName).toBe('GitHub');
|
||||
expect(withQuery).toHaveLength(1);
|
||||
expect(withQuery[0].ServiceName).toBe('Stack Overflow');
|
||||
expect(withFragment).toHaveLength(1);
|
||||
expect(withFragment[0].ServiceName).toBe('Gmail');
|
||||
});
|
||||
|
||||
// [#11] - Complex URL variations
|
||||
it('should handle complex URL variations', () => {
|
||||
// Test complex URL matching scenario
|
||||
const complexUrl = filterCredentials(
|
||||
testCredentials,
|
||||
'https://www.coolblue.nl/product/12345?ref=google',
|
||||
''
|
||||
);
|
||||
|
||||
expect(complexUrl).toHaveLength(1);
|
||||
expect(complexUrl[0].ServiceName).toBe('Coolblue');
|
||||
});
|
||||
|
||||
// [#12] - Priority ordering
|
||||
it('should handle priority ordering', () => {
|
||||
const matches = filterCredentials(
|
||||
testCredentials,
|
||||
'coolblue.nl',
|
||||
''
|
||||
);
|
||||
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].ServiceName).toBe('Coolblue');
|
||||
});
|
||||
|
||||
// [#13] - Title-only matching
|
||||
it('should match title only', () => {
|
||||
const matches = filterCredentials(
|
||||
testCredentials,
|
||||
'https://nomatch.com',
|
||||
'newyorktimes'
|
||||
);
|
||||
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].ServiceName).toBe('Title Only newyorktimes');
|
||||
});
|
||||
|
||||
/* [#14] - Domain name part matching */
|
||||
it('should handle domain name part matching', () => {
|
||||
const matches = filterCredentials(
|
||||
testCredentials,
|
||||
'https://coolblue.be',
|
||||
''
|
||||
);
|
||||
|
||||
expect(matches).toHaveLength(0);
|
||||
});
|
||||
|
||||
// [#15] - Package name matching
|
||||
it('should handle package name matching', () => {
|
||||
const matches = filterCredentials(
|
||||
testCredentials,
|
||||
'com.coolblue.app',
|
||||
''
|
||||
);
|
||||
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].ServiceName).toBe('Coolblue App');
|
||||
});
|
||||
|
||||
// [#16] - Invalid URL handling
|
||||
it('should handle invalid URL', () => {
|
||||
const matches = filterCredentials(
|
||||
testCredentials,
|
||||
'not a url',
|
||||
''
|
||||
);
|
||||
|
||||
expect(matches).toHaveLength(0);
|
||||
});
|
||||
|
||||
// [#17] - Anti-phishing protection
|
||||
it('should handle anti-phishing protection', () => {
|
||||
const matches = filterCredentials(
|
||||
testCredentials,
|
||||
'https://secure-bankk.com',
|
||||
''
|
||||
);
|
||||
|
||||
expect(matches).toHaveLength(0);
|
||||
});
|
||||
|
||||
// [#18] - Ensure only full words are matched
|
||||
it('should not match on string part of word', () => {
|
||||
const matches = filterCredentials(
|
||||
testCredentials,
|
||||
'',
|
||||
'Express Yourself App | Description'
|
||||
);
|
||||
|
||||
// The string above should not match "AliExpress" service name
|
||||
expect(matches).toHaveLength(0);
|
||||
});
|
||||
|
||||
// [#19] - Ensure separators and punctuation are stripped for matching
|
||||
it('should match service names when separated by commas and other punctuation', () => {
|
||||
const matches = filterCredentials(
|
||||
testCredentials,
|
||||
'https://nomatch.com',
|
||||
'Reddit, social media platform'
|
||||
);
|
||||
|
||||
// Should match "Reddit" even though it's followed by a comma and description
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].ServiceName).toBe('Reddit');
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates the shared test credential dataset used across all platforms.
|
||||
* Note: when making changes to this list, make sure to update the corresponding list for iOS and Android tests as well.
|
||||
*/
|
||||
function createSharedTestCredentials(): Credential[] {
|
||||
return [
|
||||
createTestCredential('Gmail', 'https://gmail.com', 'user@gmail.com'),
|
||||
createTestCredential('Google', 'https://google.com', 'user@google.com'),
|
||||
createTestCredential('Coolblue', 'https://www.coolblue.nl', 'user@coolblue.nl'),
|
||||
createTestCredential('Amazon', 'https://amazon.com', 'user@amazon.com'),
|
||||
createTestCredential('Coolblue App', 'com.coolblue.app', 'user@coolblue.nl'),
|
||||
createTestCredential('Dumpert', 'dumpert.nl', 'user@dumpert.nl'),
|
||||
createTestCredential('GitHub', 'github.com', 'user@github.com'),
|
||||
createTestCredential('Stack Overflow', 'https://stackoverflow.com', 'user@stackoverflow.com'),
|
||||
createTestCredential('Subdomain Example', 'https://app.example.com', 'user@example.com'),
|
||||
createTestCredential('Title Only newyorktimes', '', ''),
|
||||
createTestCredential('Bank Account', 'https://secure-bank.com', 'user@bank.com'),
|
||||
createTestCredential('AliExpress', 'https://aliexpress.com', 'user@aliexpress.com'),
|
||||
createTestCredential('Reddit', '', 'user@reddit.com'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create test credentials with standardized structure.
|
||||
* @param serviceName - The name of the service
|
||||
* @param serviceUrl - The URL of the service
|
||||
* @param username - The username for the service
|
||||
* @returns A test credential matching the platform's Credential type
|
||||
*/
|
||||
function createTestCredential(
|
||||
serviceName: string,
|
||||
serviceUrl: string,
|
||||
username: string
|
||||
): Credential {
|
||||
return {
|
||||
Id: Math.random().toString(),
|
||||
ServiceName: serviceName,
|
||||
ServiceUrl: serviceUrl,
|
||||
Username: username,
|
||||
Password: 'password123',
|
||||
Notes: '',
|
||||
Logo: new Uint8Array(),
|
||||
Alias: {
|
||||
FirstName: '',
|
||||
LastName: '',
|
||||
NickName: '',
|
||||
BirthDate: '',
|
||||
Gender: undefined,
|
||||
Email: username
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -222,7 +222,7 @@ body {
|
||||
|
||||
/* Search Input */
|
||||
.av-search-input {
|
||||
flex: 2;
|
||||
flex: 1;
|
||||
border-radius: 4px;
|
||||
background: #374151;
|
||||
color: #e5e7eb;
|
||||
@@ -231,12 +231,13 @@ body {
|
||||
border: 1px solid #4b5563;
|
||||
outline: none;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
min-width: 0px;
|
||||
padding: 8px 12px;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.av-search-input::placeholder {
|
||||
color: #bdbebe;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.av-search-input:focus {
|
||||
@@ -299,6 +300,71 @@ body {
|
||||
border: 1px solid #6f6f6f;
|
||||
}
|
||||
|
||||
/* Upgrade Required Popup */
|
||||
.av-upgrade-required {
|
||||
padding: 12px 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.av-upgrade-required:hover {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.av-upgrade-required-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 32px;
|
||||
width: 100%;
|
||||
transition: background-color 0.2s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.av-upgrade-required-message {
|
||||
color: #d1d5db;
|
||||
font-size: 14px;
|
||||
flex-grow: 1;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.av-upgrade-required-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
padding-right: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #f59e0b;
|
||||
border-radius: 4px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.av-upgrade-required-close {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
border: 1px solid #6f6f6f;
|
||||
}
|
||||
|
||||
.av-icon-upgrade {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Create Name Popup */
|
||||
.av-create-popup-overlay {
|
||||
position: fixed;
|
||||
@@ -832,4 +898,288 @@ body {
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Password Configuration Styles */
|
||||
.av-password-length-container {
|
||||
margin-top: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.av-password-length-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.av-password-length-header label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.av-password-length-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.av-password-length-value {
|
||||
font-size: 0.875rem;
|
||||
color: #e5e7eb;
|
||||
font-family: 'Courier New', monospace;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.av-password-config-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
transition: color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.av-password-config-btn:hover {
|
||||
color: #e5e7eb;
|
||||
background-color: rgba(75, 85, 99, 0.3);
|
||||
}
|
||||
|
||||
.av-password-config-btn .av-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.av-password-length-slider {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #374151;
|
||||
border-radius: 4px;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.av-password-length-slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
background: #d68338;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.av-password-length-slider::-moz-range-thumb {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
background: #d68338;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Password Config Dialog */
|
||||
.av-password-config-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 2147483647;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.av-password-config-dialog {
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.av-password-config-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
|
||||
.av-password-config-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
.av-password-config-close {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.av-password-config-close:hover {
|
||||
color: #e5e7eb;
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.av-password-config-close .av-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.av-password-config-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.av-password-preview-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.av-password-config-preview {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 6px;
|
||||
background: #374151;
|
||||
color: #f8f9fa;
|
||||
font-size: 14px;
|
||||
font-family: 'Courier New', monospace;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.av-password-config-refresh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
background: #374151;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #e5e7eb;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.av-password-config-refresh:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
.av-password-config-refresh .av-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.av-password-config-options {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.av-password-config-toggles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.av-password-config-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
background: #374151;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
transition: all 0.2s ease;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.av-password-config-toggle:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
.av-password-config-toggle.active {
|
||||
background-color: #d68338;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.av-password-config-toggle.active:hover {
|
||||
background-color: #c97731;
|
||||
}
|
||||
|
||||
.av-password-config-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.av-password-config-checkbox label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #e5e7eb;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.av-password-config-checkbox input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #d68338;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.av-password-config-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.av-password-config-use {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
background: #6b7280;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.av-password-config-use:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
.av-password-config-use .av-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
@@ -1,20 +1,38 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HashRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
|
||||
import Header from '@/entrypoints/popup/components/Layout/Header';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { ClipboardCountdownBar } from '@/entrypoints/popup/components/ClipboardCountdownBar';
|
||||
import BottomNav from '@/entrypoints/popup/components/Layout/BottomNav';
|
||||
import AuthSettings from '@/entrypoints/popup/pages/AuthSettings';
|
||||
import CredentialsList from '@/entrypoints/popup/pages/CredentialsList';
|
||||
import EmailsList from '@/entrypoints/popup/pages/EmailsList';
|
||||
import Header from '@/entrypoints/popup/components/Layout/Header';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import Home from '@/entrypoints/popup/pages/Home';
|
||||
import CredentialDetails from '@/entrypoints/popup/pages/CredentialDetails';
|
||||
import EmailDetails from '@/entrypoints/popup/pages/EmailDetails';
|
||||
import Settings from '@/entrypoints/popup/pages/Settings';
|
||||
import GlobalStateChangeHandler from '@/entrypoints/popup/components/GlobalStateChangeHandler';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { NavigationProvider } from '@/entrypoints/popup/context/NavigationContext';
|
||||
import AuthSettings from '@/entrypoints/popup/pages/AuthSettings';
|
||||
import CredentialAddEdit from '@/entrypoints/popup/pages/CredentialAddEdit';
|
||||
import CredentialDetails from '@/entrypoints/popup/pages/CredentialDetails';
|
||||
import CredentialsList from '@/entrypoints/popup/pages/CredentialsList';
|
||||
import EmailDetails from '@/entrypoints/popup/pages/EmailDetails';
|
||||
import EmailsList from '@/entrypoints/popup/pages/EmailsList';
|
||||
import Index from '@/entrypoints/popup/pages/Index';
|
||||
import Login from '@/entrypoints/popup/pages/Login';
|
||||
import Logout from '@/entrypoints/popup/pages/Logout';
|
||||
import Reinitialize from '@/entrypoints/popup/pages/Reinitialize';
|
||||
import Settings from '@/entrypoints/popup/pages/Settings';
|
||||
import AutofillSettings from '@/entrypoints/popup/pages/settings/AutofillSettings';
|
||||
import AutoLockSettings from '@/entrypoints/popup/pages/settings/AutoLockSettings';
|
||||
import ClipboardSettings from '@/entrypoints/popup/pages/settings/ClipboardSettings';
|
||||
import ContextMenuSettings from '@/entrypoints/popup/pages/settings/ContextMenuSettings';
|
||||
import LanguageSettings from '@/entrypoints/popup/pages/settings/LanguageSettings';
|
||||
import Unlock from '@/entrypoints/popup/pages/Unlock';
|
||||
import UnlockSuccess from '@/entrypoints/popup/pages/UnlockSuccess';
|
||||
import Upgrade from '@/entrypoints/popup/pages/Upgrade';
|
||||
|
||||
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
|
||||
|
||||
import '@/entrypoints/popup/style.css';
|
||||
|
||||
/**
|
||||
@@ -31,22 +49,36 @@ type RouteConfig = {
|
||||
* App component.
|
||||
*/
|
||||
const App: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const authContext = useAuth();
|
||||
const { isInitialLoading } = useLoading();
|
||||
const [isLoading, setIsLoading] = useMinDurationLoading(true, 150);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const { headerButtons } = useHeaderButtons();
|
||||
|
||||
// Add these route configurations
|
||||
const routes: RouteConfig[] = [
|
||||
{ path: '/', element: <Home />, showBackButton: false },
|
||||
{ path: '/auth-settings', element: <AuthSettings />, showBackButton: true, title: 'Settings' },
|
||||
// Move routes definition to useMemo to prevent recreation on every render
|
||||
const routes: RouteConfig[] = React.useMemo(() => [
|
||||
{ path: '/', element: <Index />, showBackButton: false },
|
||||
{ path: '/reinitialize', element: <Reinitialize />, showBackButton: false },
|
||||
{ path: '/login', element: <Login />, showBackButton: false },
|
||||
{ path: '/unlock', element: <Unlock />, showBackButton: false },
|
||||
{ path: '/unlock-success', element: <UnlockSuccess />, showBackButton: false },
|
||||
{ path: '/upgrade', element: <Upgrade />, showBackButton: false },
|
||||
{ path: '/auth-settings', element: <AuthSettings />, showBackButton: true, title: t('settings.title') },
|
||||
{ path: '/credentials', element: <CredentialsList />, showBackButton: false },
|
||||
{ path: '/credentials/:id', element: <CredentialDetails />, showBackButton: true, title: 'Credential details' },
|
||||
{ path: '/credentials/add', element: <CredentialAddEdit />, showBackButton: true, title: t('credentials.addCredential') },
|
||||
{ path: '/credentials/:id', element: <CredentialDetails />, showBackButton: true, title: t('credentials.credentialDetails') },
|
||||
{ path: '/credentials/:id/edit', element: <CredentialAddEdit />, showBackButton: true, title: t('credentials.editCredential') },
|
||||
{ path: '/emails', element: <EmailsList />, showBackButton: false },
|
||||
{ path: '/emails/:id', element: <EmailDetails />, showBackButton: true, title: 'Email details' },
|
||||
{ path: '/emails/:id', element: <EmailDetails />, showBackButton: true, title: t('emails.title') },
|
||||
{ path: '/settings', element: <Settings />, showBackButton: false },
|
||||
{ path: '/settings/autofill', element: <AutofillSettings />, showBackButton: true, title: t('settings.autofillSettings') },
|
||||
{ path: '/settings/context-menu', element: <ContextMenuSettings />, showBackButton: true, title: t('settings.contextMenuSettings') },
|
||||
{ path: '/settings/clipboard', element: <ClipboardSettings />, showBackButton: true, title: t('settings.clipboardSettings') },
|
||||
{ path: '/settings/language', element: <LanguageSettings />, showBackButton: true, title: t('settings.language') },
|
||||
{ path: '/settings/auto-lock', element: <AutoLockSettings />, showBackButton: true, title: t('settings.autoLockTimeout') },
|
||||
{ path: '/logout', element: <Logout />, showBackButton: false },
|
||||
];
|
||||
], [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialLoading) {
|
||||
@@ -54,6 +86,29 @@ const App: React.FC = () => {
|
||||
}
|
||||
}, [isInitialLoading, setIsLoading]);
|
||||
|
||||
/**
|
||||
* Send heartbeat to background every 5 seconds while popup is open.
|
||||
* This extends the auto-lock timer to prevent vault locking while popup is active.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Send initial heartbeat
|
||||
sendMessage('POPUP_HEARTBEAT', {}, 'background').catch(() => {
|
||||
// Ignore errors as background script might not be ready
|
||||
});
|
||||
|
||||
// Set up heartbeat interval
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
sendMessage('POPUP_HEARTBEAT', {}, 'background').catch(() => {
|
||||
// Ignore errors as background script might not be ready
|
||||
});
|
||||
}, 5000); // Send heartbeat every 5 seconds
|
||||
|
||||
// Cleanup: clear interval when popup closes
|
||||
return () : void => {
|
||||
clearInterval(heartbeatInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Print global message if it exists.
|
||||
*/
|
||||
@@ -67,44 +122,45 @@ const App: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen min-w-[350px] bg-white dark:bg-gray-900 flex flex-col">
|
||||
{isLoading && (
|
||||
<div className="fixed inset-0 bg-white dark:bg-gray-900 z-50 flex items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
|
||||
<GlobalStateChangeHandler />
|
||||
<Header
|
||||
routes={routes}
|
||||
/>
|
||||
<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)',
|
||||
maxHeight: '600px',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</NavigationProvider>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { onMessage, sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
/**
|
||||
* Clipboard countdown bar component.
|
||||
*/
|
||||
export const ClipboardCountdownBar: React.FC = () => {
|
||||
const [isVisible, setIsVisible] = useState<boolean>(false);
|
||||
const animationRef = useRef<HTMLDivElement>(null);
|
||||
const currentCountdownIdRef = useRef<number>(0);
|
||||
|
||||
/**
|
||||
* Starts the countdown animation.
|
||||
*/
|
||||
const startAnimation = (remaining: number, total: number) : void => {
|
||||
// Use a small delay to ensure the component is fully rendered
|
||||
setTimeout(() => {
|
||||
if (animationRef.current) {
|
||||
// Calculate the starting percentage based on remaining time
|
||||
const percentage = (remaining / total) * 100;
|
||||
|
||||
// Reset any existing animation
|
||||
animationRef.current.style.transition = 'none';
|
||||
animationRef.current.style.width = `${percentage}%`;
|
||||
|
||||
// Force browser to flush styles
|
||||
void animationRef.current.offsetHeight;
|
||||
|
||||
// Start animation from current position to 0
|
||||
requestAnimationFrame(() => {
|
||||
if (animationRef.current) {
|
||||
animationRef.current.style.transition = `width ${remaining}s linear`;
|
||||
animationRef.current.style.width = '0%';
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Request current countdown state on mount
|
||||
sendMessage('GET_CLIPBOARD_COUNTDOWN_STATE', {}, 'background').then((state) => {
|
||||
const countdownState = state as { remaining: number; total: number; id: number } | null;
|
||||
if (countdownState && countdownState.remaining > 0) {
|
||||
currentCountdownIdRef.current = countdownState.id;
|
||||
setIsVisible(true);
|
||||
startAnimation(countdownState.remaining, countdownState.total);
|
||||
}
|
||||
}).catch(() => {
|
||||
// No active countdown
|
||||
});
|
||||
// Listen for countdown updates from background script
|
||||
const unsubscribe = onMessage('CLIPBOARD_COUNTDOWN', ({ data }) => {
|
||||
const { remaining, total, id } = data as { remaining: number; total: number; id: number };
|
||||
setIsVisible(remaining > 0);
|
||||
|
||||
// Check if this is a new countdown (different ID)
|
||||
const isNewCountdown = id !== currentCountdownIdRef.current;
|
||||
|
||||
// Start animation when new countdown begins
|
||||
if (isNewCountdown && remaining > 0) {
|
||||
currentCountdownIdRef.current = id;
|
||||
startAnimation(remaining, total);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for clipboard cleared message
|
||||
const unsubscribeClear = onMessage('CLIPBOARD_CLEARED', () => {
|
||||
setIsVisible(false);
|
||||
currentCountdownIdRef.current = 0;
|
||||
if (animationRef.current) {
|
||||
animationRef.current.style.transition = 'none';
|
||||
animationRef.current.style.width = '0%';
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for countdown cancelled message
|
||||
const unsubscribeCancel = onMessage('CLIPBOARD_COUNTDOWN_CANCELLED', () => {
|
||||
setIsVisible(false);
|
||||
currentCountdownIdRef.current = 0;
|
||||
if (animationRef.current) {
|
||||
animationRef.current.style.transition = 'none';
|
||||
animationRef.current.style.width = '0%';
|
||||
}
|
||||
});
|
||||
|
||||
return () : void => {
|
||||
// Clean up listeners
|
||||
unsubscribe();
|
||||
unsubscribeClear();
|
||||
unsubscribeCancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 z-50 h-1 bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
ref={animationRef}
|
||||
className="h-full bg-orange-500"
|
||||
style={{ width: '100%', transition: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Credential } from '@/utils/types/Credential';
|
||||
|
||||
import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
import SqliteClient from '@/utils/SqliteClient';
|
||||
|
||||
type CredentialCardProps = {
|
||||
@@ -23,18 +24,34 @@ const CredentialCard: React.FC<CredentialCardProps> = ({ credential }) => {
|
||||
* @returns The display text for the credential
|
||||
*/
|
||||
const getDisplayText = (cred: Credential): string => {
|
||||
let returnValue = '';
|
||||
|
||||
// Show username if available
|
||||
if (cred.Username) {
|
||||
return cred.Username;
|
||||
returnValue = cred.Username;
|
||||
}
|
||||
|
||||
// Show email if username is not available
|
||||
if (cred.Alias?.Email) {
|
||||
return cred.Alias.Email;
|
||||
returnValue = cred.Alias.Email;
|
||||
}
|
||||
|
||||
// Show empty string if neither username nor email is available
|
||||
return '';
|
||||
// Trim the return value to max. 33 characters.
|
||||
return returnValue.length > 33 ? returnValue.slice(0, 30) + '...' : returnValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the service name for a credential, trimming it to maximum length so it doesn't overflow the UI.
|
||||
*/
|
||||
const getCredentialServiceName = (cred: Credential): string => {
|
||||
let returnValue = 'Untitled';
|
||||
|
||||
if (cred.ServiceName) {
|
||||
returnValue = cred.ServiceName;
|
||||
}
|
||||
|
||||
// Trim the return value to max. 33 characters.
|
||||
return returnValue.length > 33 ? returnValue.slice(0, 30) + '...' : returnValue;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -53,7 +70,7 @@ const CredentialCard: React.FC<CredentialCardProps> = ({ credential }) => {
|
||||
}}
|
||||
/>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{credential.ServiceName}</p>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{getCredentialServiceName(credential)}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{getDisplayText(credential)}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Credential } from '@/utils/types/Credential';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FormInputCopyToClipboard } from '@/entrypoints/popup/components/FormInputCopyToClipboard';
|
||||
import { IdentityHelperUtils } from '@/utils/shared/identity-generator';
|
||||
|
||||
import { IdentityHelperUtils } from '@/utils/dist/shared/identity-generator';
|
||||
import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
|
||||
type AliasBlockProps = {
|
||||
credential: Credential;
|
||||
@@ -11,6 +14,7 @@ type AliasBlockProps = {
|
||||
* Render the alias block.
|
||||
*/
|
||||
const AliasBlock: React.FC<AliasBlockProps> = ({ credential }) => {
|
||||
const { t } = useTranslation();
|
||||
const hasFirstName = Boolean(credential.Alias?.FirstName?.trim());
|
||||
const hasLastName = Boolean(credential.Alias?.LastName?.trim());
|
||||
const hasNickName = Boolean(credential.Alias?.NickName?.trim());
|
||||
@@ -22,39 +26,39 @@ const AliasBlock: React.FC<AliasBlockProps> = ({ credential }) => {
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Alias</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{t('common.alias')}</h2>
|
||||
{(hasFirstName || hasLastName) && (
|
||||
<FormInputCopyToClipboard
|
||||
id="fullName"
|
||||
label="Full Name"
|
||||
label={t('common.fullName')}
|
||||
value={[credential.Alias?.FirstName, credential.Alias?.LastName].filter(Boolean).join(' ')}
|
||||
/>
|
||||
)}
|
||||
{hasFirstName && (
|
||||
<FormInputCopyToClipboard
|
||||
id="firstName"
|
||||
label="First Name"
|
||||
label={t('common.firstName')}
|
||||
value={credential.Alias?.FirstName ?? ''}
|
||||
/>
|
||||
)}
|
||||
{hasLastName && (
|
||||
<FormInputCopyToClipboard
|
||||
id="lastName"
|
||||
label="Last Name"
|
||||
label={t('common.lastName')}
|
||||
value={credential.Alias?.LastName ?? ''}
|
||||
/>
|
||||
)}
|
||||
{hasBirthDate && (
|
||||
<FormInputCopyToClipboard
|
||||
id="birthDate"
|
||||
label="Birth Date"
|
||||
label={t('common.birthDate')}
|
||||
value={IdentityHelperUtils.normalizeBirthDateForDisplay(credential.Alias?.BirthDate)}
|
||||
/>
|
||||
)}
|
||||
{hasNickName && (
|
||||
<FormInputCopyToClipboard
|
||||
id="nickName"
|
||||
label="Nickname"
|
||||
label={t('common.nickname')}
|
||||
value={credential.Alias?.NickName ?? ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
import type { Attachment } from '@/utils/dist/shared/models/vault';
|
||||
|
||||
type AttachmentBlockProps = {
|
||||
credentialId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component shows attachments for a credential.
|
||||
*/
|
||||
const AttachmentBlock: React.FC<AttachmentBlockProps> = ({ credentialId }) => {
|
||||
const { t } = useTranslation();
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const dbContext = useDb();
|
||||
|
||||
/**
|
||||
* Downloads an attachment file.
|
||||
*/
|
||||
const downloadAttachment = (attachment: Attachment): void => {
|
||||
try {
|
||||
// Convert Uint8Array or number[] to Uint8Array
|
||||
const byteArray = attachment.Blob instanceof Uint8Array
|
||||
? attachment.Blob
|
||||
: new Uint8Array(attachment.Blob);
|
||||
|
||||
// Create blob and download
|
||||
const blob = new Blob([byteArray as BlobPart]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Create temporary download link
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = attachment.Filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error downloading attachment:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Loads the attachments for the credential.
|
||||
*/
|
||||
const loadAttachments = async (): Promise<void> => {
|
||||
if (!dbContext?.sqliteClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const attachmentList = dbContext.sqliteClient.getAttachmentsForCredential(credentialId);
|
||||
setAttachments(attachmentList);
|
||||
} catch (error) {
|
||||
console.error('Error loading attachments:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAttachments();
|
||||
}, [credentialId, dbContext?.sqliteClient]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">{t('common.attachments')}</h2>
|
||||
{t('common.loadingAttachments')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('common.attachments')}</h2>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{attachments.map(attachment => (
|
||||
<button
|
||||
key={attachment.Id}
|
||||
className="w-full text-left p-2 ps-3 pe-3 rounded bg-white dark:bg-gray-800 shadow hover:shadow-md transition-all border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
onClick={() => downloadAttachment(attachment)}
|
||||
aria-label={`Download ${attachment.Filename}`}
|
||||
>
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<div className="flex items-center flex-1">
|
||||
<div className="flex flex-col">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{attachment.Filename}</h4>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{new Date(attachment.CreatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5m0 0l5-5m-5 5V4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentBlock;
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { Attachment } from '@/utils/dist/shared/models/vault';
|
||||
|
||||
type AttachmentUploaderProps = {
|
||||
attachments: Attachment[];
|
||||
onAttachmentsChange: (attachments: Attachment[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component allows uploading and managing attachments for a credential.
|
||||
*/
|
||||
const AttachmentUploader: React.FC<AttachmentUploaderProps> = ({
|
||||
attachments,
|
||||
onAttachmentsChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [statusMessage, setStatusMessage] = useState<string>('');
|
||||
|
||||
/**
|
||||
* Handles file selection and upload.
|
||||
*/
|
||||
const handleFileSelection = async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatusMessage('Uploading...');
|
||||
|
||||
try {
|
||||
const newAttachments = [...attachments];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const byteArray = new Uint8Array(arrayBuffer);
|
||||
|
||||
const attachment: Attachment = {
|
||||
Id: crypto.randomUUID(),
|
||||
Filename: file.name,
|
||||
Blob: byteArray,
|
||||
CredentialId: '', // Will be set when saving credential
|
||||
CreatedAt: new Date().toISOString(),
|
||||
UpdatedAt: new Date().toISOString(),
|
||||
IsDeleted: false,
|
||||
};
|
||||
|
||||
newAttachments.push(attachment);
|
||||
}
|
||||
|
||||
onAttachmentsChange(newAttachments);
|
||||
setStatusMessage('Files uploaded successfully.');
|
||||
|
||||
// Clear status message after 3 seconds
|
||||
setTimeout(() => setStatusMessage(''), 3000);
|
||||
} catch (error) {
|
||||
console.error('Error uploading files:', error);
|
||||
setStatusMessage('Error uploading files.');
|
||||
setTimeout(() => setStatusMessage(''), 3000);
|
||||
}
|
||||
|
||||
// Reset file input
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes an attachment.
|
||||
*/
|
||||
const deleteAttachment = (attachmentToDelete: Attachment): void => {
|
||||
try {
|
||||
const updatedAttachments = [...attachments];
|
||||
|
||||
// Remove attachment from array
|
||||
const index = updatedAttachments.findIndex(a => a.Id === attachmentToDelete.Id);
|
||||
if (index !== -1) {
|
||||
updatedAttachments.splice(index, 1);
|
||||
}
|
||||
|
||||
onAttachmentsChange(updatedAttachments);
|
||||
setStatusMessage('Attachment deleted successfully.');
|
||||
setTimeout(() => setStatusMessage(''), 3000);
|
||||
} catch (error) {
|
||||
console.error('Error deleting attachment:', error);
|
||||
setStatusMessage('Error deleting attachment.');
|
||||
setTimeout(() => setStatusMessage(''), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const activeAttachments = attachments.filter(a => !a.IsDeleted);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('common.attachments')}</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelection}
|
||||
className="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400"
|
||||
/>
|
||||
{statusMessage && (
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{statusMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeAttachments.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-md font-medium text-gray-900 dark:text-white">Current attachments:</h4>
|
||||
<div className="space-y-2">
|
||||
{activeAttachments.map(attachment => (
|
||||
<div
|
||||
key={attachment.Id}
|
||||
className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded border border-gray-200 dark:border-gray-600"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{attachment.Filename}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{new Date(attachment.CreatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteAttachment(attachment)}
|
||||
className="text-red-500 hover:text-red-700 focus:outline-none"
|
||||
aria-label={`Delete ${attachment.Filename}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentUploader;
|
||||
@@ -1,18 +1,45 @@
|
||||
import React from 'react';
|
||||
|
||||
import { EmailPreview } from '@/entrypoints/popup/components/EmailPreview';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
type EmailBlockProps = {
|
||||
email: string;
|
||||
isSupported: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the email block.
|
||||
*/
|
||||
const EmailBlock: React.FC<EmailBlockProps> = ({ email, isSupported }) => (
|
||||
<>
|
||||
{isSupported && <EmailPreview email={email} />}
|
||||
</>
|
||||
);
|
||||
const EmailBlock: React.FC<EmailBlockProps> = ({ email }) => {
|
||||
const dbContext = useDb();
|
||||
|
||||
/**
|
||||
* Check if the email domain is supported.
|
||||
*/
|
||||
const isEmailDomainSupported = async (email: string): Promise<boolean> => {
|
||||
const domain = email.split('@')[1]?.toLowerCase();
|
||||
if (!domain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const vaultMetadata = await dbContext.getVaultMetadata();
|
||||
const publicDomains = vaultMetadata?.publicEmailDomains ?? [];
|
||||
const privateDomains = vaultMetadata?.privateEmailDomains ?? [];
|
||||
|
||||
return [...publicDomains, ...privateDomains].some(supportedDomain =>
|
||||
domain === supportedDomain || domain.endsWith(`.${supportedDomain}`)
|
||||
);
|
||||
};
|
||||
|
||||
if (!isEmailDomainSupported(email)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{<EmailPreview email={email} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailBlock;
|
||||
@@ -1,27 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Credential } from '@/utils/types/Credential';
|
||||
|
||||
import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
import SqliteClient from '@/utils/SqliteClient';
|
||||
|
||||
type HeaderBlockProps = {
|
||||
credential: Credential;
|
||||
onOpenNewPopup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the header block.
|
||||
*/
|
||||
const HeaderBlock: React.FC<HeaderBlockProps> = ({ credential, onOpenNewPopup }) => (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={SqliteClient.imgSrcFromBytes(credential.Logo)}
|
||||
alt={credential.ServiceName}
|
||||
className="w-12 h-12 rounded-lg mr-4"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{credential.ServiceName}</h1>
|
||||
{credential.ServiceUrl && (
|
||||
const HeaderBlock: React.FC<HeaderBlockProps> = ({ credential }) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={SqliteClient.imgSrcFromBytes(credential.Logo)}
|
||||
alt={credential.ServiceName}
|
||||
className="w-12 h-12 rounded-lg mr-4"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-gray-900 dark:text-white">{credential.ServiceName}</h1>
|
||||
{credential.ServiceUrl && (
|
||||
/^https?:\/\//i.test(credential.ServiceUrl) ? (
|
||||
<a
|
||||
href={credential.ServiceUrl}
|
||||
target="_blank"
|
||||
@@ -30,29 +30,11 @@ const HeaderBlock: React.FC<HeaderBlockProps> = ({ credential, onOpenNewPopup })
|
||||
>
|
||||
{credential.ServiceUrl}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="break-all">{credential.ServiceUrl}</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenNewPopup}
|
||||
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
title="Open in new window"
|
||||
>
|
||||
<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="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Credential } from '@/utils/types/Credential';
|
||||
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;
|
||||
}
|
||||
@@ -10,6 +13,7 @@ type LoginCredentialsBlockProps = {
|
||||
* Render the login credentials block.
|
||||
*/
|
||||
const LoginCredentialsBlock: React.FC<LoginCredentialsBlockProps> = ({ credential }) => {
|
||||
const { t } = useTranslation();
|
||||
const email = credential.Alias?.Email?.trim();
|
||||
const username = credential.Username?.trim();
|
||||
const password = credential.Password?.trim();
|
||||
@@ -20,25 +24,25 @@ const LoginCredentialsBlock: React.FC<LoginCredentialsBlockProps> = ({ credentia
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Login credentials</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{t('common.loginCredentials')}</h2>
|
||||
{email && (
|
||||
<FormInputCopyToClipboard
|
||||
id="email"
|
||||
label="Email"
|
||||
label={t('common.email')}
|
||||
value={email}
|
||||
/>
|
||||
)}
|
||||
{username && (
|
||||
<FormInputCopyToClipboard
|
||||
id="username"
|
||||
label="Username"
|
||||
label={t('common.username')}
|
||||
value={username}
|
||||
/>
|
||||
)}
|
||||
{password && (
|
||||
<FormInputCopyToClipboard
|
||||
id="password"
|
||||
label="Password"
|
||||
label={t('common.password')}
|
||||
value={password}
|
||||
type="password"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type NotesBlockProps = {
|
||||
notes: string | undefined;
|
||||
@@ -20,6 +21,7 @@ const convertUrlsToLinks = (text: string): string => {
|
||||
* Render the notes block.
|
||||
*/
|
||||
const NotesBlock: React.FC<NotesBlockProps> = ({ notes }) => {
|
||||
const { t } = useTranslation();
|
||||
if (!notes) {
|
||||
return null;
|
||||
}
|
||||
@@ -28,7 +30,7 @@ const NotesBlock: React.FC<NotesBlockProps> = ({ notes }) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Notes</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{t('common.notes')}</h2>
|
||||
<div className="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<p
|
||||
className="text-gray-900 dark:text-gray-100 whitespace-pre-wrap"
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { TotpCode } from '@/utils/types/TotpCode';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
import type { TotpCode } from '@/utils/dist/shared/models/vault';
|
||||
|
||||
type TotpBlockProps = {
|
||||
credentialId: string;
|
||||
@@ -11,6 +15,7 @@ type TotpBlockProps = {
|
||||
* This component shows TOTP codes for a credential.
|
||||
*/
|
||||
const TotpBlock: React.FC<TotpBlockProps> = ({ credentialId }) => {
|
||||
const { t } = useTranslation();
|
||||
const [totpCodes, setTotpCodes] = useState<TotpCode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentCodes, setCurrentCodes] = useState<Record<string, string>>({});
|
||||
@@ -64,6 +69,9 @@ const TotpBlock: React.FC<TotpBlockProps> = ({ credentialId }) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopiedId(id);
|
||||
|
||||
// Notify background script that clipboard was copied
|
||||
await sendMessage('CLIPBOARD_COPIED', { value: code }, 'background');
|
||||
|
||||
// Reset copied state after 2 seconds
|
||||
setTimeout(() => {
|
||||
@@ -136,8 +144,8 @@ const TotpBlock: React.FC<TotpBlockProps> = ({ credentialId }) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Two-factor authentication</h2>
|
||||
Loading TOTP codes...
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">{t('common.twoFactorAuthentication')}</h2>
|
||||
{t('common.loadingTotpCodes')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -149,7 +157,7 @@ const TotpBlock: React.FC<TotpBlockProps> = ({ credentialId }) => {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Two-factor authentication</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('common.twoFactorAuthentication')}</h2>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{totpCodes.map(totpCode => (
|
||||
<button
|
||||
@@ -169,7 +177,7 @@ const TotpBlock: React.FC<TotpBlockProps> = ({ credentialId }) => {
|
||||
</span>
|
||||
<div className="text-xs">
|
||||
{copiedId === totpCode.Id ? (
|
||||
<span className="text-green-600 dark:text-green-400">Copied!</span>
|
||||
<span className="text-green-600 dark:text-green-400">{t('common.copied')}</span>
|
||||
) : (
|
||||
<span className="text-gray-500 dark:text-gray-400">{getRemainingSeconds()}s</span>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import HeaderBlock from './HeaderBlock';
|
||||
import EmailBlock from './EmailBlock';
|
||||
import TotpBlock from './TotpBlock';
|
||||
import LoginCredentialsBlock from './LoginCredentialsBlock';
|
||||
import AliasBlock from './AliasBlock';
|
||||
import AttachmentBlock from './AttachmentBlock';
|
||||
import EmailBlock from './EmailBlock';
|
||||
import HeaderBlock from './HeaderBlock';
|
||||
import LoginCredentialsBlock from './LoginCredentialsBlock';
|
||||
import NotesBlock from './NotesBlock';
|
||||
import TotpBlock from './TotpBlock';
|
||||
|
||||
export {
|
||||
HeaderBlock,
|
||||
@@ -11,5 +12,6 @@ export {
|
||||
TotpBlock,
|
||||
LoginCredentialsBlock,
|
||||
AliasBlock,
|
||||
NotesBlock
|
||||
NotesBlock,
|
||||
AttachmentBlock
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
type EmailDomainFieldProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
// Hardcoded public email domains (same as in AliasVault.Client)
|
||||
const PUBLIC_EMAIL_DOMAINS = [
|
||||
'spamok.com',
|
||||
'solarflarecorp.com',
|
||||
'spamok.nl',
|
||||
'3060.nl',
|
||||
'landmail.nl',
|
||||
'asdasd.nl',
|
||||
'spamok.de',
|
||||
'spamok.com.ua',
|
||||
'spamok.es',
|
||||
'spamok.fr',
|
||||
];
|
||||
|
||||
/**
|
||||
* Email domain field component with domain chooser functionality.
|
||||
* Allows users to select from private/public domains or enter custom email addresses.
|
||||
*/
|
||||
const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
required = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const dbContext = useDb();
|
||||
const [isCustomDomain, setIsCustomDomain] = useState(false);
|
||||
const [localPart, setLocalPart] = useState('');
|
||||
const [selectedDomain, setSelectedDomain] = useState('');
|
||||
const [isPopupVisible, setIsPopupVisible] = useState(false);
|
||||
const [privateEmailDomains, setPrivateEmailDomains] = useState<string[]>([]);
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get private email domains from vault metadata
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Load private email domains from vault metadata.
|
||||
*/
|
||||
const loadDomains = async (): Promise<void> => {
|
||||
const metadata = await dbContext.getVaultMetadata();
|
||||
if (metadata?.privateEmailDomains) {
|
||||
setPrivateEmailDomains(metadata.privateEmailDomains);
|
||||
}
|
||||
};
|
||||
loadDomains();
|
||||
}, [dbContext]);
|
||||
|
||||
// Check if private domains are available and valid
|
||||
const showPrivateDomains = useMemo(() => {
|
||||
return privateEmailDomains.length > 0 &&
|
||||
!(privateEmailDomains.length === 1 && (privateEmailDomains[0] === 'DISABLED.TLD' || privateEmailDomains[0] === ''));
|
||||
}, [privateEmailDomains]);
|
||||
|
||||
// Initialize state from value prop
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
// Set default domain
|
||||
if (showPrivateDomains && privateEmailDomains[0]) {
|
||||
setSelectedDomain(privateEmailDomains[0]);
|
||||
} else if (PUBLIC_EMAIL_DOMAINS[0]) {
|
||||
setSelectedDomain(PUBLIC_EMAIL_DOMAINS[0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.includes('@')) {
|
||||
const [local, domain] = value.split('@');
|
||||
setLocalPart(local);
|
||||
setSelectedDomain(domain);
|
||||
|
||||
// Check if it's a custom domain
|
||||
const isKnownDomain = PUBLIC_EMAIL_DOMAINS.includes(domain) ||
|
||||
privateEmailDomains.includes(domain);
|
||||
setIsCustomDomain(!isKnownDomain);
|
||||
} else {
|
||||
setLocalPart(value);
|
||||
setIsCustomDomain(false);
|
||||
|
||||
// Set default domain if not already set
|
||||
if (!selectedDomain && !value.includes('@')) {
|
||||
if (showPrivateDomains && privateEmailDomains[0]) {
|
||||
setSelectedDomain(privateEmailDomains[0]);
|
||||
} else if (PUBLIC_EMAIL_DOMAINS[0]) {
|
||||
setSelectedDomain(PUBLIC_EMAIL_DOMAINS[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [value, privateEmailDomains, showPrivateDomains, selectedDomain]);
|
||||
|
||||
// Handle local part changes
|
||||
const handleLocalPartChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newLocalPart = e.target.value;
|
||||
|
||||
// Check if new value contains '@' symbol, if so, switch to custom domain mode
|
||||
if (newLocalPart.includes('@')) {
|
||||
setIsCustomDomain(true);
|
||||
onChange(newLocalPart);
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalPart(newLocalPart);
|
||||
if (!isCustomDomain && selectedDomain) {
|
||||
onChange(`${newLocalPart}@${selectedDomain}`);
|
||||
} else {
|
||||
onChange(newLocalPart);
|
||||
}
|
||||
}, [isCustomDomain, selectedDomain, onChange]);
|
||||
|
||||
// Select a domain from the popup
|
||||
const selectDomain = useCallback((domain: string) => {
|
||||
setSelectedDomain(domain);
|
||||
const cleanLocalPart = localPart.includes('@') ? localPart.split('@')[0] : localPart;
|
||||
onChange(`${cleanLocalPart}@${domain}`);
|
||||
setIsCustomDomain(false);
|
||||
setIsPopupVisible(false);
|
||||
}, [localPart, onChange]);
|
||||
|
||||
// Toggle between custom domain and domain chooser
|
||||
const toggleCustomDomain = useCallback(() => {
|
||||
const newIsCustom = !isCustomDomain;
|
||||
setIsCustomDomain(newIsCustom);
|
||||
|
||||
if (!newIsCustom && !value.includes('@')) {
|
||||
// Switching to domain chooser mode, add default domain
|
||||
const defaultDomain = showPrivateDomains && privateEmailDomains[0]
|
||||
? privateEmailDomains[0]
|
||||
: PUBLIC_EMAIL_DOMAINS[0];
|
||||
onChange(`${localPart}@${defaultDomain}`);
|
||||
setSelectedDomain(defaultDomain);
|
||||
}
|
||||
}, [isCustomDomain, value, localPart, showPrivateDomains, privateEmailDomains, onChange]);
|
||||
|
||||
// Handle clicks outside the popup
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Handle clicks outside the popup to close it.
|
||||
*/
|
||||
const handleClickOutside = (event: MouseEvent): void => {
|
||||
if (popupRef.current && !popupRef.current.contains(event.target as Node)) {
|
||||
setIsPopupVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isPopupVisible) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return (): void => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [isPopupVisible]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={id} className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
|
||||
<div className="relative w-full">
|
||||
<div className="flex w-full">
|
||||
<input
|
||||
type="text"
|
||||
id={id}
|
||||
className={`flex-1 min-w-0 px-3 py-2 border ${
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
} ${
|
||||
!isCustomDomain ? 'rounded-l-md' : 'rounded-md'
|
||||
} focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-white`}
|
||||
value={isCustomDomain ? value : localPart}
|
||||
onChange={handleLocalPartChange}
|
||||
placeholder={isCustomDomain ? t('credentials.enterFullEmail') : t('credentials.enterEmailPrefix')}
|
||||
/>
|
||||
|
||||
{!isCustomDomain && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPopupVisible(!isPopupVisible)}
|
||||
className="inline-flex items-center px-2 py-2 border border-l-0 border-gray-300 dark:border-gray-600 rounded-r-md bg-gray-50 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-500 cursor-pointer text-sm truncate max-w-[120px]"
|
||||
>
|
||||
<span className="text-gray-500 dark:text-gray-400">@</span>
|
||||
<span className="truncate ml-0.5">{selectedDomain}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Domain selection popup */}
|
||||
{isPopupVisible && !isCustomDomain && (
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="absolute z-50 mt-2 w-full bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 max-h-96 overflow-y-auto"
|
||||
>
|
||||
<div className="p-4">
|
||||
{showPrivateDomains && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('credentials.privateEmailTitle')} <span className="text-xs text-gray-500 dark:text-gray-400">({t('credentials.privateEmailAliasVaultServer')})</span>
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{t('credentials.privateEmailDescription')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{privateEmailDomains.map((domain) => (
|
||||
<button
|
||||
key={domain}
|
||||
type="button"
|
||||
onClick={() => selectDomain(domain)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
selectedDomain === domain
|
||||
? 'bg-primary-600 text-white hover:bg-primary-700'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{domain}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={showPrivateDomains ? 'border-t border-gray-200 dark:border-gray-600 pt-4' : ''}>
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('credentials.publicEmailTitle')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{t('credentials.publicEmailDescription')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PUBLIC_EMAIL_DOMAINS.map((domain) => (
|
||||
<button
|
||||
key={domain}
|
||||
type="button"
|
||||
onClick={() => selectDomain(domain)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
selectedDomain === domain
|
||||
? 'bg-primary-600 text-white hover:bg-primary-700'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{domain}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle custom domain button */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCustomDomain}
|
||||
className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
|
||||
>
|
||||
{isCustomDomain
|
||||
? t('credentials.useDomainChooser')
|
||||
: t('credentials.enterCustomDomain')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 mt-1">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailDomainField;
|
||||
@@ -1,11 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { storage } from '#imports';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { EncryptionUtility } from '@/utils/EncryptionUtility';
|
||||
import { MailboxEmail } from '@/utils/types/webapi/MailboxEmail';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
|
||||
import { AppInfo } from '@/utils/AppInfo';
|
||||
import type { ApiErrorResponse, MailboxEmail } from '@/utils/dist/shared/models/webapi';
|
||||
import { EncryptionUtility } from '@/utils/EncryptionUtility';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
type EmailPreviewProps = {
|
||||
email: string;
|
||||
@@ -15,13 +19,38 @@ type EmailPreviewProps = {
|
||||
* This component shows a preview of the latest emails in the inbox.
|
||||
*/
|
||||
export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
const { t } = useTranslation();
|
||||
const [emails, setEmails] = useState<MailboxEmail[]>([]);
|
||||
const [displayedEmails, setDisplayedEmails] = useState<MailboxEmail[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastEmailId, setLastEmailId] = useState<number>(0);
|
||||
const [isSpamOk, setIsSpamOk] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSupportedDomain, setIsSupportedDomain] = useState(false);
|
||||
const [displayedCount, setDisplayedCount] = useState(2);
|
||||
const webApi = useWebApi();
|
||||
const dbContext = useDb();
|
||||
|
||||
const emailsPerLoad = 3;
|
||||
const canLoadMore = displayedCount < emails.length;
|
||||
|
||||
/**
|
||||
* Updates the displayed emails based on the current count.
|
||||
*/
|
||||
const updateDisplayedEmails = (allEmails: MailboxEmail[], count: number) : void => {
|
||||
const displayed = allEmails.slice(0, count);
|
||||
setDisplayedEmails(displayed);
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads more emails.
|
||||
*/
|
||||
const loadMoreEmails = (): void => {
|
||||
const newCount = Math.min(displayedCount + emailsPerLoad, emails.length);
|
||||
setDisplayedCount(newCount);
|
||||
updateDisplayedEmails(emails, newCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the email is a public domain.
|
||||
*/
|
||||
@@ -31,14 +60,32 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
return publicEmailDomains.some(domain => emailAddress.toLowerCase().endsWith(domain));
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the email is a private domain.
|
||||
*/
|
||||
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));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Loads the latest emails from the server and decrypts them locally if needed.
|
||||
*/
|
||||
const loadEmails = async (): Promise<void> => {
|
||||
try {
|
||||
setError(null);
|
||||
const isPublic = await isPublicDomain(email);
|
||||
const isPrivate = await isPrivateDomain(email);
|
||||
const isSupported = isPublic || isPrivate;
|
||||
|
||||
setIsSpamOk(isPublic);
|
||||
setIsSupportedDomain(isSupported);
|
||||
|
||||
if (!isSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPublic) {
|
||||
// For public domains (SpamOK), use the SpamOK API directly
|
||||
@@ -49,45 +96,82 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
'X-Asdasd-Platform-Version': AppInfo.VERSION,
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// Only show the latest 2 emails to save space in UI
|
||||
const latestMails = data?.mails
|
||||
?.toSorted((a: MailboxEmail, b: MailboxEmail) =>
|
||||
new Date(b.dateSystem).getTime() - new Date(a.dateSystem).getTime())
|
||||
?.slice(0, 2) ?? [];
|
||||
|
||||
if (loading && latestMails.length > 0) {
|
||||
setLastEmailId(latestMails[0].id);
|
||||
if (!response.ok) {
|
||||
setError(t('emails.errors.emailLoadError'));
|
||||
return;
|
||||
}
|
||||
|
||||
setEmails(latestMails);
|
||||
} else {
|
||||
// For private domains, use existing encrypted email logic
|
||||
const response = await webApi.get(`EmailBox/${email}`);
|
||||
const data = response as { mails: MailboxEmail[] };
|
||||
const data = await response.json();
|
||||
|
||||
// Only show the latest 2 emails to save space in UI
|
||||
const latestMails = data.mails
|
||||
.toSorted((a, b) => new Date(b.dateSystem).getTime() - new Date(a.dateSystem).getTime())
|
||||
.slice(0, 2);
|
||||
// Store all emails, sorted by date
|
||||
const allMails = data?.mails
|
||||
?.toSorted((a: MailboxEmail, b: MailboxEmail) =>
|
||||
new Date(b.dateSystem).getTime() - new Date(a.dateSystem).getTime()) ?? [];
|
||||
|
||||
if (latestMails) {
|
||||
// Loop through all emails and decrypt them locally
|
||||
const decryptedEmails: MailboxEmail[] = await EncryptionUtility.decryptEmailList(
|
||||
latestMails,
|
||||
dbContext.sqliteClient!.getAllEncryptionKeys()
|
||||
);
|
||||
if (loading && allMails.length > 0) {
|
||||
setLastEmailId(allMails[0].id);
|
||||
}
|
||||
|
||||
if (loading && decryptedEmails.length > 0) {
|
||||
setLastEmailId(decryptedEmails[0].id);
|
||||
// Only update emails if they actually changed to preserve displayedCount
|
||||
setEmails(prevEmails => {
|
||||
const emailsChanged = JSON.stringify(prevEmails.map(e => e.id)) !== JSON.stringify(allMails.map(e => e.id));
|
||||
if (emailsChanged) {
|
||||
updateDisplayedEmails(allMails, displayedCount);
|
||||
return allMails;
|
||||
}
|
||||
return prevEmails;
|
||||
});
|
||||
} else if (isPrivate) {
|
||||
// For private domains, use existing encrypted email logic
|
||||
try {
|
||||
/**
|
||||
* We use authFetch here because we don't want to the inner method to throw an error if HTTP status is not 200.
|
||||
* Instead we want to catch the error ourselves.
|
||||
*/
|
||||
const response = await webApi.authFetch(`EmailBox/${email}`, { method: 'GET' }, true, false);
|
||||
try {
|
||||
const data = response as { mails: MailboxEmail[] };
|
||||
|
||||
setEmails(decryptedEmails);
|
||||
// Store all emails, sorted by date
|
||||
const allMails = data.mails
|
||||
.toSorted((a, b) => new Date(b.dateSystem).getTime() - new Date(a.dateSystem).getTime());
|
||||
|
||||
if (allMails) {
|
||||
// Loop through all emails and decrypt them locally
|
||||
const decryptedEmails: MailboxEmail[] = await EncryptionUtility.decryptEmailList(
|
||||
allMails,
|
||||
dbContext.sqliteClient!.getAllEncryptionKeys()
|
||||
);
|
||||
|
||||
if (loading && decryptedEmails.length > 0) {
|
||||
setLastEmailId(decryptedEmails[0].id);
|
||||
}
|
||||
|
||||
// Only update emails if they actually changed to preserve displayedCount
|
||||
setEmails(prevEmails => {
|
||||
const emailsChanged = JSON.stringify(prevEmails.map(e => e.id)) !== JSON.stringify(decryptedEmails.map(e => e.id));
|
||||
if (emailsChanged) {
|
||||
updateDisplayedEmails(decryptedEmails, displayedCount);
|
||||
return decryptedEmails;
|
||||
}
|
||||
return prevEmails;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Try to parse as error response instead
|
||||
const apiErrorResponse = response as ApiErrorResponse;
|
||||
setError(t('emails.apiErrors.' + apiErrorResponse?.code));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setError(t('emails.errors.emailLoadError'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading emails:', err);
|
||||
setError(t('emails.errors.emailUnexpectedError'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -96,16 +180,34 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
// Set up auto-refresh interval
|
||||
const interval = setInterval(loadEmails, 2000);
|
||||
return () : void => clearInterval(interval);
|
||||
}, [email, loading, webApi, dbContext]);
|
||||
}, [email, loading, webApi, dbContext, t, displayedCount]);
|
||||
|
||||
// Don't render anything if the domain is not supported
|
||||
if (!isSupportedDomain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('common.recentEmails')}</h2>
|
||||
</div>
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Recent emails</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('common.recentEmails')}</h2>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
Loading emails...
|
||||
{t('common.loadingEmails')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -113,10 +215,10 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
return (
|
||||
<div className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Recent emails</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('common.recentEmails')}</h2>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
No emails received yet.
|
||||
{t('emails.noEmails')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -124,11 +226,11 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
return (
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Recent emails</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('common.recentEmails')}</h2>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
|
||||
{emails.map((mail) => (
|
||||
{displayedEmails.map((mail) => (
|
||||
isSpamOk ? (
|
||||
<a
|
||||
key={mail.id}
|
||||
@@ -167,6 +269,18 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
</Link>
|
||||
)
|
||||
))}
|
||||
|
||||
{canLoadMore && (
|
||||
<button
|
||||
onClick={loadMoreEmails}
|
||||
className="w-full mt-2 py-1 px-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md transition-colors duration-200 text-sm text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100 flex items-center justify-center gap-1"
|
||||
>
|
||||
<span>{t('common.loadMore')}</span>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Button configuration for form input.
|
||||
*/
|
||||
type FormInputButton = {
|
||||
icon: string;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon component for form input buttons.
|
||||
*/
|
||||
const Icon: React.FC<{ name: string }> = ({ name }) => {
|
||||
switch (name) {
|
||||
case 'visibility':
|
||||
return (
|
||||
<>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</>
|
||||
);
|
||||
case 'visibility-off':
|
||||
return (
|
||||
<>
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</>
|
||||
);
|
||||
case 'refresh':
|
||||
return (
|
||||
<>
|
||||
<path d="M23 4v6h-6" />
|
||||
<path d="M1 20v-6h6" />
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
</>
|
||||
);
|
||||
case 'settings':
|
||||
return (
|
||||
<>
|
||||
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Form input props.
|
||||
*/
|
||||
type FormInputProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
type?: 'text' | 'password';
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
error?: string;
|
||||
buttons?: FormInputButton[];
|
||||
showPassword?: boolean;
|
||||
onShowPasswordChange?: (show: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form input component.
|
||||
*/
|
||||
export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
required = false,
|
||||
multiline = false,
|
||||
rows = 1,
|
||||
error,
|
||||
buttons = [],
|
||||
showPassword: controlledShowPassword,
|
||||
onShowPasswordChange
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [internalShowPassword, setInternalShowPassword] = React.useState(false);
|
||||
|
||||
/**
|
||||
* Use controlled or uncontrolled showPassword state.
|
||||
* If controlledShowPassword is provided, use that value and call onShowPasswordChange.
|
||||
* Otherwise, use internal state.
|
||||
*/
|
||||
const showPassword = controlledShowPassword !== undefined ? controlledShowPassword : internalShowPassword;
|
||||
|
||||
/**
|
||||
* Set the showPassword state.
|
||||
* If controlledShowPassword is provided, use that value and call onShowPasswordChange.
|
||||
* Otherwise, use internal state.
|
||||
*/
|
||||
const setShowPassword = (value: boolean): void => {
|
||||
if (controlledShowPassword !== undefined) {
|
||||
onShowPasswordChange?.(value);
|
||||
} else {
|
||||
setInternalShowPassword(value);
|
||||
}
|
||||
};
|
||||
|
||||
const inputClasses = `mt-1 block w-full rounded-md ${
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
} text-gray-900 sm:text-sm rounded-lg shadow-sm border focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400 py-2 px-3`;
|
||||
|
||||
// Add password visibility button if type is password
|
||||
const allButtons = type === 'password'
|
||||
? [...buttons, {
|
||||
icon: showPassword ? 'visibility-off' : 'visibility',
|
||||
/**
|
||||
* Toggle password visibility.
|
||||
*/
|
||||
onClick: (): void => setShowPassword(!showPassword),
|
||||
title: showPassword ? t('common.hidePassword') : t('common.showPassword')
|
||||
}]
|
||||
: buttons;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
{multiline ? (
|
||||
<textarea
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
className={inputClasses}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type === 'password' && !showPassword ? 'password' : 'text'}
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={inputClasses}
|
||||
/>
|
||||
)}
|
||||
{allButtons.length > 0 && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||
{allButtons.map((button, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
onClick={button.onClick}
|
||||
title={button.title}
|
||||
className="p-1 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<Icon name={button.icon} />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
FormInput.displayName = 'FormInput';
|
||||
@@ -1,4 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { ClipboardCopyService } from '@/entrypoints/popup/utils/ClipboardCopyService';
|
||||
|
||||
/**
|
||||
@@ -13,6 +16,43 @@ type FormInputCopyToClipboardProps = {
|
||||
|
||||
const clipboardService = new ClipboardCopyService();
|
||||
|
||||
/**
|
||||
* Icon component for form input buttons.
|
||||
*/
|
||||
const Icon: React.FC<{ name: string }> = ({ name }) => {
|
||||
switch (name) {
|
||||
case 'visibility':
|
||||
return (
|
||||
<>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</>
|
||||
);
|
||||
case 'visibility-off':
|
||||
return (
|
||||
<>
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</>
|
||||
);
|
||||
case 'copy':
|
||||
return (
|
||||
<>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</>
|
||||
);
|
||||
case 'check':
|
||||
return (
|
||||
<>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Form input copy to clipboard component.
|
||||
*/
|
||||
@@ -22,6 +62,7 @@ export const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> =
|
||||
value,
|
||||
type = 'text'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -41,6 +82,9 @@ export const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> =
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
clipboardService.setCopied(id);
|
||||
|
||||
// Notify background script that clipboard was copied
|
||||
await sendMessage('CLIPBOARD_COPIED', { value }, 'background');
|
||||
|
||||
// Reset copied state after 2 seconds
|
||||
setTimeout(() => {
|
||||
@@ -70,17 +114,38 @@ export const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> =
|
||||
} text-gray-900 sm:text-sm rounded-lg shadow-sm focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400`}
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||
{copied && (
|
||||
<span className="text-green-500 dark:text-green-400">
|
||||
Copied!
|
||||
</span>
|
||||
{copied ? (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-green-500 dark:text-green-400 transition-colors duration-200"
|
||||
title={t('common.copied')}
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<Icon name="check" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyToClipboard}
|
||||
className="p-1 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
|
||||
title={t('common.copyToClipboard')}
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<Icon name="copy" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{type === 'password' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
|
||||
className="p-1 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
|
||||
title={showPassword ? t('common.hidePassword') : t('common.showPassword')}
|
||||
>
|
||||
{showPassword ? 'Hide' : 'Show'}
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<Icon name={showPassword ? 'visibility-off' : 'visibility'} />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
|
||||
/**
|
||||
* Global state change handler component which listens for global state changes and e.g. redirects user to login
|
||||
* page if login state changes.
|
||||
*/
|
||||
const GlobalStateChangeHandler: React.FC = () => {
|
||||
const authContext = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const lastLoginState = useRef(authContext.isLoggedIn);
|
||||
const initialRender = useRef(true);
|
||||
|
||||
/**
|
||||
* Listen for auth logged in changes and redirect to home page if logged in state changes to handle logins and logouts.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Only navigate when auth state is different from the last state we acted on.
|
||||
if (lastLoginState.current !== authContext.isLoggedIn) {
|
||||
lastLoginState.current = authContext.isLoggedIn;
|
||||
|
||||
/**
|
||||
* Skip the first auth state change to avoid redirecting when popup opens for the first time
|
||||
* which already causes the auth state to change from false to true.
|
||||
*/
|
||||
if (initialRender.current) {
|
||||
initialRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to home page if logged in state changes.
|
||||
navigate('/');
|
||||
}
|
||||
}, [authContext.isLoggedIn]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default GlobalStateChangeHandler;
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
import { HeaderIcon, HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
|
||||
type HeaderButtonProps = {
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
iconType: HeaderIconType;
|
||||
variant?: 'default' | 'primary' | 'danger';
|
||||
};
|
||||
|
||||
/**
|
||||
* Header button component for consistent header button styling
|
||||
*/
|
||||
const HeaderButton: React.FC<HeaderButtonProps> = ({
|
||||
onClick,
|
||||
title,
|
||||
iconType,
|
||||
variant = 'default'
|
||||
}) => {
|
||||
const colorClasses = {
|
||||
default: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||
primary: 'text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/20',
|
||||
danger: 'text-red-500 hover:text-red-600 hover:bg-red-100 dark:hover:bg-red-900/20'
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`p-2 rounded-lg ${colorClasses[variant]}`}
|
||||
title={title}
|
||||
>
|
||||
<HeaderIcon type={iconType} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderButton;
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type HelpModalProps = {
|
||||
titleKey: string;
|
||||
contentKey: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable help modal component with a question mark icon button.
|
||||
* Shows a modal popup with help information when clicked.
|
||||
*/
|
||||
const HelpModal: React.FC<HelpModalProps> = ({ titleKey, contentKey, className = '' }) => {
|
||||
const { t } = useTranslation();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className={`${className}`}
|
||||
type="button"
|
||||
aria-label="Help"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-help"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm w-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t(titleKey)}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{t(contentKey)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="mt-4 w-full px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-md transition-colors"
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpModal;
|
||||
@@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
|
||||
export enum HeaderIconType {
|
||||
EXPAND = 'expand',
|
||||
EDIT = 'edit',
|
||||
DELETE = 'delete',
|
||||
SETTINGS = 'settings',
|
||||
RELOAD = 'reload',
|
||||
EXTERNAL_LINK = 'external_link',
|
||||
SAVE = 'save',
|
||||
PLUS = 'plus',
|
||||
TAB = 'tab'
|
||||
}
|
||||
|
||||
type HeaderIconProps = {
|
||||
type: HeaderIconType;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Component to render header icons
|
||||
*/
|
||||
export const HeaderIcon: React.FC<HeaderIconProps> = ({ type, className = 'w-5 h-5' }) => {
|
||||
const icons = {
|
||||
[HeaderIconType.EXPAND]: (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
[HeaderIconType.EDIT]: (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
[HeaderIconType.DELETE]: (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
[HeaderIconType.SETTINGS]: (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
[HeaderIconType.RELOAD]: (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
[HeaderIconType.EXTERNAL_LINK]: (
|
||||
<svg
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
|
||||
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
|
||||
</svg>
|
||||
),
|
||||
[HeaderIconType.SAVE]: (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V7l-4-4z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 3v5h10"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 12a2 2 0 100 4 2 2 0 000-4z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
[HeaderIconType.PLUS]: (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
),
|
||||
[HeaderIconType.TAB]: (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 5a2 2 0 012-2h4a2 2 0 012 2v2H8V5z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
|
||||
return icons[type] || null;
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AVAILABLE_LANGUAGES, getLanguageConfig, ILanguageConfig } from '../../../i18n/config';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
type LanguageSwitcherProps = {
|
||||
variant?: 'dropdown' | 'buttons';
|
||||
size?: 'sm' | 'md';
|
||||
};
|
||||
|
||||
/**
|
||||
* Language switcher component that allows users to switch between supported languages
|
||||
* @param props - Component props including variant and size
|
||||
* @returns JSX element for the language switcher
|
||||
*/
|
||||
const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({
|
||||
variant = 'dropdown',
|
||||
size = 'md'
|
||||
}): React.JSX.Element => {
|
||||
const { i18n } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentLanguage = getLanguageConfig(i18n.language) || AVAILABLE_LANGUAGES[0];
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect((): (() => void) => {
|
||||
/**
|
||||
* Handle clicks outside the dropdown to close it
|
||||
* @param event - Mouse event
|
||||
*/
|
||||
const handleClickOutside = (event: MouseEvent): void => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Change the application language
|
||||
* @param lng - Language code to switch to
|
||||
*/
|
||||
const changeLanguage = async (lng: string): Promise<void> => {
|
||||
await i18n.changeLanguage(lng);
|
||||
await storage.setItem('local:language', lng);
|
||||
|
||||
setIsOpen(false);
|
||||
|
||||
// Force immediate re-render by dispatching the event that react-i18next listens to
|
||||
i18n.emit('languageChanged', lng);
|
||||
};
|
||||
|
||||
if (variant === 'buttons') {
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
{AVAILABLE_LANGUAGES.map((lang: ILanguageConfig) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
className={`flex items-center space-x-1 px-2 py-1 text-xs rounded transition-colors ${
|
||||
i18n.language === lang.code
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-500'
|
||||
}`}
|
||||
title={lang.nativeName}
|
||||
>
|
||||
<span className="text-sm">{lang.flag}</span>
|
||||
<span>{lang.code.toUpperCase()}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`w-full flex items-center justify-between px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors ${
|
||||
size === 'sm' ? 'text-sm' : 'text-base'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-lg">{currentLanguage.flag}</span>
|
||||
<span>{currentLanguage.nativeName}</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 w-full bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg z-50">
|
||||
{AVAILABLE_LANGUAGES.map((lang: ILanguageConfig) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors first:rounded-t-lg last:rounded-b-lg ${
|
||||
size === 'sm' ? 'text-sm' : 'text-base'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-lg">{lang.flag}</span>
|
||||
<span className="text-gray-700 dark:text-gray-200">{lang.nativeName}</span>
|
||||
</div>
|
||||
{i18n.language === lang.code && (
|
||||
<svg className="w-4 h-4 text-primary-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitcher;
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
type TabName = 'credentials' | 'emails' | 'settings';
|
||||
|
||||
@@ -9,17 +8,20 @@ type TabName = 'credentials' | 'emails' | 'settings';
|
||||
* Bottom nav component.
|
||||
*/
|
||||
const BottomNav: React.FC = () => {
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [currentTab, setCurrentTab] = useState<TabName>('credentials');
|
||||
|
||||
// Add effect to update currentTab based on route
|
||||
useEffect(() => {
|
||||
const path = location.pathname.substring(1) as TabName;
|
||||
if (['credentials', 'emails', 'settings'].includes(path)) {
|
||||
setCurrentTab(path);
|
||||
const path = location.pathname.substring(1); // Remove leading slash
|
||||
const tabNames: TabName[] = ['credentials', 'emails', 'settings'];
|
||||
|
||||
// Find the first tab name that matches the start of the path
|
||||
const matchingTab = tabNames.find(tab => path === tab || path.startsWith(`${tab}/`));
|
||||
if (matchingTab) {
|
||||
setCurrentTab(matchingTab);
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
@@ -31,7 +33,11 @@ const BottomNav: React.FC = () => {
|
||||
navigate(`/${tab}`);
|
||||
};
|
||||
|
||||
if (!authContext.isLoggedIn || !dbContext.dbAvailable) {
|
||||
// Auth pages that don't show bottom navigation but still show header
|
||||
const authPages = ['/login', '/auth-settings', '/unlock', '/unlock-success', '/upgrade'];
|
||||
const isAuthPage = authPages.includes(location.pathname);
|
||||
|
||||
if (isAuthPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -56,7 +62,7 @@ const BottomNav: React.FC = () => {
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<span className="text-xs mt-1">Credentials</span>
|
||||
<span className="text-xs mt-1">{t('menu.credentials')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('emails')}
|
||||
@@ -67,7 +73,7 @@ const BottomNav: React.FC = () => {
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="text-xs mt-1">Emails</span>
|
||||
<span className="text-xs mt-1">{t('menu.emails')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('settings')}
|
||||
@@ -79,7 +85,7 @@ const BottomNav: React.FC = () => {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span className="text-xs mt-1">Settings</span>
|
||||
<span className="text-xs mt-1">{t('menu.settings')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { storage } from '#imports';
|
||||
import { UserMenu } from '@/entrypoints/popup/components/Layout/UserMenu';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { AppInfo } from '@/utils/AppInfo';
|
||||
|
||||
/**
|
||||
* Header props.
|
||||
@@ -14,31 +13,21 @@ type HeaderProps = {
|
||||
showBackButton?: boolean;
|
||||
title?: string;
|
||||
}[];
|
||||
rightButtons?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header component.
|
||||
*/
|
||||
const Header: React.FC<HeaderProps> = ({
|
||||
routes = []
|
||||
routes = [],
|
||||
rightButtons
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const authContext = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
/**
|
||||
* Open the client tab.
|
||||
*/
|
||||
const openClientTab = async () : Promise<void> => {
|
||||
const settingClientUrl = await storage.getItem('local:clientUrl') as string;
|
||||
let clientUrl = AppInfo.DEFAULT_CLIENT_URL;
|
||||
if (settingClientUrl && settingClientUrl.length > 0) {
|
||||
clientUrl = settingClientUrl;
|
||||
}
|
||||
|
||||
window.open(clientUrl, '_blank');
|
||||
};
|
||||
|
||||
// Updated route matching logic to handle URL parameters
|
||||
const currentRoute = routes?.find(route => {
|
||||
// Convert route pattern to regex
|
||||
@@ -58,6 +47,11 @@ const Header: React.FC<HeaderProps> = ({
|
||||
* Handle logo click.
|
||||
*/
|
||||
const logoClick = () : void => {
|
||||
// Don't navigate if on upgrade page or login page
|
||||
if (location.pathname === '/upgrade' || location.pathname === '/login' || location.pathname === '/unlock') {
|
||||
return;
|
||||
}
|
||||
|
||||
// If logged in, navigate to credentials.
|
||||
if (authContext.isLoggedIn) {
|
||||
navigate('/credentials');
|
||||
@@ -94,7 +88,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
className="flex items-center hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img src="/assets/images/logo.svg" alt="AliasVault" className="h-8 w-8 mr-2" />
|
||||
<h1 className="text-gray-900 dark:text-white text-xl font-bold">AliasVault</h1>
|
||||
<h1 className="text-gray-900 dark:text-white text-xl font-bold">{t('common.appName')}</h1>
|
||||
{/* Hide beta badge on Safari as it's not allowed to show non-production badges */}
|
||||
{!import.meta.env.SAFARI && (
|
||||
<span className="text-primary-500 text-[10px] ml-1 font-normal">BETA</span>
|
||||
@@ -105,33 +99,25 @@ const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
<div className="flex-grow" />
|
||||
|
||||
<div className="flex items-center">
|
||||
{!currentRoute?.showBackButton ? (
|
||||
<button
|
||||
onClick={openClientTab}
|
||||
className="p-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
|
||||
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (<></>)}
|
||||
<div className="flex items-center gap-2">
|
||||
{!authContext.isLoggedIn ? (
|
||||
<>
|
||||
{rightButtons}
|
||||
<button
|
||||
id="settings"
|
||||
onClick={(handleSettings)}
|
||||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span className="sr-only">{t('common.settings')}</span>
|
||||
<svg className="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
rightButtons
|
||||
)}
|
||||
</div>
|
||||
{!authContext.isLoggedIn ? (
|
||||
<button
|
||||
id="settings"
|
||||
onClick={(handleSettings)}
|
||||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span className="sr-only">Settings</span>
|
||||
<svg className="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<UserMenu />
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
|
||||
/**
|
||||
* User menu component.
|
||||
*/
|
||||
export const UserMenu: React.FC = () => {
|
||||
const authContext = useAuth();
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const { showLoading, hideLoading } = useLoading();
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Handle clicking outside the user menu.
|
||||
*/
|
||||
const handleClickOutside = (event: MouseEvent) : void => {
|
||||
if (
|
||||
menuRef.current &&
|
||||
buttonRef.current &&
|
||||
!menuRef.current.contains(event.target as Node) &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsUserMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () : void => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Toggle the user menu.
|
||||
*/
|
||||
const toggleUserMenu = () : void => {
|
||||
setIsUserMenuOpen(!isUserMenuOpen);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle logging out.
|
||||
*/
|
||||
const onLogout = async () : Promise<void> => {
|
||||
showLoading();
|
||||
navigate('/logout', { replace: true });
|
||||
hideLoading();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={toggleUserMenu}
|
||||
className="flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<svg className="w-6 h-6" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isUserMenuOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute right-0 z-50 mt-2 w-48 py-1 bg-white rounded-lg shadow-lg dark:bg-gray-700 border border-gray-200 dark:border-gray-600"
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-600">
|
||||
<span className="block text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{authContext.username}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:text-red-400 dark:hover:bg-gray-600"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserMenu;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { storage } from '#imports';
|
||||
import { AppInfo } from '@/utils/AppInfo';
|
||||
|
||||
import { useApiUrl } from '@/entrypoints/popup/utils/ApiUrlUtility';
|
||||
|
||||
/**
|
||||
* Component for displaying the login server information.
|
||||
*/
|
||||
const LoginServerInfo: React.FC = () => {
|
||||
const [baseUrl, setBaseUrl] = useState<string>('');
|
||||
const { loadApiUrl, getDisplayUrl } = useApiUrl();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Loads the base URL for the login server.
|
||||
*/
|
||||
const loadApiUrl = async () : Promise<void> => {
|
||||
const apiUrl = await storage.getItem('local:apiUrl') as string;
|
||||
setBaseUrl(apiUrl ?? AppInfo.DEFAULT_API_URL);
|
||||
};
|
||||
loadApiUrl();
|
||||
}, []);
|
||||
|
||||
const isDefaultServer = !baseUrl || baseUrl === AppInfo.DEFAULT_API_URL;
|
||||
const displayUrl = isDefaultServer ? 'aliasvault.net' : new URL(baseUrl).hostname;
|
||||
}, [loadApiUrl]);
|
||||
|
||||
/**
|
||||
* Handles the click event for the login server information.
|
||||
@@ -33,13 +28,13 @@ const LoginServerInfo: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mb-4">
|
||||
(Connecting to{' '}
|
||||
({t('auth.connectingTo')}{' '}
|
||||
<button
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
className="text-orange-500 hover:text-orange-600 dark:text-orange-400 dark:hover:text-orange-500 underline"
|
||||
>
|
||||
{displayUrl}
|
||||
{getDisplayUrl()}
|
||||
</button>)
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface IModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'danger' | 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable modal component for confirmations and alerts.
|
||||
*/
|
||||
const Modal: React.FC<IModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = '',
|
||||
cancelText = '',
|
||||
variant = 'default'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const confirmButtonClass = variant === 'danger'
|
||||
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
||||
: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500';
|
||||
|
||||
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} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all w-full max-w-lg">
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-4 top-4 text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||
onClick={onClose}
|
||||
>
|
||||
<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="sm:flex sm:items-start">
|
||||
{variant === 'danger' && (
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg className="h-6 w-6 text-red-600 dark:text-red-200" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||
<h3 className="text-lg font-semibold leading-6 text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
{confirmText && (
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex w-full justify-center rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto ${confirmButtonClass}`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
)}
|
||||
{cancelText && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 inline-flex w-full 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 sm:mt-0 sm:w-auto"
|
||||
onClick={onClose}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@@ -0,0 +1,218 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { PasswordSettings } from '@/utils/dist/shared/models/vault';
|
||||
import { CreatePasswordGenerator } from '@/utils/dist/shared/password-generator';
|
||||
|
||||
interface IPasswordConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (password: string) => void;
|
||||
onSettingsChange?: (settings: PasswordSettings) => void;
|
||||
initialSettings: PasswordSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Password configuration dialog component.
|
||||
*/
|
||||
const PasswordConfigDialog: React.FC<IPasswordConfigDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
onSettingsChange,
|
||||
initialSettings
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [settings, setSettings] = useState<PasswordSettings>(initialSettings);
|
||||
const [previewPassword, setPreviewPassword] = useState<string>('');
|
||||
|
||||
const generatePreview = useCallback((currentSettings: PasswordSettings) => {
|
||||
try {
|
||||
const passwordGenerator = CreatePasswordGenerator(currentSettings);
|
||||
const password = passwordGenerator.generateRandomPassword();
|
||||
setPreviewPassword(password);
|
||||
} catch (error) {
|
||||
console.error('Error generating preview password:', error);
|
||||
setPreviewPassword('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize settings when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSettings({ ...initialSettings });
|
||||
generatePreview({ ...initialSettings });
|
||||
}
|
||||
}, [isOpen, initialSettings, generatePreview]);
|
||||
|
||||
const handleSettingChange = useCallback((key: keyof PasswordSettings, value: boolean | number) => {
|
||||
const newSettings = { ...settings, [key]: value };
|
||||
setSettings(newSettings);
|
||||
generatePreview(newSettings);
|
||||
onSettingsChange?.(newSettings);
|
||||
}, [settings, generatePreview, onSettingsChange]);
|
||||
|
||||
const handleRefreshPreview = useCallback(() => {
|
||||
generatePreview(settings);
|
||||
}, [settings, generatePreview]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(previewPassword);
|
||||
onClose();
|
||||
}, [previewPassword, onSave, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 bg-black bg-opacity-60 transition-opacity" onClick={handleCancel} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all w-full max-w-lg">
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-4 top-4 text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Modal content */}
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="w-full mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">
|
||||
{t('credentials.changePasswordComplexity')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Password Preview */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={previewPassword}
|
||||
readOnly
|
||||
className="flex-1 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:text-white font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefreshPreview}
|
||||
className="px-3 py-2 text-sm text-gray-500 dark:text-white bg-gray-200 hover:bg-gray-300 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800"
|
||||
title={t('credentials.generateNewPreview')}
|
||||
>
|
||||
<svg className="w-4 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Character Type Toggle Buttons */}
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* Lowercase Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSettingChange('UseLowercase', !settings.UseLowercase)}
|
||||
className={`flex items-center justify-center px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
settings.UseLowercase
|
||||
? 'bg-primary-600 text-white hover:bg-primary-700'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
title={t('credentials.includeLowercase')}
|
||||
>
|
||||
<span className="font-mono text-base">a-z</span>
|
||||
</button>
|
||||
|
||||
{/* Uppercase Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSettingChange('UseUppercase', !settings.UseUppercase)}
|
||||
className={`flex items-center justify-center px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
settings.UseUppercase
|
||||
? 'bg-primary-600 text-white hover:bg-primary-700'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
title={t('credentials.includeUppercase')}
|
||||
>
|
||||
<span className="font-mono text-base">A-Z</span>
|
||||
</button>
|
||||
|
||||
{/* Numbers Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSettingChange('UseNumbers', !settings.UseNumbers)}
|
||||
className={`flex items-center justify-center px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
settings.UseNumbers
|
||||
? 'bg-primary-600 text-white hover:bg-primary-700'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
title={t('credentials.includeNumbers')}
|
||||
>
|
||||
<span className="font-mono text-base">0-9</span>
|
||||
</button>
|
||||
|
||||
{/* Special Characters Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSettingChange('UseSpecialChars', !settings.UseSpecialChars)}
|
||||
className={`flex items-center justify-center px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
settings.UseSpecialChars
|
||||
? 'bg-primary-600 text-white hover:bg-primary-700'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
title={t('credentials.includeSpecialChars')}
|
||||
>
|
||||
<span className="font-mono text-base">!@#</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Avoid Ambiguous Characters - Checkbox */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="use-non-ambiguous"
|
||||
type="checkbox"
|
||||
checked={settings.UseNonAmbiguousChars}
|
||||
onChange={(e) => handleSettingChange('UseNonAmbiguousChars', e.target.checked)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<label htmlFor="use-non-ambiguous" className="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{t('credentials.avoidAmbiguousChars')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-5 sm:mt-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full justify-center items-center gap-1 rounded-md bg-gray-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-gray-500 sm:ml-3 sm:w-auto"
|
||||
onClick={handleSave}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 13l-3 3m0 0l-3-3m3 3V8m0 13a9 9 0 110-18 9 9 0 010 18z" />
|
||||
</svg>
|
||||
{t('common.use')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordConfigDialog;
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { PasswordSettings } from '@/utils/dist/shared/models/vault';
|
||||
import { CreatePasswordGenerator } from '@/utils/dist/shared/password-generator';
|
||||
|
||||
import PasswordConfigDialog from './PasswordConfigDialog';
|
||||
|
||||
interface IPasswordFieldProps {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
showPassword?: boolean;
|
||||
onShowPasswordChange?: (show: boolean) => void;
|
||||
initialSettings: PasswordSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Password field component with inline length slider and advanced configuration.
|
||||
*/
|
||||
const PasswordField: React.FC<IPasswordFieldProps> = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
error,
|
||||
showPassword: controlledShowPassword,
|
||||
onShowPasswordChange,
|
||||
initialSettings
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [internalShowPassword, setInternalShowPassword] = useState(false);
|
||||
const [showConfigDialog, setShowConfigDialog] = useState(false);
|
||||
const [currentSettings, setCurrentSettings] = useState<PasswordSettings>(initialSettings);
|
||||
|
||||
// Use controlled or uncontrolled showPassword state
|
||||
const showPassword = controlledShowPassword !== undefined ? controlledShowPassword : internalShowPassword;
|
||||
|
||||
/**
|
||||
* Set the showPassword state.
|
||||
*/
|
||||
const setShowPassword = useCallback((show: boolean): void => {
|
||||
if (controlledShowPassword !== undefined) {
|
||||
onShowPasswordChange?.(show);
|
||||
} else {
|
||||
setInternalShowPassword(show);
|
||||
}
|
||||
}, [controlledShowPassword, onShowPasswordChange]);
|
||||
|
||||
// Initialize settings only once when component mounts
|
||||
useEffect(() => {
|
||||
setCurrentSettings({ ...initialSettings });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Only run on mount to avoid resetting user changes
|
||||
|
||||
const generatePassword = useCallback((settings: PasswordSettings) => {
|
||||
try {
|
||||
const passwordGenerator = CreatePasswordGenerator(settings);
|
||||
const password = passwordGenerator.generateRandomPassword();
|
||||
onChange(password);
|
||||
setShowPassword(true);
|
||||
} catch (error) {
|
||||
console.error('Error generating password:', error);
|
||||
}
|
||||
}, [onChange, setShowPassword]);
|
||||
|
||||
const handleLengthChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const length = parseInt(e.target.value, 10);
|
||||
const newSettings = { ...currentSettings, Length: length };
|
||||
setCurrentSettings(newSettings);
|
||||
|
||||
// Always generate password when length changes
|
||||
generatePassword(newSettings);
|
||||
}, [currentSettings, generatePassword]);
|
||||
|
||||
const handleRegeneratePassword = useCallback(() => {
|
||||
generatePassword(currentSettings);
|
||||
}, [generatePassword, currentSettings]);
|
||||
|
||||
const handleConfiguredPassword = useCallback((password: string) => {
|
||||
onChange(password);
|
||||
setShowPassword(true);
|
||||
}, [onChange, setShowPassword]);
|
||||
|
||||
const handleAdvancedSettingsChange = useCallback((newSettings: PasswordSettings) => {
|
||||
setCurrentSettings(newSettings);
|
||||
}, []);
|
||||
|
||||
const togglePasswordVisibility = useCallback(() => {
|
||||
setShowPassword(!showPassword);
|
||||
}, [showPassword, setShowPassword]);
|
||||
|
||||
const openConfigDialog = useCallback(() => {
|
||||
setShowConfigDialog(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Label */}
|
||||
<label htmlFor={id} className="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
{label}
|
||||
</label>
|
||||
|
||||
{/* Password Input with Buttons */}
|
||||
<div className="flex">
|
||||
<div className="relative flex-grow">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="outline-0 shadow-sm border border-gray-300 bg-gray-50 text-gray-900 sm:text-sm rounded-l-lg block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{/* Show/Hide Password Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePasswordVisibility}
|
||||
className="px-3 text-gray-500 dark:text-white bg-gray-200 hover:bg-gray-300 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium text-sm dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800"
|
||||
title={showPassword ? t('common.hidePassword') : t('common.showPassword')}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{showPassword ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
) : (
|
||||
<>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Generate Password Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRegeneratePassword}
|
||||
className="px-3 text-gray-500 dark:text-white bg-gray-200 hover:bg-gray-300 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-r-lg text-sm border-l border-gray-300 dark:border-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800"
|
||||
title={t('credentials.generateRandomPassword')}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline Password Length Slider */}
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label htmlFor={`${id}-length`} className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('credentials.passwordLength')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">
|
||||
{currentSettings.Length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openConfigDialog}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
title={t('credentials.changePasswordComplexity')}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
id={`${id}-length`}
|
||||
min="8"
|
||||
max="64"
|
||||
value={currentSettings.Length}
|
||||
onChange={handleLengthChange}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Advanced Configuration Dialog */}
|
||||
<PasswordConfigDialog
|
||||
isOpen={showConfigDialog}
|
||||
onClose={() => setShowConfigDialog(false)}
|
||||
onSave={handleConfiguredPassword}
|
||||
onSettingsChange={handleAdvancedSettingsChange}
|
||||
initialSettings={currentSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordField;
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface IUsernameFieldProps {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
onRegenerate: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Username field component with regenerate functionality.
|
||||
*/
|
||||
const UsernameField: React.FC<IUsernameFieldProps> = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
error,
|
||||
onRegenerate
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
}, [onChange]);
|
||||
|
||||
const handleRegenerate = useCallback(() => {
|
||||
onRegenerate();
|
||||
}, [onRegenerate]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Label */}
|
||||
<label htmlFor={id} className="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
{label}
|
||||
</label>
|
||||
|
||||
{/* Username Input with Button */}
|
||||
<div className="flex">
|
||||
<div className="relative flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
className="outline-0 shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-l-lg block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{/* Generate Username Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRegenerate}
|
||||
className="px-3 text-gray-500 dark:text-white bg-gray-200 hover:bg-gray-300 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-r-lg text-sm dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800"
|
||||
title={t('credentials.generateRandomUsername')}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsernameField;
|
||||
@@ -1,13 +1,17 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { storage } from '#imports';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
import { VAULT_LOCKED_DISMISS_UNTIL_KEY } from '@/utils/Constants';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
type AuthContextType = {
|
||||
isLoggedIn: boolean;
|
||||
isInitialized: boolean;
|
||||
username: string | null;
|
||||
initializeAuth: () => Promise<{ isLoggedIn: boolean }>;
|
||||
setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise<void>;
|
||||
login: () => Promise<void>;
|
||||
logout: (errorMessage?: string) => Promise<void>;
|
||||
@@ -31,25 +35,32 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
const dbContext = useDb();
|
||||
|
||||
/**
|
||||
* Check for tokens in browser local storage on initial load.
|
||||
* Initialize the authentication state.
|
||||
*
|
||||
* @returns object containing whether the user is logged in.
|
||||
*/
|
||||
const initializeAuth = useCallback(async () : Promise<{ isLoggedIn: boolean }> => {
|
||||
let isLoggedIn = false;
|
||||
|
||||
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;
|
||||
if (accessToken && refreshToken && username) {
|
||||
setUsername(username);
|
||||
setIsLoggedIn(true);
|
||||
isLoggedIn = true;
|
||||
}
|
||||
setIsInitialized(true);
|
||||
|
||||
return { isLoggedIn };
|
||||
}, [setUsername, setIsLoggedIn]);
|
||||
|
||||
/**
|
||||
* Check for tokens in browser local storage on initial load when this context is mounted.
|
||||
*/
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Initialize the authentication state.
|
||||
*/
|
||||
const initializeAuth = async () : Promise<void> => {
|
||||
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;
|
||||
if (accessToken && refreshToken && username) {
|
||||
setUsername(username);
|
||||
setIsLoggedIn(true);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
}, []);
|
||||
}, [initializeAuth]);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -100,12 +111,13 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
isLoggedIn,
|
||||
isInitialized,
|
||||
username,
|
||||
initializeAuth,
|
||||
setAuthTokens,
|
||||
login,
|
||||
logout,
|
||||
globalMessage,
|
||||
clearGlobalMessage,
|
||||
}), [isLoggedIn, isInitialized, username, globalMessage, setAuthTokens, login, logout, clearGlobalMessage]);
|
||||
}), [isLoggedIn, isInitialized, username, initializeAuth, globalMessage, setAuthTokens, login, logout, clearGlobalMessage]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
import SqliteClient from '@/utils/SqliteClient';
|
||||
import { VaultResponse } from '@/utils/types/webapi/VaultResponse';
|
||||
|
||||
import type { EncryptionKeyDerivationParams, VaultMetadata } from '@/utils/dist/shared/models/metadata';
|
||||
import type { VaultResponse } from '@/utils/dist/shared/models/webapi';
|
||||
import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
import { VaultResponse as messageVaultResponse } from '@/utils/types/messaging/VaultResponse';
|
||||
import SqliteClient from '@/utils/SqliteClient';
|
||||
import { StoreVaultRequest } from '@/utils/types/messaging/StoreVaultRequest';
|
||||
import type { VaultResponse as messageVaultResponse } from '@/utils/types/messaging/VaultResponse';
|
||||
|
||||
type DbContextType = {
|
||||
sqliteClient: SqliteClient | null;
|
||||
dbInitialized: boolean;
|
||||
dbAvailable: boolean;
|
||||
initializeDatabase: (vaultResponse: VaultResponse, derivedKey: string) => Promise<void>;
|
||||
initializeDatabase: (vaultResponse: VaultResponse, derivedKey: string) => Promise<SqliteClient>;
|
||||
storeEncryptionKey: (derivedKey: string) => Promise<void>;
|
||||
storeEncryptionKeyDerivationParams: (params: EncryptionKeyDerivationParams) => Promise<void>;
|
||||
clearDatabase: () => void;
|
||||
vaultRevision: number;
|
||||
publicEmailDomains: string[];
|
||||
privateEmailDomains: string[];
|
||||
getVaultMetadata: () => Promise<VaultMetadata | null>;
|
||||
setCurrentVaultRevisionNumber: (revisionNumber: number) => Promise<void>;
|
||||
hasPendingMigrations: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const DbContext = createContext<DbContextType | undefined>(undefined);
|
||||
@@ -37,20 +42,10 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
*/
|
||||
const [dbAvailable, setDbAvailable] = useState(false);
|
||||
|
||||
/**
|
||||
* Public email domains.
|
||||
*/
|
||||
const [publicEmailDomains, setPublicEmailDomains] = useState<string[]>([]);
|
||||
|
||||
/**
|
||||
* Vault revision.
|
||||
*/
|
||||
const [vaultRevision, setVaultRevision] = useState(0);
|
||||
|
||||
/**
|
||||
* Private email domains.
|
||||
*/
|
||||
const [privateEmailDomains, setPrivateEmailDomains] = useState<string[]>([]);
|
||||
const [vaultMetadata, setVaultMetadata] = useState<VaultMetadata | null>(null);
|
||||
|
||||
const initializeDatabase = useCallback(async (vaultResponse: VaultResponse, derivedKey: string) => {
|
||||
// Attempt to decrypt the blob.
|
||||
@@ -66,17 +61,25 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
setSqliteClient(client);
|
||||
setDbInitialized(true);
|
||||
setDbAvailable(true);
|
||||
setPublicEmailDomains(vaultResponse.vault.publicEmailDomainList);
|
||||
setPrivateEmailDomains(vaultResponse.vault.privateEmailDomainList);
|
||||
setVaultRevision(vaultResponse.vault.currentRevisionNumber);
|
||||
setVaultMetadata({
|
||||
publicEmailDomains: vaultResponse.vault.publicEmailDomainList,
|
||||
privateEmailDomains: vaultResponse.vault.privateEmailDomainList,
|
||||
vaultRevisionNumber: vaultResponse.vault.currentRevisionNumber,
|
||||
});
|
||||
|
||||
/*
|
||||
/**
|
||||
* Store encrypted vault in background worker.
|
||||
*/
|
||||
sendMessage('STORE_VAULT', {
|
||||
derivedKey: derivedKey,
|
||||
vaultResponse: vaultResponse,
|
||||
}, 'background');
|
||||
const request: StoreVaultRequest = {
|
||||
vaultBlob: vaultResponse.vault.blob,
|
||||
publicEmailDomainList: vaultResponse.vault.publicEmailDomainList,
|
||||
privateEmailDomainList: vaultResponse.vault.privateEmailDomainList,
|
||||
vaultRevisionNumber: vaultResponse.vault.currentRevisionNumber,
|
||||
};
|
||||
|
||||
await sendMessage('STORE_VAULT', request, 'background');
|
||||
|
||||
return client;
|
||||
}, []);
|
||||
|
||||
const checkStoredVault = useCallback(async () => {
|
||||
@@ -89,9 +92,12 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
setSqliteClient(client);
|
||||
setDbInitialized(true);
|
||||
setDbAvailable(true);
|
||||
setPublicEmailDomains(response.publicEmailDomains ?? []);
|
||||
setPrivateEmailDomains(response.privateEmailDomains ?? []);
|
||||
setVaultRevision(response.vaultRevisionNumber ?? 0);
|
||||
|
||||
setVaultMetadata({
|
||||
publicEmailDomains: response.publicEmailDomains ?? [],
|
||||
privateEmailDomains: response.privateEmailDomains ?? [],
|
||||
vaultRevisionNumber: response.vaultRevisionNumber ?? 0,
|
||||
});
|
||||
} else {
|
||||
setDbInitialized(true);
|
||||
setDbAvailable(false);
|
||||
@@ -103,6 +109,34 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get the vault metadata.
|
||||
*/
|
||||
const getVaultMetadata = useCallback(async () : Promise<VaultMetadata | null> => {
|
||||
return vaultMetadata;
|
||||
}, [vaultMetadata]);
|
||||
|
||||
/**
|
||||
* Set the current vault revision number.
|
||||
*/
|
||||
const setCurrentVaultRevisionNumber = useCallback(async (revisionNumber: number) => {
|
||||
setVaultMetadata({
|
||||
publicEmailDomains: vaultMetadata?.publicEmailDomains ?? [],
|
||||
privateEmailDomains: vaultMetadata?.privateEmailDomains ?? [],
|
||||
vaultRevisionNumber: revisionNumber,
|
||||
});
|
||||
}, [vaultMetadata]);
|
||||
|
||||
/**
|
||||
* Check if there are pending migrations.
|
||||
*/
|
||||
const hasPendingMigrations = useCallback(async () => {
|
||||
if (!sqliteClient) {
|
||||
return false;
|
||||
}
|
||||
return await sqliteClient.hasPendingMigrations();
|
||||
}, [sqliteClient]);
|
||||
|
||||
/**
|
||||
* Check if database is initialized and try to retrieve vault from background
|
||||
*/
|
||||
@@ -112,12 +146,27 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
}
|
||||
}, [dbInitialized, checkStoredVault]);
|
||||
|
||||
/**
|
||||
* Store encryption key in background worker.
|
||||
*/
|
||||
const storeEncryptionKey = useCallback(async (encryptionKey: string) : Promise<void> => {
|
||||
await sendMessage('STORE_ENCRYPTION_KEY', encryptionKey, 'background');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Store encryption key derivation params in background worker.
|
||||
*/
|
||||
const storeEncryptionKeyDerivationParams = useCallback(async (params: EncryptionKeyDerivationParams) : Promise<void> => {
|
||||
await sendMessage('STORE_ENCRYPTION_KEY_DERIVATION_PARAMS', params, 'background');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear database and remove from background worker, called when logging out.
|
||||
*/
|
||||
const clearDatabase = useCallback(() : void => {
|
||||
setSqliteClient(null);
|
||||
setDbInitialized(false);
|
||||
setDbAvailable(false);
|
||||
sendMessage('CLEAR_VAULT', {}, 'background');
|
||||
}, []);
|
||||
|
||||
@@ -126,11 +175,13 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
dbInitialized,
|
||||
dbAvailable,
|
||||
initializeDatabase,
|
||||
storeEncryptionKey,
|
||||
storeEncryptionKeyDerivationParams,
|
||||
clearDatabase,
|
||||
vaultRevision,
|
||||
publicEmailDomains,
|
||||
privateEmailDomains
|
||||
}), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, clearDatabase, vaultRevision, publicEmailDomains, privateEmailDomains]);
|
||||
getVaultMetadata,
|
||||
setCurrentVaultRevisionNumber,
|
||||
hasPendingMigrations,
|
||||
}), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, storeEncryptionKey, storeEncryptionKeyDerivationParams, clearDatabase, getVaultMetadata, setCurrentVaultRevisionNumber, hasPendingMigrations]);
|
||||
|
||||
return (
|
||||
<DbContext.Provider value={contextValue}>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo } from "react";
|
||||
|
||||
type HeaderButtonsContextType = {
|
||||
setHeaderButtons: (buttons: React.ReactNode) => void;
|
||||
headerButtons: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for managing header buttons in the popup
|
||||
*/
|
||||
export const HeaderButtonsContext = createContext<HeaderButtonsContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Provider component for HeaderButtonsContext
|
||||
*/
|
||||
export const HeaderButtonsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [headerButtons, setHeaderButtons] = useState<React.ReactNode>(null);
|
||||
|
||||
const handleSetHeaderButtons = useCallback((buttons: React.ReactNode) => {
|
||||
setHeaderButtons(buttons);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
setHeaderButtons: handleSetHeaderButtons,
|
||||
headerButtons
|
||||
}), [handleSetHeaderButtons, headerButtons]);
|
||||
|
||||
return (
|
||||
<HeaderButtonsContext.Provider value={value}>
|
||||
{children}
|
||||
</HeaderButtonsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to use the HeaderButtonsContext
|
||||
* @returns The HeaderButtonsContext value
|
||||
*/
|
||||
export const useHeaderButtons = (): {
|
||||
setHeaderButtons: (buttons: React.ReactNode) => void;
|
||||
headerButtons: React.ReactNode;
|
||||
} => {
|
||||
const context = useContext(HeaderButtonsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useHeaderButtons must be used within a HeaderButtonsProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { createContext, useContext, useState, useMemo } from 'react';
|
||||
|
||||
import LoadingSpinnerFullScreen from '@/entrypoints/popup/components/LoadingSpinnerFullScreen';
|
||||
|
||||
type LoadingContextType = {
|
||||
isLoading: boolean;
|
||||
showLoading: () => void;
|
||||
loadingMessage?: string;
|
||||
showLoading: (message?: string) => void;
|
||||
hideLoading: () => void;
|
||||
isInitialLoading: boolean;
|
||||
setIsInitialLoading: (isInitialLoading: boolean) => void;
|
||||
@@ -29,31 +31,39 @@ export const LoadingProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
||||
* Loading state that can be used by other components during normal operation.
|
||||
*/
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingMessage, setLoadingMessage] = useState<string | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Show loading spinner
|
||||
* Show loading spinner with optional message
|
||||
*/
|
||||
const showLoading = (): void => setIsLoading(true);
|
||||
const showLoading = (message?: string): void => {
|
||||
setIsLoading(true);
|
||||
setLoadingMessage(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hide loading spinner
|
||||
* Hide loading spinner and clear message
|
||||
*/
|
||||
const hideLoading = (): void => setIsLoading(false);
|
||||
const hideLoading = (): void => {
|
||||
setIsLoading(false);
|
||||
setLoadingMessage(undefined);
|
||||
};
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
isLoading,
|
||||
loadingMessage,
|
||||
showLoading,
|
||||
hideLoading,
|
||||
isInitialLoading,
|
||||
setIsInitialLoading,
|
||||
}),
|
||||
[isLoading, isInitialLoading]
|
||||
[isLoading, loadingMessage, isInitialLoading]
|
||||
);
|
||||
|
||||
return (
|
||||
<LoadingContext.Provider value={value}>
|
||||
<LoadingSpinnerFullScreen />
|
||||
<LoadingSpinnerFullScreen message={loadingMessage} />
|
||||
{children}
|
||||
</LoadingContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
const LAST_VISITED_PAGE_KEY = 'session:lastVisitedPage';
|
||||
const LAST_VISITED_TIME_KEY = 'session:lastVisitedTime';
|
||||
const NAVIGATION_HISTORY_KEY = 'session:navigationHistory';
|
||||
|
||||
type NavigationHistoryEntry = {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
type NavigationContextType = {
|
||||
storeCurrentPage: () => Promise<void>;
|
||||
isFullyInitialized: boolean;
|
||||
requiresAuth: boolean;
|
||||
};
|
||||
|
||||
const NavigationContext = createContext<NavigationContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Navigation provider component that handles storing the last visited page.
|
||||
*/
|
||||
export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
|
||||
// Auth and DB state
|
||||
const { isInitialized: authInitialized, isLoggedIn } = useAuth();
|
||||
const { dbInitialized, dbAvailable, upgradeRequired } = useDb();
|
||||
|
||||
// Derived state
|
||||
const isFullyInitialized = authInitialized && dbInitialized;
|
||||
const requiresAuth = isFullyInitialized && (!isLoggedIn || (!dbAvailable && !upgradeRequired));
|
||||
|
||||
/**
|
||||
* 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'];
|
||||
|
||||
// Only store the page if we're fully initialized and don't need auth
|
||||
if (isFullyInitialized && !requiresAuth && !notAllowedPaths.includes(location.pathname)) {
|
||||
// Split the path into segments and build up the history
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
const historyEntries: NavigationHistoryEntry[] = [];
|
||||
|
||||
let currentPath = '';
|
||||
for (const segment of segments) {
|
||||
currentPath += '/' + segment;
|
||||
historyEntries.push({
|
||||
pathname: currentPath,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
storage.setItem(LAST_VISITED_PAGE_KEY, location.pathname),
|
||||
storage.setItem(LAST_VISITED_TIME_KEY, Date.now()),
|
||||
storage.setItem(NAVIGATION_HISTORY_KEY, historyEntries),
|
||||
]);
|
||||
}
|
||||
}, [location, isFullyInitialized, requiresAuth]);
|
||||
|
||||
// Store the current page whenever it changes
|
||||
useEffect(() => {
|
||||
if (isFullyInitialized) {
|
||||
storeCurrentPage();
|
||||
}
|
||||
}, [location.pathname, location.search, location.hash, isFullyInitialized, storeCurrentPage]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
storeCurrentPage,
|
||||
isFullyInitialized,
|
||||
requiresAuth
|
||||
}), [storeCurrentPage, isFullyInitialized, requiresAuth]);
|
||||
|
||||
return (
|
||||
<NavigationContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</NavigationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to access the navigation context.
|
||||
* @returns The navigation context
|
||||
*/
|
||||
export const useNavigation = (): NavigationContextType => {
|
||||
const context = useContext(NavigationContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useNavigation must be used within a NavigationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { createContext, useContext, useState, useMemo, useEffect, useCallback } from 'react';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { WebApiService } from '@/utils/WebApiService';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
|
||||
import { WebApiService } from '@/utils/WebApiService';
|
||||
|
||||
const WebApiContext = createContext<WebApiService | null>(null);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync';
|
||||
|
||||
import { EncryptionUtility } from '@/utils/EncryptionUtility';
|
||||
import { UploadVaultRequest } from '@/utils/types/messaging/UploadVaultRequest';
|
||||
import { VaultUploadResponse as messageVaultUploadResponse } from '@/utils/types/messaging/VaultUploadResponse';
|
||||
|
||||
type VaultMutationOptions = {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
skipSyncCheck?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to execute a vault mutation.
|
||||
*/
|
||||
export function useVaultMutate() : {
|
||||
executeVaultMutation: (operation: () => Promise<void>, options?: VaultMutationOptions) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
syncStatus: string;
|
||||
} {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [syncStatus, setSyncStatus] = useState(t('common.syncingVault'));
|
||||
const dbContext = useDb();
|
||||
const { syncVault } = useVaultSync();
|
||||
|
||||
/**
|
||||
* Execute the provided operation (e.g. create/update/delete credential)
|
||||
*/
|
||||
const executeMutateOperation = useCallback(async (
|
||||
operation: () => Promise<void>,
|
||||
options: VaultMutationOptions
|
||||
) : Promise<void> => {
|
||||
setSyncStatus(t('common.savingChangesToVault'));
|
||||
|
||||
// Execute the provided operation (e.g. create/update/delete credential)
|
||||
await operation();
|
||||
|
||||
setSyncStatus(t('common.uploadingVaultToServer'));
|
||||
|
||||
try {
|
||||
// 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;
|
||||
|
||||
// Encrypt the vault.
|
||||
const encryptedVaultBlob = await EncryptionUtility.symmetricEncrypt(
|
||||
base64Vault,
|
||||
encryptionKey
|
||||
);
|
||||
|
||||
const request: UploadVaultRequest = {
|
||||
vaultBlob: encryptedVaultBlob,
|
||||
};
|
||||
|
||||
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 (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;
|
||||
}
|
||||
}, [dbContext, t]);
|
||||
|
||||
/**
|
||||
* Hook to execute a vault mutation which uploads a new encrypted vault to the server
|
||||
*/
|
||||
const executeVaultMutation = useCallback(async (
|
||||
operation: () => Promise<void>,
|
||||
options: VaultMutationOptions = {}
|
||||
) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setSyncStatus(t('common.checkingVaultUpdates'));
|
||||
|
||||
// Skip sync check if requested (e.g., during upgrade operations)
|
||||
if (options.skipSyncCheck) {
|
||||
setSyncStatus(t('common.executingOperation'));
|
||||
await executeMutateOperation(operation, options);
|
||||
return;
|
||||
}
|
||||
|
||||
await syncVault({
|
||||
/**
|
||||
* Handle the status update.
|
||||
*/
|
||||
onStatus: (message) => setSyncStatus(message),
|
||||
/**
|
||||
* Handle successful vault sync and continue with vault mutation.
|
||||
*/
|
||||
onSuccess: async (hasNewVault) => {
|
||||
if (hasNewVault) {
|
||||
// Vault was changed, but has now been reloaded so we can continue with the operation.
|
||||
}
|
||||
await executeMutateOperation(operation, options);
|
||||
},
|
||||
/**
|
||||
* 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'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setSyncStatus('');
|
||||
}
|
||||
}, [syncVault, executeMutateOperation, t]);
|
||||
|
||||
return {
|
||||
executeVaultMutation,
|
||||
isLoading,
|
||||
syncStatus,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
|
||||
import type { EncryptionKeyDerivationParams } from '@/utils/dist/shared/models/metadata';
|
||||
import type { VaultResponse } from '@/utils/dist/shared/models/webapi';
|
||||
|
||||
/**
|
||||
* Utility function to ensure a minimum time has elapsed for an operation
|
||||
*/
|
||||
const withMinimumDelay = async <T>(
|
||||
operation: () => Promise<T>,
|
||||
minDelayMs: number,
|
||||
enableDelay: boolean = true
|
||||
): Promise<T> => {
|
||||
if (!enableDelay) {
|
||||
// If delay is disabled, return the result immediately.
|
||||
return operation();
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const result = await operation();
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
|
||||
if (elapsedTime < minDelayMs) {
|
||||
await new Promise(resolve => setTimeout(resolve, minDelayMs - elapsedTime));
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
type VaultSyncOptions = {
|
||||
initialSync?: boolean;
|
||||
onSuccess?: (hasNewVault: boolean) => void;
|
||||
onError?: (error: string) => void;
|
||||
onStatus?: (message: string) => void;
|
||||
_onOffline?: () => void;
|
||||
onUpgradeRequired?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to sync the vault with the server.
|
||||
*/
|
||||
export const useVaultSync = () : {
|
||||
syncVault: (options?: VaultSyncOptions) => Promise<boolean>;
|
||||
} => {
|
||||
const { t } = useTranslation();
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
|
||||
const syncVault = useCallback(async (options: VaultSyncOptions = {}) => {
|
||||
const { initialSync = false, onSuccess, onError, onStatus, _onOffline, onUpgradeRequired } = options;
|
||||
|
||||
// For the initial sync, we add an artifical delay to various steps which makes it feel more fluid.
|
||||
const enableDelay = initialSync;
|
||||
|
||||
try {
|
||||
const { isLoggedIn } = await authContext.initializeAuth();
|
||||
|
||||
if (!isLoggedIn) {
|
||||
// Not authenticated, return false immediately
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check app status and vault revision
|
||||
onStatus?.(t('common.checkingVaultUpdates'));
|
||||
const statusResponse = await withMinimumDelay(() => webApi.getStatus(), 300, enableDelay);
|
||||
|
||||
// Check if server is actually available, 0.0.0 indicates connection error which triggers offline mode.
|
||||
if (statusResponse.serverVersion === '0.0.0') {
|
||||
// Offline mode is not implemented for browser extension yet, let it fail below due to the validateStatusResponse check.
|
||||
}
|
||||
|
||||
const statusError = webApi.validateStatusResponse(statusResponse);
|
||||
if (statusError) {
|
||||
onError?.(t('common.errors.' + statusError));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the SRP salt has changed compared to locally stored encryption key derivation params
|
||||
const storedEncryptionParams = await sendMessage('GET_ENCRYPTION_KEY_DERIVATION_PARAMS', {}, 'background') as EncryptionKeyDerivationParams | null;
|
||||
if (storedEncryptionParams && statusResponse.srpSalt && statusResponse.srpSalt !== storedEncryptionParams.salt) {
|
||||
/**
|
||||
* Server SRP salt has changed compared to locally stored value, which means the user has changed
|
||||
* their password since the last time they logged in. This means that the local encryption key is no
|
||||
* longer valid and the user needs to re-authenticate. We trigger a logout but do not revoke tokens
|
||||
* as these were already revoked by the server upon password change.
|
||||
*/
|
||||
await webApi.logout(t('common.errors.passwordChanged'));
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* If we get here, it means we have a valid connection to the server.
|
||||
* TODO: browser extension does not support offline mode yet.
|
||||
* authContext.setOfflineMode(false);
|
||||
*/
|
||||
|
||||
// Compare vault revisions
|
||||
const vaultMetadata = await dbContext.getVaultMetadata();
|
||||
const vaultRevisionNumber = vaultMetadata?.vaultRevisionNumber ?? 0;
|
||||
|
||||
if (statusResponse.vaultRevision > vaultRevisionNumber) {
|
||||
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;
|
||||
const sqliteClient = await dbContext.initializeDatabase(vaultResponseJson as VaultResponse, encryptionKey);
|
||||
|
||||
// Check if the current vault version is known and up to date, if not known trigger an exception, if not up to date redirect to the upgrade page.
|
||||
if (await sqliteClient.hasPendingMigrations()) {
|
||||
onUpgradeRequired?.();
|
||||
return false;
|
||||
}
|
||||
|
||||
onSuccess?.(true);
|
||||
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);
|
||||
return false;
|
||||
}
|
||||
// Vault could not be decrypted, throw an error
|
||||
throw new Error('Vault could not be decrypted, if the problem persists please logout and login again.');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the vault is up to date, if not, redirect to the upgrade page.
|
||||
if (await dbContext.hasPendingMigrations()) {
|
||||
onUpgradeRequired?.();
|
||||
return false;
|
||||
}
|
||||
|
||||
await withMinimumDelay(() => Promise.resolve(onSuccess?.(false)), 300, enableDelay);
|
||||
return false;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error during vault sync';
|
||||
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);
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* Check if it's a network error
|
||||
* TODO: browser extension does not support offline mode yet.
|
||||
*/
|
||||
/*
|
||||
* if (errorMessage.includes('network') || errorMessage.includes('timeout')) {
|
||||
*authContext.setOfflineMode(true);
|
||||
*return true;
|
||||
*}
|
||||
*/
|
||||
|
||||
onError?.(errorMessage);
|
||||
return false;
|
||||
}
|
||||
}, [authContext, dbContext, webApi, t]);
|
||||
|
||||
return { syncVault };
|
||||
};
|
||||
@@ -4,6 +4,11 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AliasVault</title>
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/icon/16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icon/32.png" />
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/icon/48.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon/192.png" />
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/icon/192.png" />
|
||||
<link href="~/assets/tailwind.css" rel="stylesheet" />
|
||||
<meta name="manifest.type" content="browser_action" />
|
||||
</head>
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
import App from '@/entrypoints/popup/App';
|
||||
import { AuthProvider } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { WebApiProvider } from '@/entrypoints/popup/context/WebApiContext';
|
||||
import { DbProvider } from '@/entrypoints/popup/context/DbContext';
|
||||
import { HeaderButtonsProvider } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { LoadingProvider } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { ThemeProvider } from '@/entrypoints/popup/context/ThemeContext';
|
||||
import { setupExpandedMode } from '@/utils/ExpandedMode';
|
||||
import { WebApiProvider } from '@/entrypoints/popup/context/WebApiContext';
|
||||
|
||||
// Run before React initializes to ensure the popup is always a fixed width except for when explicitly expanded.
|
||||
setupExpandedMode();
|
||||
import i18n from '@/i18n/i18n';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
|
||||
root.render(
|
||||
<DbProvider>
|
||||
<AuthProvider>
|
||||
<WebApiProvider>
|
||||
<LoadingProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</LoadingProvider>
|
||||
</WebApiProvider>
|
||||
</AuthProvider>
|
||||
</DbProvider>
|
||||
);
|
||||
/**
|
||||
* Renders the main application.
|
||||
*/
|
||||
const renderApp = (): void => {
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
|
||||
root.render(
|
||||
<DbProvider>
|
||||
<AuthProvider>
|
||||
<WebApiProvider>
|
||||
<LoadingProvider>
|
||||
<HeaderButtonsProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</HeaderButtonsProvider>
|
||||
</LoadingProvider>
|
||||
</WebApiProvider>
|
||||
</AuthProvider>
|
||||
</DbProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Wait for i18n to be ready before rendering React. Not waiting can cause issues on some browsers, Firefox on Windows specifically.
|
||||
if (i18n.isInitialized) {
|
||||
renderApp();
|
||||
} else {
|
||||
i18n.on('initialized', renderApp);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import LanguageSwitcher from '@/entrypoints/popup/components/LanguageSwitcher';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
|
||||
import { AppInfo } from '@/utils/AppInfo';
|
||||
import { storage } from '#imports';
|
||||
import { GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, DISABLED_SITES_KEY, VAULT_LOCKED_DISMISS_UNTIL_KEY } from '@/utils/Constants';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
type ApiOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -13,14 +20,52 @@ const DEFAULT_OPTIONS: ApiOption[] = [
|
||||
{ label: 'Self-hosted', value: 'custom' }
|
||||
];
|
||||
|
||||
// Validation schema for URLs
|
||||
/**
|
||||
* Creates a URL validation schema with localized error messages.
|
||||
*/
|
||||
const createUrlSchema = (t: (key: string) => string): Yup.ObjectSchema<{apiUrl: string; clientUrl: string}> => Yup.object().shape({
|
||||
apiUrl: Yup.string()
|
||||
.required(t('validation.apiUrlRequired'))
|
||||
.test('is-valid-api-url', t('settings.validation.apiUrlInvalid'), (value: string | undefined) => {
|
||||
if (!value) {
|
||||
return true; // Allow empty for non-custom option
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
clientUrl: Yup.string()
|
||||
.required(t('validation.clientUrlRequired'))
|
||||
.test('is-valid-client-url', t('settings.validation.clientUrlInvalid'), (value: string | undefined) => {
|
||||
if (!value) {
|
||||
return true; // Allow empty for non-custom option
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Auth settings page only shown when user is not logged in.
|
||||
*/
|
||||
const AuthSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedOption, setSelectedOption] = useState<string>('');
|
||||
const [customUrl, setCustomUrl] = useState<string>('');
|
||||
const [customClientUrl, setCustomClientUrl] = useState<string>('');
|
||||
const [isGloballyEnabled, setIsGloballyEnabled] = useState<boolean>(true);
|
||||
const [errors, setErrors] = useState<{ apiUrl?: string; clientUrl?: string }>({});
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
|
||||
const urlSchema = createUrlSchema(t);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
@@ -49,10 +94,11 @@ const AuthSettings: React.FC = () => {
|
||||
} else {
|
||||
setSelectedOption(DEFAULT_OPTIONS[0].value);
|
||||
}
|
||||
setIsInitialLoading(false);
|
||||
};
|
||||
|
||||
loadStoredSettings();
|
||||
}, []);
|
||||
}, [setIsInitialLoading]);
|
||||
|
||||
/**
|
||||
* Handle option change
|
||||
@@ -63,6 +109,9 @@ const AuthSettings: React.FC = () => {
|
||||
if (value !== 'custom') {
|
||||
await storage.setItem('local:apiUrl', '');
|
||||
await storage.setItem('local:clientUrl', '');
|
||||
setCustomUrl('');
|
||||
setCustomClientUrl('');
|
||||
setErrors({});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,17 +121,37 @@ const AuthSettings: React.FC = () => {
|
||||
const handleCustomUrlChange = async (e: React.ChangeEvent<HTMLInputElement>) : Promise<void> => {
|
||||
const value = e.target.value;
|
||||
setCustomUrl(value);
|
||||
await storage.setItem('local:apiUrl', value);
|
||||
|
||||
try {
|
||||
await urlSchema.validateAt('apiUrl', { apiUrl: value });
|
||||
setErrors(prev => ({ ...prev, apiUrl: undefined }));
|
||||
await storage.setItem('local:apiUrl', value);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Yup.ValidationError) {
|
||||
setErrors(prev => ({ ...prev, apiUrl: error.message }));
|
||||
// On error we revert back to the aliasvault.net official hosted instance.
|
||||
await storage.setItem('local:apiUrl', AppInfo.DEFAULT_API_URL);
|
||||
await storage.setItem('local:clientUrl', AppInfo.DEFAULT_CLIENT_URL);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle custom client URL change
|
||||
* @param e
|
||||
*/
|
||||
const handleCustomClientUrlChange = async (e: React.ChangeEvent<HTMLInputElement>) : Promise<void> => {
|
||||
const value = e.target.value;
|
||||
setCustomClientUrl(value);
|
||||
await storage.setItem('local:clientUrl', value);
|
||||
|
||||
try {
|
||||
await urlSchema.validateAt('clientUrl', { clientUrl: value });
|
||||
setErrors(prev => ({ ...prev, clientUrl: undefined }));
|
||||
await storage.setItem('local:clientUrl', value);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Yup.ValidationError) {
|
||||
setErrors(prev => ({ ...prev, clientUrl: error.message }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -104,9 +173,17 @@ const AuthSettings: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
{/* Language Settings Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('common.language')}</p>
|
||||
<LanguageSwitcher variant="dropdown" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="api-connection" className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
API Connection
|
||||
{t('settings.serverUrl')}
|
||||
</label>
|
||||
<select
|
||||
value={selectedOption}
|
||||
@@ -133,8 +210,11 @@ const AuthSettings: React.FC = () => {
|
||||
value={customClientUrl}
|
||||
onChange={handleCustomClientUrlChange}
|
||||
placeholder="https://my-aliasvault-instance.com"
|
||||
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"
|
||||
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>
|
||||
<div className="mb-6">
|
||||
<label htmlFor="custom-api-url" className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
@@ -146,8 +226,11 @@ const AuthSettings: React.FC = () => {
|
||||
value={customUrl}
|
||||
onChange={handleCustomUrlChange}
|
||||
placeholder="https://my-aliasvault-instance.com/api"
|
||||
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"
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
@@ -155,7 +238,7 @@ const AuthSettings: React.FC = () => {
|
||||
{/* Autofill Popup Settings Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Autofill popup</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('settings.autofillEnabled')}</p>
|
||||
<button
|
||||
onClick={toggleGlobalPopup}
|
||||
className={`px-4 py-2 rounded-md transition-colors ${
|
||||
@@ -164,13 +247,13 @@ const AuthSettings: React.FC = () => {
|
||||
: 'bg-red-200 text-red-800 hover:bg-red-300 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
|
||||
}`}
|
||||
>
|
||||
{isGloballyEnabled ? 'Enabled' : 'Disabled'}
|
||||
{isGloballyEnabled ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-gray-400 dark:text-gray-600">
|
||||
Version: {AppInfo.VERSION}
|
||||
{t('settings.version')}: {AppInfo.VERSION}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import AttachmentUploader from '@/entrypoints/popup/components/CredentialDetails/AttachmentUploader';
|
||||
import EmailDomainField from '@/entrypoints/popup/components/EmailDomainField';
|
||||
import { FormInput } from '@/entrypoints/popup/components/FormInput';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import Modal from '@/entrypoints/popup/components/Modal';
|
||||
import PasswordField from '@/entrypoints/popup/components/PasswordField';
|
||||
import UsernameField from '@/entrypoints/popup/components/UsernameField';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
|
||||
|
||||
import { IdentityHelperUtils, CreateIdentityGenerator, CreateUsernameEmailGenerator, Identity, Gender } from '@/utils/dist/shared/identity-generator';
|
||||
import type { Attachment, Credential } from '@/utils/dist/shared/models/vault';
|
||||
import { CreatePasswordGenerator } from '@/utils/dist/shared/password-generator';
|
||||
|
||||
type CredentialMode = 'random' | 'manual';
|
||||
|
||||
// Persisted form data type used for JSON serialization.
|
||||
type PersistedFormData = {
|
||||
credentialId: string | null;
|
||||
mode: CredentialMode;
|
||||
formValues: Omit<Credential, 'Logo'> & { Logo?: string | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or edit credential page.
|
||||
*/
|
||||
const CredentialAddEdit: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const dbContext = useDb();
|
||||
// If we received an ID, we're in edit mode
|
||||
const isEditMode = id !== undefined && id.length > 0;
|
||||
|
||||
/**
|
||||
* Validation schema for the credential form with translatable messages.
|
||||
*/
|
||||
const credentialSchema = useMemo(() => Yup.object().shape({
|
||||
Id: Yup.string(),
|
||||
ServiceName: Yup.string().required(t('credentials.validation.serviceNameRequired')),
|
||||
ServiceUrl: Yup.string().nullable().optional(),
|
||||
Alias: Yup.object().shape({
|
||||
FirstName: Yup.string().nullable().optional(),
|
||||
LastName: Yup.string().nullable().optional(),
|
||||
NickName: Yup.string().nullable().optional(),
|
||||
BirthDate: Yup.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.test(
|
||||
'is-valid-date-format',
|
||||
t('credentials.validation.invalidDateFormat'),
|
||||
value => {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||
},
|
||||
),
|
||||
Gender: Yup.string().nullable().optional(),
|
||||
Email: Yup.string().email(t('credentials.validation.invalidEmail')).nullable().optional()
|
||||
}),
|
||||
Username: Yup.string().nullable().optional(),
|
||||
Password: Yup.string().nullable().optional(),
|
||||
Notes: Yup.string().nullable().optional()
|
||||
}), [t]);
|
||||
|
||||
const { executeVaultMutation, isLoading, syncStatus } = useVaultMutate();
|
||||
const [mode, setMode] = useState<CredentialMode>('random');
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const [localLoading, setLocalLoading] = useState(true);
|
||||
const [showPassword, setShowPassword] = useState(!isEditMode);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [originalAttachmentIds, setOriginalAttachmentIds] = useState<string[]>([]);
|
||||
const webApi = useWebApi();
|
||||
|
||||
const serviceNameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { handleSubmit, setValue, watch, formState: { errors } } = useForm<Credential>({
|
||||
resolver: yupResolver(credentialSchema as Yup.ObjectSchema<Credential>),
|
||||
defaultValues: {
|
||||
Id: "",
|
||||
Username: "",
|
||||
Password: "",
|
||||
ServiceName: "",
|
||||
ServiceUrl: "https://",
|
||||
Notes: "",
|
||||
Alias: {
|
||||
FirstName: "",
|
||||
LastName: "",
|
||||
NickName: "",
|
||||
BirthDate: "",
|
||||
Gender: undefined,
|
||||
Email: ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Persists the current form values to storage
|
||||
* @returns Promise that resolves when the form values are persisted
|
||||
*/
|
||||
const persistFormValues = useCallback(async (): Promise<void> => {
|
||||
if (localLoading) {
|
||||
// Do not persist values if the page is still loading.
|
||||
return;
|
||||
}
|
||||
|
||||
const formValues = watch();
|
||||
const persistedData: PersistedFormData = {
|
||||
credentialId: id || null,
|
||||
mode,
|
||||
formValues: {
|
||||
...formValues,
|
||||
Logo: null // Don't persist the Logo field as it can't be user modified in the UI.
|
||||
}
|
||||
};
|
||||
await sendMessage('PERSIST_FORM_VALUES', JSON.stringify(persistedData), 'background');
|
||||
}, [watch, id, mode, localLoading]);
|
||||
|
||||
/**
|
||||
* Watch for mode changes and persist form values
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!localLoading) {
|
||||
void persistFormValues();
|
||||
}
|
||||
}, [mode, persistFormValues, localLoading]);
|
||||
|
||||
// Watch for form changes and persist them
|
||||
useEffect(() => {
|
||||
const subscription = watch(() => {
|
||||
void persistFormValues();
|
||||
});
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [watch, persistFormValues]);
|
||||
|
||||
/**
|
||||
* Loads persisted form values from storage. This is used to keep track of form changes
|
||||
* and restore them when the page is reloaded. The browser extension popup will close
|
||||
* automatically by clicking outside of the popup, but with this logic we can restore
|
||||
* the form values when the page is reloaded so the user can continue their mutation operation.
|
||||
*
|
||||
* @returns Promise that resolves when the form values are loaded
|
||||
*/
|
||||
const loadPersistedValues = useCallback(async (): Promise<void> => {
|
||||
const persistedData = await sendMessage('GET_PERSISTED_FORM_VALUES', null, 'background') as string | null;
|
||||
|
||||
// Try to parse the persisted data as a JSON object.
|
||||
try {
|
||||
let persistedDataObject: PersistedFormData | null = null;
|
||||
try {
|
||||
if (persistedData) {
|
||||
persistedDataObject = JSON.parse(persistedData) as PersistedFormData;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing persisted data:', error);
|
||||
}
|
||||
|
||||
// Check if the object has a value and is not null
|
||||
const objectEmpty = persistedDataObject === null || persistedDataObject === undefined;
|
||||
if (objectEmpty) {
|
||||
// If the persisted data object is empty, we don't have any values to restore and can exit early.
|
||||
setLocalLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const isCurrentPage = persistedDataObject?.credentialId == id;
|
||||
if (persistedDataObject && isCurrentPage) {
|
||||
// Only restore if the persisted credential ID matches current page
|
||||
setMode(persistedDataObject.mode);
|
||||
Object.entries(persistedDataObject.formValues).forEach(([key, value]) => {
|
||||
setValue(key as keyof Credential, value as Credential[keyof Credential]);
|
||||
});
|
||||
} else {
|
||||
console.error('Persisted values do not match current page');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading persisted data:', error);
|
||||
}
|
||||
|
||||
// Set local loading state to false which also activates the persisting of form value changes from this point on.
|
||||
setLocalLoading(false);
|
||||
}, [setValue, id, setMode, setLocalLoading]);
|
||||
|
||||
/**
|
||||
* Clears persisted form values from storage
|
||||
* @returns Promise that resolves when the form values are cleared
|
||||
*/
|
||||
const clearPersistedValues = useCallback(async (): Promise<void> => {
|
||||
await sendMessage('CLEAR_PERSISTED_FORM_VALUES', null, 'background');
|
||||
}, []);
|
||||
|
||||
// Clear persisted values when the page is unmounted.
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
void clearPersistedValues();
|
||||
};
|
||||
}, [clearPersistedValues]);
|
||||
|
||||
/**
|
||||
* Load an existing credential from the database in edit mode.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!dbContext?.sqliteClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
// On create mode, focus the service name field after a short delay to ensure the component is mounted.
|
||||
setTimeout(() => {
|
||||
serviceNameRef.current?.focus();
|
||||
}, 100);
|
||||
setIsInitialLoading(false);
|
||||
|
||||
// Load persisted form values if they exist.
|
||||
loadPersistedValues().then(() => {
|
||||
// Generate default password if no persisted password exists
|
||||
if (!watch('Password')) {
|
||||
const passwordSettings = dbContext.sqliteClient!.getPasswordSettings();
|
||||
const passwordGenerator = CreatePasswordGenerator(passwordSettings);
|
||||
const defaultPassword = passwordGenerator.generateRandomPassword();
|
||||
setValue('Password', defaultPassword);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = dbContext.sqliteClient.getCredentialById(id);
|
||||
|
||||
if (result) {
|
||||
result.Alias.BirthDate = IdentityHelperUtils.normalizeBirthDateForDisplay(result.Alias.BirthDate);
|
||||
|
||||
// Set form values
|
||||
Object.entries(result).forEach(([key, value]) => {
|
||||
setValue(key as keyof Credential, value);
|
||||
});
|
||||
|
||||
// Load attachments for this credential
|
||||
const credentialAttachments = dbContext.sqliteClient.getAttachmentsForCredential(id);
|
||||
setAttachments(credentialAttachments);
|
||||
setOriginalAttachmentIds(credentialAttachments.map(a => a.Id));
|
||||
|
||||
setMode('manual');
|
||||
setIsInitialLoading(false);
|
||||
|
||||
// Check for persisted values that might override the loaded values if they exist.
|
||||
loadPersistedValues();
|
||||
} else {
|
||||
console.error('Credential not found');
|
||||
navigate('/credentials');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading credential:', err);
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
}, [dbContext.sqliteClient, id, navigate, setIsInitialLoading, setValue, loadPersistedValues, watch]);
|
||||
|
||||
/**
|
||||
* Handle the delete button click.
|
||||
*/
|
||||
const handleDelete = useCallback(async (): Promise<void> => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
executeVaultMutation(async () => {
|
||||
dbContext.sqliteClient!.deleteCredentialById(id);
|
||||
}, {
|
||||
/**
|
||||
* Navigate to the credentials list page on success.
|
||||
*/
|
||||
onSuccess: () => {
|
||||
void clearPersistedValues();
|
||||
navigate('/credentials');
|
||||
}
|
||||
});
|
||||
}, [id, executeVaultMutation, dbContext.sqliteClient, navigate, clearPersistedValues]);
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// Initialize identity generator based on language
|
||||
const identityGenerator = CreateIdentityGenerator(identityLanguage);
|
||||
|
||||
// Initialize password generator with settings from vault
|
||||
const passwordSettings = dbContext.sqliteClient!.getPasswordSettings();
|
||||
const passwordGenerator = CreatePasswordGenerator(passwordSettings);
|
||||
|
||||
return { identityGenerator, passwordGenerator };
|
||||
}, [dbContext.sqliteClient]);
|
||||
|
||||
/**
|
||||
* Generate a random alias and password.
|
||||
*/
|
||||
const generateRandomAlias = useCallback(async () => {
|
||||
const { identityGenerator, passwordGenerator } = await initializeGenerators();
|
||||
|
||||
// Get gender preference from database
|
||||
const genderPreference = dbContext.sqliteClient!.getDefaultIdentityGender();
|
||||
|
||||
// Generate identity with gender preference
|
||||
const identity = identityGenerator.generateRandomIdentity(genderPreference);
|
||||
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 email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix;
|
||||
|
||||
setValue('Alias.Email', email);
|
||||
setValue('Alias.FirstName', identity.firstName);
|
||||
setValue('Alias.LastName', identity.lastName);
|
||||
setValue('Alias.NickName', identity.nickName);
|
||||
setValue('Alias.Gender', identity.gender);
|
||||
setValue('Alias.BirthDate', IdentityHelperUtils.normalizeBirthDateForDisplay(identity.birthDate.toISOString()));
|
||||
|
||||
// In edit mode, preserve existing username and password if they exist
|
||||
if (isEditMode && watch('Username')) {
|
||||
// Keep the existing username in edit mode, so don't do anything here.
|
||||
} else {
|
||||
// Use the newly generated username
|
||||
setValue('Username', identity.nickName);
|
||||
}
|
||||
|
||||
if (isEditMode && watch('Password')) {
|
||||
// Keep the existing password in edit mode, so don't do anything here.
|
||||
} else {
|
||||
// Use the newly generated password
|
||||
setValue('Password', password);
|
||||
}
|
||||
}, [isEditMode, watch, setValue, initializeGenerators, dbContext]);
|
||||
|
||||
/**
|
||||
* Handle the generate random alias button press.
|
||||
*/
|
||||
const handleGenerateRandomAlias = useCallback(() => {
|
||||
void generateRandomAlias();
|
||||
}, [generateRandomAlias]);
|
||||
|
||||
const generateRandomUsername = useCallback(async () => {
|
||||
try {
|
||||
const usernameEmailGenerator = CreateUsernameEmailGenerator();
|
||||
|
||||
let gender = Gender.Other;
|
||||
try {
|
||||
gender = watch('Alias.Gender') as Gender;
|
||||
} catch {
|
||||
// Gender parsing failed, default to other.
|
||||
}
|
||||
|
||||
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);
|
||||
setValue('Username', username);
|
||||
} catch (error) {
|
||||
console.error('Error generating random username:', error);
|
||||
}
|
||||
}, [setValue, watch]);
|
||||
|
||||
const initialPasswordSettings = useMemo(() => {
|
||||
return dbContext.sqliteClient?.getPasswordSettings();
|
||||
}, [dbContext.sqliteClient]);
|
||||
|
||||
/**
|
||||
* Handle form submission.
|
||||
*/
|
||||
const onSubmit = useCallback(async (data: Credential): Promise<void> => {
|
||||
// Normalize the birth date for database entry.
|
||||
let birthdate = data.Alias.BirthDate;
|
||||
if (birthdate) {
|
||||
birthdate = IdentityHelperUtils.normalizeBirthDateForDb(birthdate);
|
||||
}
|
||||
|
||||
// Clean up empty protocol-only URLs
|
||||
if (data.ServiceUrl === 'http://' || data.ServiceUrl === 'https://') {
|
||||
data.ServiceUrl = '';
|
||||
}
|
||||
|
||||
// If we're creating a new credential and mode is random, generate random values here
|
||||
if (!isEditMode && mode === 'random') {
|
||||
// Generate random values now and then read them from the form fields to manually assign to the credentialToSave object
|
||||
await generateRandomAlias();
|
||||
data.Username = watch('Username');
|
||||
data.Password = watch('Password');
|
||||
data.Alias.FirstName = watch('Alias.FirstName');
|
||||
data.Alias.LastName = watch('Alias.LastName');
|
||||
data.Alias.NickName = watch('Alias.NickName');
|
||||
data.Alias.BirthDate = birthdate;
|
||||
data.Alias.Gender = watch('Alias.Gender');
|
||||
data.Alias.Email = watch('Alias.Email');
|
||||
// Clean up ServiceUrl for random mode too
|
||||
const serviceUrl = watch('ServiceUrl');
|
||||
data.ServiceUrl = (serviceUrl === 'http://' || serviceUrl === 'https://') ? '' : serviceUrl;
|
||||
}
|
||||
|
||||
// Extract favicon from service URL if the credential has one
|
||||
if (data.ServiceUrl) {
|
||||
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=' + data.ServiceUrl);
|
||||
const faviconResponse = await Promise.race([faviconPromise, timeoutPromise]) as { image: string };
|
||||
|
||||
if (faviconResponse?.image) {
|
||||
const decodedImage = Uint8Array.from(Buffer.from(faviconResponse.image, 'base64'));
|
||||
data.Logo = decodedImage;
|
||||
}
|
||||
} catch {
|
||||
// Favicon extraction failed or timed out, this is not a critical error so we can ignore it.
|
||||
}
|
||||
}
|
||||
|
||||
executeVaultMutation(async () => {
|
||||
setLocalLoading(false);
|
||||
|
||||
if (isEditMode) {
|
||||
await dbContext.sqliteClient!.updateCredentialById(data, originalAttachmentIds, attachments);
|
||||
} else {
|
||||
const credentialId = await dbContext.sqliteClient!.createCredential(data, attachments);
|
||||
data.Id = credentialId.toString();
|
||||
}
|
||||
}, {
|
||||
/**
|
||||
* Navigate to the credential details page on success.
|
||||
*/
|
||||
onSuccess: () => {
|
||||
void clearPersistedValues();
|
||||
// If in add mode, navigate to the credential details page.
|
||||
if (!isEditMode) {
|
||||
// Navigate to the credential details page.
|
||||
navigate(`/credentials/${data.Id}`, { replace: true });
|
||||
} else {
|
||||
// If in edit mode, pop the current page from the history stack to end up on details page as well.
|
||||
navigate(-1);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate, mode, watch, generateRandomAlias, webApi, clearPersistedValues, originalAttachmentIds, attachments]);
|
||||
|
||||
// Set header buttons on mount and clear on unmount
|
||||
useEffect((): (() => void) => {
|
||||
// Only set the header buttons once on mount.
|
||||
const headerButtonsJSX = (
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditMode && (
|
||||
<HeaderButton
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
title={t('credentials.deleteCredential')}
|
||||
iconType={HeaderIconType.DELETE}
|
||||
variant="danger"
|
||||
/>
|
||||
)}
|
||||
<HeaderButton
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
title={t('credentials.saveCredential')}
|
||||
iconType={HeaderIconType.SAVE}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
setHeaderButtons(headerButtonsJSX);
|
||||
return () => {};
|
||||
}, [setHeaderButtons, handleSubmit, onSubmit, isEditMode, t]);
|
||||
|
||||
// Clear header buttons on unmount
|
||||
useEffect((): (() => void) => {
|
||||
return () => setHeaderButtons(null);
|
||||
}, [setHeaderButtons]);
|
||||
|
||||
if (isEditMode && !watch('ServiceName')) {
|
||||
return <div>{t('common.loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<button type="submit" style={{ display: 'none' }} />
|
||||
{(localLoading || isLoading) && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={() => {
|
||||
setShowDeleteModal(false);
|
||||
void handleDelete();
|
||||
}}
|
||||
title={t('credentials.deleteCredentialTitle')}
|
||||
message={t('credentials.deleteCredentialConfirm')}
|
||||
confirmText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
{!isEditMode && (
|
||||
<div className="flex space-x-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('random')}
|
||||
className={`flex-1 py-2 px-4 rounded flex items-center justify-center gap-2 ${
|
||||
mode === 'random' ? 'bg-primary-500 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<svg className='w-5 h-5' viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8" cy="8" r="1"/>
|
||||
<circle cx="16" cy="8" r="1"/>
|
||||
<circle cx="12" cy="12" r="1"/>
|
||||
<circle cx="8" cy="16" r="1"/>
|
||||
<circle cx="16" cy="16" r="1"/>
|
||||
</svg>
|
||||
{t('credentials.randomAlias')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('manual')}
|
||||
className={`flex-1 py-2 px-4 rounded flex items-center justify-center gap-2 ${
|
||||
mode === 'manual' ? 'bg-primary-500 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
<path d="M5.5 20a6.5 6.5 0 0 1 13 0"/>
|
||||
</svg>
|
||||
{t('credentials.manual')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('credentials.service')}</h2>
|
||||
<div className="space-y-4">
|
||||
<FormInput
|
||||
id="serviceName"
|
||||
label={t('credentials.serviceName')}
|
||||
ref={serviceNameRef}
|
||||
value={watch('ServiceName') ?? ''}
|
||||
onChange={(value) => setValue('ServiceName', value)}
|
||||
required
|
||||
error={errors.ServiceName?.message}
|
||||
/>
|
||||
<FormInput
|
||||
id="serviceUrl"
|
||||
label={t('credentials.serviceUrl')}
|
||||
value={watch('ServiceUrl') ?? ''}
|
||||
onChange={(value) => setValue('ServiceUrl', value)}
|
||||
error={errors.ServiceUrl?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(mode === 'manual' || isEditMode) && (
|
||||
<>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('credentials.loginCredentials')}</h2>
|
||||
<div className="space-y-4">
|
||||
<EmailDomainField
|
||||
id="email"
|
||||
label={t('common.email')}
|
||||
value={watch('Alias.Email') ?? ''}
|
||||
onChange={(value: string) => setValue('Alias.Email', value)}
|
||||
error={errors.Alias?.Email?.message}
|
||||
/>
|
||||
<UsernameField
|
||||
id="username"
|
||||
label={t('common.username')}
|
||||
value={watch('Username') ?? ''}
|
||||
onChange={(value) => setValue('Username', value)}
|
||||
error={errors.Username?.message}
|
||||
onRegenerate={generateRandomUsername}
|
||||
/>
|
||||
{initialPasswordSettings && (
|
||||
<PasswordField
|
||||
id="password"
|
||||
label={t('common.password')}
|
||||
value={watch('Password') ?? ''}
|
||||
onChange={(value) => setValue('Password', value)}
|
||||
error={errors.Password?.message}
|
||||
showPassword={showPassword}
|
||||
onShowPasswordChange={setShowPassword}
|
||||
initialSettings={initialPasswordSettings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('credentials.alias')}</h2>
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerateRandomAlias}
|
||||
className="w-full bg-primary-500 text-white py-2 px-4 rounded hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className='w-5 h-5 inline-block' viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8" cy="8" r="1"/>
|
||||
<circle cx="16" cy="8" r="1"/>
|
||||
<circle cx="12" cy="12" r="1"/>
|
||||
<circle cx="8" cy="16" r="1"/>
|
||||
<circle cx="16" cy="16" r="1"/>
|
||||
</svg>
|
||||
<span>{t('credentials.generateRandomAlias')}</span>
|
||||
</button>
|
||||
<FormInput
|
||||
id="firstName"
|
||||
label={t('credentials.firstName')}
|
||||
value={watch('Alias.FirstName') ?? ''}
|
||||
onChange={(value) => setValue('Alias.FirstName', value)}
|
||||
error={errors.Alias?.FirstName?.message}
|
||||
/>
|
||||
<FormInput
|
||||
id="lastName"
|
||||
label={t('credentials.lastName')}
|
||||
value={watch('Alias.LastName') ?? ''}
|
||||
onChange={(value) => setValue('Alias.LastName', value)}
|
||||
error={errors.Alias?.LastName?.message}
|
||||
/>
|
||||
<FormInput
|
||||
id="nickName"
|
||||
label={t('credentials.nickName')}
|
||||
value={watch('Alias.NickName') ?? ''}
|
||||
onChange={(value) => setValue('Alias.NickName', value)}
|
||||
error={errors.Alias?.NickName?.message}
|
||||
/>
|
||||
<FormInput
|
||||
id="gender"
|
||||
label={t('credentials.gender')}
|
||||
value={watch('Alias.Gender') ?? ''}
|
||||
onChange={(value) => setValue('Alias.Gender', value)}
|
||||
error={errors.Alias?.Gender?.message}
|
||||
/>
|
||||
<FormInput
|
||||
id="birthDate"
|
||||
label={t('credentials.birthDate')}
|
||||
placeholder={t('credentials.birthDatePlaceholder')}
|
||||
value={watch('Alias.BirthDate') ?? ''}
|
||||
onChange={(value) => setValue('Alias.BirthDate', value)}
|
||||
error={errors.Alias?.BirthDate?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('credentials.metadata')}</h2>
|
||||
<div className="space-y-4">
|
||||
<FormInput
|
||||
id="notes"
|
||||
label={t('credentials.notes')}
|
||||
value={watch('Notes') ?? ''}
|
||||
onChange={(value) => setValue('Notes', value)}
|
||||
multiline
|
||||
rows={4}
|
||||
error={errors.Notes?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AttachmentUploader
|
||||
attachments={attachments}
|
||||
onAttachmentsChange={setAttachments}
|
||||
originalAttachmentIds={originalAttachmentIds}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredentialAddEdit;
|
||||
@@ -1,72 +1,53 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { Credential } from '@/utils/types/Credential';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
|
||||
import {
|
||||
HeaderBlock,
|
||||
EmailBlock,
|
||||
TotpBlock,
|
||||
LoginCredentialsBlock,
|
||||
AliasBlock,
|
||||
NotesBlock
|
||||
NotesBlock,
|
||||
AttachmentBlock
|
||||
} from '@/entrypoints/popup/components/CredentialDetails';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
|
||||
|
||||
import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
|
||||
/**
|
||||
* Credential details page.
|
||||
*/
|
||||
const CredentialDetails: React.FC = () => {
|
||||
const CredentialDetails: React.FC = (): React.ReactElement => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const dbContext = useDb();
|
||||
const [credential, setCredential] = useState<Credential | null>(null);
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
|
||||
/**
|
||||
* Check if the current page is an expanded popup.
|
||||
*/
|
||||
const isPopup = (): boolean => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('expanded') === 'true';
|
||||
};
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
|
||||
/**
|
||||
* Open the credential details in a new expanded popup.
|
||||
*/
|
||||
const openInNewPopup = (): void => {
|
||||
const width = 380;
|
||||
const height = 600;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
window.open(
|
||||
`popup.html?expanded=true#/credentials/${id}`,
|
||||
'CredentialDetails',
|
||||
`width=${width},height=${height},left=${left},top=${top},popup=true`
|
||||
);
|
||||
|
||||
window.close();
|
||||
};
|
||||
const openInNewPopup = useCallback((): void => {
|
||||
PopoutUtility.openInNewPopup(`/credentials/${id}`);
|
||||
}, [id]);
|
||||
|
||||
/**
|
||||
* Check if the email domain is supported.
|
||||
* Navigate to the edit page for this credential.
|
||||
*/
|
||||
const isEmailDomainSupported = (email: string): boolean => {
|
||||
const domain = email.split('@')[1]?.toLowerCase();
|
||||
if (!domain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const publicDomains = dbContext.publicEmailDomains ?? [];
|
||||
const privateDomains = dbContext.privateEmailDomains ?? [];
|
||||
|
||||
return [...publicDomains, ...privateDomains].some(supportedDomain =>
|
||||
domain === supportedDomain || domain.endsWith(`.${supportedDomain}`)
|
||||
);
|
||||
};
|
||||
const handleEdit = useCallback((): void => {
|
||||
navigate(`/credentials/${id}/edit`);
|
||||
}, [id, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPopup()) {
|
||||
if (PopoutUtility.isPopup()) {
|
||||
window.history.replaceState({}, '', `popup.html#/credentials`);
|
||||
window.history.pushState({}, '', `popup.html#/credentials/${id}`);
|
||||
}
|
||||
@@ -89,23 +70,52 @@ const CredentialDetails: React.FC = () => {
|
||||
}
|
||||
}, [dbContext.sqliteClient, id, navigate, setIsInitialLoading]);
|
||||
|
||||
// Set header buttons on mount and clear on unmount
|
||||
useEffect((): (() => void) => {
|
||||
const headerButtonsJSX = (
|
||||
<div className="flex items-center gap-2">
|
||||
{!PopoutUtility.isPopup() && (
|
||||
<HeaderButton
|
||||
onClick={openInNewPopup}
|
||||
title={t('common.openInNewWindow')}
|
||||
iconType={HeaderIconType.EXPAND}
|
||||
/>
|
||||
)}
|
||||
<HeaderButton
|
||||
onClick={handleEdit}
|
||||
title={t('credentials.editCredential')}
|
||||
iconType={HeaderIconType.EDIT}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
setHeaderButtons(headerButtonsJSX);
|
||||
return () => {};
|
||||
}, [setHeaderButtons, handleEdit, openInNewPopup, t]);
|
||||
|
||||
// Clear header buttons on unmount
|
||||
useEffect((): (() => void) => {
|
||||
return () => setHeaderButtons(null);
|
||||
}, [setHeaderButtons]);
|
||||
|
||||
if (!credential) {
|
||||
return <div>Loading...</div>;
|
||||
return <div>{t('common.loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<HeaderBlock credential={credential} onOpenNewPopup={openInNewPopup} />
|
||||
<div className="flex justify-between items-center">
|
||||
<HeaderBlock credential={credential} />
|
||||
</div>
|
||||
{credential.Alias?.Email && (
|
||||
<EmailBlock
|
||||
email={credential.Alias.Email}
|
||||
isSupported={isEmailDomainSupported(credential.Alias.Email)}
|
||||
/>
|
||||
)}
|
||||
<NotesBlock notes={credential.Notes} />
|
||||
<TotpBlock credentialId={credential.Id} />
|
||||
<LoginCredentialsBlock credential={credential} />
|
||||
<AliasBlock credential={credential} />
|
||||
<NotesBlock notes={credential.Notes} />
|
||||
<AttachmentBlock credentialId={credential.Id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,30 +1,49 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import CredentialCard from '@/entrypoints/popup/components/CredentialCard';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import ReloadButton from '@/entrypoints/popup/components/ReloadButton';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { Credential } from '@/utils/types/Credential';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
import { VaultResponse } from '@/utils/types/webapi/VaultResponse';
|
||||
import ReloadButton from '@/entrypoints/popup/components/ReloadButton';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync';
|
||||
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
|
||||
|
||||
import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
|
||||
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
|
||||
import CredentialCard from '@/entrypoints/popup/components/CredentialCard';
|
||||
|
||||
/**
|
||||
* Credentials list page.
|
||||
*/
|
||||
const CredentialsList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
const navigate = useNavigate();
|
||||
const { syncVault } = useVaultSync();
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { showLoading, hideLoading, setIsInitialLoading } = useLoading();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
|
||||
/**
|
||||
* Loading state with minimum duration for more fluid UX.
|
||||
*/
|
||||
const [isLoading, setIsLoading] = useMinDurationLoading(true, 100);
|
||||
|
||||
/**
|
||||
* Handle add new credential.
|
||||
*/
|
||||
const handleAddCredential = useCallback(() : void => {
|
||||
navigate('/credentials/add');
|
||||
}, [navigate]);
|
||||
|
||||
/**
|
||||
* Retrieve latest vault and refresh the credentials list.
|
||||
*/
|
||||
@@ -33,92 +52,106 @@ const CredentialsList: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Do status check first to ensure the extension is (still) supported.
|
||||
const statusResponse = await webApi.getStatus();
|
||||
const statusError = webApi.validateStatusResponse(statusResponse);
|
||||
if (statusError !== null) {
|
||||
await webApi.logout(statusError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// If the vault revision is the same or lower, (re)load existing credentials.
|
||||
if (statusResponse.vaultRevision <= dbContext.vaultRevision) {
|
||||
const results = dbContext.sqliteClient.getAllCredentials();
|
||||
setCredentials(results);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the vault revision is higher, fetch the latest vault and initialize the SQLite context again.
|
||||
* This will trigger a new credentials list refresh.
|
||||
*/
|
||||
const vaultResponseJson = await webApi.get<VaultResponse>('Vault');
|
||||
|
||||
const vaultError = webApi.validateVaultResponse(vaultResponseJson);
|
||||
if (vaultError) {
|
||||
await webApi.logout(vaultError);
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get derived key from background worker
|
||||
const passwordHashBase64 = await sendMessage('GET_DERIVED_KEY', {}, 'background') as string;
|
||||
|
||||
// Initialize the SQLite context again with the newly retrieved decrypted blob)
|
||||
try {
|
||||
await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
|
||||
} catch {
|
||||
// Sync vault and load credentials
|
||||
await syncVault({
|
||||
/**
|
||||
* If error occurs during database initialization, it most likely has to do with decryption that
|
||||
* failed. This is most likely due to the user changing their password.
|
||||
* So we logout the user here to force them to re-authenticate.
|
||||
* On success.
|
||||
*/
|
||||
await webApi.logout('Vault could not be decrypted, please re-authenticate.');
|
||||
}
|
||||
onSuccess: async (_hasNewVault) => {
|
||||
// Credentials list is refreshed automatically when the (new) sqlite client is available via useEffect hook below.
|
||||
},
|
||||
/**
|
||||
* On offline.
|
||||
*/
|
||||
_onOffline: () => {
|
||||
// Not implemented for browser extension yet.
|
||||
},
|
||||
/**
|
||||
* On error.
|
||||
*/
|
||||
onError: async (error) => {
|
||||
console.error('Error syncing vault:', error);
|
||||
await webApi.logout('Error while syncing vault, please re-authenticate.');
|
||||
navigate('/logout');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Refresh error:', err);
|
||||
console.error('Error refreshing credentials:', err);
|
||||
await webApi.logout('Error while syncing vault, please re-authenticate.');
|
||||
navigate('/logout');
|
||||
}
|
||||
}, [dbContext, webApi, hideLoading]);
|
||||
}, [dbContext, webApi, syncVault, navigate]);
|
||||
|
||||
/**
|
||||
* Manually refresh the credentials list.
|
||||
* Get latest vault from server and refresh the credentials list.
|
||||
*/
|
||||
const onManualRefresh = async (): Promise<void> => {
|
||||
showLoading();
|
||||
const syncVaultAndRefresh = useCallback(async () : Promise<void> => {
|
||||
setIsLoading(true);
|
||||
await onRefresh();
|
||||
hideLoading();
|
||||
};
|
||||
setIsLoading(false);
|
||||
}, [onRefresh, setIsLoading]);
|
||||
|
||||
// Set header buttons on mount and clear on unmount
|
||||
useEffect((): (() => void) => {
|
||||
const headerButtonsJSX = (
|
||||
<div className="flex items-center gap-2">
|
||||
{!PopoutUtility.isPopup() && (
|
||||
<HeaderButton
|
||||
onClick={() => PopoutUtility.openInNewPopup()}
|
||||
title="Open in new window"
|
||||
iconType={HeaderIconType.EXPAND}
|
||||
/>
|
||||
)}
|
||||
<HeaderButton
|
||||
onClick={handleAddCredential}
|
||||
title="Add new credential"
|
||||
iconType={HeaderIconType.PLUS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
setHeaderButtons(headerButtonsJSX);
|
||||
return () => setHeaderButtons(null);
|
||||
}, [setHeaderButtons, handleAddCredential]);
|
||||
|
||||
/**
|
||||
* Load credentials list on mount and on sqlite client change.
|
||||
*/
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Refresh credentials list when sqlite client is available.
|
||||
* Refresh credentials list when a (new) sqlite client is available.
|
||||
*/
|
||||
const refreshCredentials = async () : Promise<void> => {
|
||||
if (dbContext?.sqliteClient) {
|
||||
setIsLoading(true);
|
||||
await onRefresh();
|
||||
const results = dbContext.sqliteClient?.getAllCredentials() ?? [];
|
||||
setCredentials(results);
|
||||
setIsLoading(false);
|
||||
|
||||
// Hide the global app initial loading state after the credentials list is loaded.
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
refreshCredentials();
|
||||
}, [dbContext?.sqliteClient, onRefresh, setIsLoading, setIsInitialLoading]);
|
||||
}, [dbContext?.sqliteClient, setIsLoading, setIsInitialLoading]);
|
||||
|
||||
// Add this function to filter credentials
|
||||
const filteredCredentials = credentials.filter(cred => {
|
||||
const filteredCredentials = credentials.filter(credential => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
|
||||
/**
|
||||
* We filter credentials by searching in the following fields:
|
||||
* - Service name
|
||||
* - Username
|
||||
* - Alias email
|
||||
* - Service URL
|
||||
* - Notes
|
||||
*/
|
||||
const searchableFields = [
|
||||
cred.ServiceName?.toLowerCase(),
|
||||
cred.Username?.toLowerCase(),
|
||||
cred.Alias?.Email?.toLowerCase(),
|
||||
cred.ServiceUrl?.toLowerCase()
|
||||
credential.ServiceName?.toLowerCase(),
|
||||
credential.Username?.toLowerCase(),
|
||||
credential.Alias?.Email?.toLowerCase(),
|
||||
credential.ServiceUrl?.toLowerCase(),
|
||||
credential.Notes?.toLowerCase(),
|
||||
];
|
||||
return searchableFields.some(field => field?.includes(searchLower));
|
||||
});
|
||||
@@ -134,19 +167,21 @@ const CredentialsList: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-gray-900 dark:text-white text-xl">Credentials</h2>
|
||||
<ReloadButton onClick={onManualRefresh} />
|
||||
<h2 className="text-gray-900 dark:text-white text-xl">{t('credentials.title')}</h2>
|
||||
<ReloadButton onClick={syncVaultAndRefresh} />
|
||||
</div>
|
||||
|
||||
{credentials.length > 0 ? (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search credentials..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
autoFocus
|
||||
className="w-full p-2 mb-4 border dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={`${t('content.searchVault')}`}
|
||||
autoFocus
|
||||
className="w-full p-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
@@ -154,13 +189,10 @@ const CredentialsList: React.FC = () => {
|
||||
{credentials.length === 0 ? (
|
||||
<div className="text-gray-500 dark:text-gray-400 space-y-2 mb-10">
|
||||
<p className="text-sm">
|
||||
Welcome to AliasVault!
|
||||
{t('credentials.welcomeTitle')}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
To use the AliasVault browser extension: navigate to a website and use the AliasVault autofill popup to create a new credential.
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
If you want to create manual identities, open the full AliasVault app via the popout icon in the top right corner.
|
||||
{t('credentials.welcomeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Email } from '@/utils/types/webapi/Email';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
|
||||
import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
import { Attachment } from '@/utils/types/webapi/Attachment';
|
||||
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';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
import ConversionUtility from '@/entrypoints/popup/utils/ConversionUtility';
|
||||
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
|
||||
|
||||
import type { EmailAttachment, Email } from '@/utils/dist/shared/models/webapi';
|
||||
import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
|
||||
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
|
||||
|
||||
import HeaderButton from '../components/HeaderButton';
|
||||
import { HeaderIconType } from '../components/Icons/HeaderIcons';
|
||||
|
||||
/**
|
||||
* Email details page.
|
||||
*/
|
||||
const EmailDetails: React.FC = () => {
|
||||
const EmailDetails: React.FC = (): React.ReactElement => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const dbContext = useDb();
|
||||
@@ -21,20 +31,14 @@ const EmailDetails: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState<Email | null>(null);
|
||||
const [isLoading, setIsLoading] = useMinDurationLoading(true, 150);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
|
||||
/**
|
||||
* Make sure the initial loading state is set to false when this component is loaded itself.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
}, [setIsInitialLoading, isLoading]);
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
const [headerButtonsConfigured, setHeaderButtonsConfigured] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// For popup windows, ensure we have proper history state for navigation
|
||||
if (isPopup()) {
|
||||
if (PopoutUtility.isPopup()) {
|
||||
// Clear existing history and create fresh entries
|
||||
window.history.replaceState({}, '', `popup.html#/emails`);
|
||||
window.history.pushState({}, '', `popup.html#/emails/${id}`);
|
||||
@@ -62,58 +66,43 @@ const EmailDetails: React.FC = () => {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadEmail();
|
||||
}, [id, dbContext?.sqliteClient, webApi, setIsLoading]);
|
||||
}, [id, dbContext?.sqliteClient, webApi, setIsLoading, setIsInitialLoading]);
|
||||
|
||||
/**
|
||||
* Handle deleting an email.
|
||||
*/
|
||||
const handleDelete = async () : Promise<void> => {
|
||||
const handleDelete = useCallback(async () : Promise<void> => {
|
||||
try {
|
||||
await webApi.delete(`Email/${id}`);
|
||||
navigate('/emails');
|
||||
if (PopoutUtility.isPopup()) {
|
||||
window.close();
|
||||
} else {
|
||||
navigate('/emails');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete email');
|
||||
}
|
||||
};
|
||||
}, [id, webApi, navigate]);
|
||||
|
||||
/**
|
||||
* Check if the current page is an expanded popup.
|
||||
* Open the email details in a new expanded popup.
|
||||
*/
|
||||
const isPopup = () : boolean => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('expanded') === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the credential details in a new expanded popup.
|
||||
*/
|
||||
const openInNewPopup = () : void => {
|
||||
const width = 800;
|
||||
const height = 1000;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
window.open(
|
||||
`popup.html?expanded=true#/emails/${id}`,
|
||||
'EmailDetails',
|
||||
`width=${width},height=${height},left=${left},top=${top},popup=true`
|
||||
);
|
||||
|
||||
// Close the current tab
|
||||
window.close();
|
||||
};
|
||||
const openInNewPopup = useCallback((): void => {
|
||||
PopoutUtility.openInNewPopup(`/emails/${id}`);
|
||||
}, [id]);
|
||||
|
||||
/**
|
||||
* Handle downloading an attachment.
|
||||
*/
|
||||
const handleDownloadAttachment = async (attachment: Attachment): Promise<void> => {
|
||||
const handleDownloadAttachment = async (attachment: EmailAttachment): Promise<void> => {
|
||||
try {
|
||||
// Get the encrypted attachment bytes from the API
|
||||
const base64EncryptedAttachment = await webApi.downloadBlobAndConvertToBase64(`Email/${id}/attachments/${attachment.id}`);
|
||||
const encryptedBytes = await webApi.downloadBlob(`Email/${id}/attachments/${attachment.id}`);
|
||||
|
||||
if (!dbContext?.sqliteClient || !email) {
|
||||
setError('Database context or email not available');
|
||||
@@ -123,16 +112,18 @@ const EmailDetails: React.FC = () => {
|
||||
// Get encryption keys for decryption
|
||||
const encryptionKeys = dbContext.sqliteClient.getAllEncryptionKeys();
|
||||
|
||||
// Decrypt the attachment using ArrayBuffer
|
||||
const decryptedBytes = await EncryptionUtility.decryptAttachment(base64EncryptedAttachment, email, encryptionKeys);
|
||||
// Decrypt the attachment using raw bytes
|
||||
const decryptedBytes = await EncryptionUtility.decryptAttachment(encryptedBytes, email, encryptionKeys);
|
||||
|
||||
if (!decryptedBytes) {
|
||||
setError('Failed to decrypt attachment');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create blob from decrypted bytes with proper MIME type
|
||||
const blob = new Blob([decryptedBytes], { type: attachment.mimeType ?? 'application/octet-stream' });
|
||||
// Create Blob directly from Uint8Array
|
||||
const blob = new Blob([new Uint8Array(decryptedBytes)], {
|
||||
type: attachment.mimeType ?? 'application/octet-stream'
|
||||
});
|
||||
|
||||
// Create download link and trigger download
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
@@ -151,6 +142,39 @@ const EmailDetails: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Set header buttons on mount and clear on unmount
|
||||
useEffect((): (() => void) => {
|
||||
// Only set the header buttons once on mount.
|
||||
if (!headerButtonsConfigured) {
|
||||
const headerButtonsJSX = (
|
||||
<div className="flex items-center gap-2">
|
||||
{!PopoutUtility.isPopup() && (
|
||||
<HeaderButton
|
||||
onClick={openInNewPopup}
|
||||
title={t('common.openInNewWindow')}
|
||||
iconType={HeaderIconType.EXPAND}
|
||||
/>
|
||||
)}
|
||||
<HeaderButton
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
title={t('emails.deleteEmail')}
|
||||
iconType={HeaderIconType.DELETE}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
setHeaderButtons(headerButtonsJSX);
|
||||
setHeaderButtonsConfigured(true);
|
||||
}
|
||||
return () => {};
|
||||
}, [setHeaderButtons, headerButtonsConfigured, openInNewPopup, t]);
|
||||
|
||||
// Clear header buttons on unmount
|
||||
useEffect((): (() => void) => {
|
||||
return () => setHeaderButtons(null);
|
||||
}, [setHeaderButtons]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
@@ -160,67 +184,39 @@ const EmailDetails: React.FC = () => {
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">Error: {error}</div>;
|
||||
return <div className="text-red-500">{t('common.error')} {error}</div>;
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
return <div className="text-gray-500">Email not found</div>;
|
||||
return <div className="text-gray-500">{t('emails.emailNotFound')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Modal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={() => {
|
||||
setShowDeleteModal(false);
|
||||
void handleDelete();
|
||||
}}
|
||||
title={t('emails.deleteEmailTitle')}
|
||||
message={t('emails.deleteEmailConfirm')}
|
||||
confirmText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{email.subject}</h1>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={openInNewPopup}
|
||||
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
title="Open in new window"
|
||||
>
|
||||
<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="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-2 text-red-500 hover:text-red-600 rounded-md hover:bg-red-100 dark:hover:bg-red-900/20"
|
||||
title="Delete email"
|
||||
>
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>From: {email.fromDisplay} ({email.fromLocal}@{email.fromDomain})</p>
|
||||
<p>To: {email.toLocal}@{email.toDomain}</p>
|
||||
<p>Date: {new Date(email.dateSystem).toLocaleString()}</p>
|
||||
<p>{t('emails.from')} {email.fromDisplay} ({email.fromLocal}@{email.fromDomain})</p>
|
||||
<p>{t('emails.to')} {email.toLocal}@{email.toDomain}</p>
|
||||
<p>{t('emails.date')} {new Date(email.dateSystem).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -230,10 +226,10 @@ const EmailDetails: React.FC = () => {
|
||||
<iframe
|
||||
srcDoc={ConversionUtility.convertAnchorTagsToOpenInNewTab(email.messageHtml)}
|
||||
className="w-full min-h-[500px] border-0"
|
||||
title="Email content"
|
||||
title={t('emails.emailContent')}
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">
|
||||
<pre className="whitespace-pre-wrap text-gray-800 p-3">
|
||||
{email.messagePlain}
|
||||
</pre>
|
||||
)}
|
||||
@@ -243,7 +239,7 @@ const EmailDetails: React.FC = () => {
|
||||
{email.attachments && email.attachments.length > 0 && (
|
||||
<div className="p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
Attachments
|
||||
{t('emails.attachments')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{email.attachments.map((attachment) => (
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MailboxBulkRequest, MailboxBulkResponse } from '@/utils/types/webapi/MailboxBulk';
|
||||
import { MailboxEmail } from '@/utils/types/webapi/MailboxEmail';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
|
||||
import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
import ReloadButton from '@/entrypoints/popup/components/ReloadButton';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
|
||||
|
||||
import type { MailboxBulkRequest, MailboxBulkResponse, MailboxEmail } from '@/utils/dist/shared/models/webapi';
|
||||
import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
|
||||
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
|
||||
|
||||
/**
|
||||
* Emails list page.
|
||||
*/
|
||||
const EmailsList: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [emails, setEmails] = useState<MailboxEmail[]>([]);
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
|
||||
/**
|
||||
* Loading state with minimum duration for more fluid UX.
|
||||
@@ -55,19 +66,37 @@ const EmailsList: React.FC = () => {
|
||||
setEmails(decryptedEmails);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error('Failed to load emails');
|
||||
throw new Error(t('emails.errors.emailLoadError'));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
setError(err instanceof Error ? err.message : t('common.errors.unknownError'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
}, [dbContext?.sqliteClient, webApi, setIsLoading]);
|
||||
}, [dbContext?.sqliteClient, webApi, setIsLoading, setIsInitialLoading, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEmails();
|
||||
}, [loadEmails]);
|
||||
|
||||
// Set header buttons on mount and clear on unmount
|
||||
useEffect((): (() => void) => {
|
||||
const headerButtonsJSX = !PopoutUtility.isPopup() ? (
|
||||
<HeaderButton
|
||||
onClick={() => PopoutUtility.openInNewPopup()}
|
||||
title={t('common.openInNewWindow')}
|
||||
iconType={HeaderIconType.EXPAND}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
setHeaderButtons(headerButtonsJSX);
|
||||
|
||||
return () => {
|
||||
setHeaderButtons(null);
|
||||
};
|
||||
}, [setHeaderButtons, t]);
|
||||
|
||||
/**
|
||||
* Formats the date display for emails
|
||||
*/
|
||||
@@ -77,18 +106,26 @@ const EmailsList: React.FC = () => {
|
||||
const secondsAgo = Math.floor((now.getTime() - emailDate.getTime()) / 1000);
|
||||
|
||||
if (secondsAgo < 60) {
|
||||
return 'just now';
|
||||
return t('emails.dateFormat.justNow');
|
||||
} else if (secondsAgo < 3600) {
|
||||
// Less than 1 hour ago
|
||||
const minutes = Math.floor(secondsAgo / 60);
|
||||
return `${minutes} ${minutes === 1 ? 'min' : 'mins'} ago`;
|
||||
if (minutes === 1) {
|
||||
return t('emails.dateFormat.minutesAgo_single', { count: minutes });
|
||||
} else {
|
||||
return t('emails.dateFormat.minutesAgo_plural', { count: minutes });
|
||||
}
|
||||
} else if (secondsAgo < 86400) {
|
||||
// Less than 24 hours ago
|
||||
const hours = Math.floor(secondsAgo / 3600);
|
||||
return `${hours} ${hours === 1 ? 'hr' : 'hrs'} ago`;
|
||||
if (hours === 1) {
|
||||
return t('emails.dateFormat.hoursAgo_single', { count: hours });
|
||||
} else {
|
||||
return t('emails.dateFormat.hoursAgo_plural', { count: hours });
|
||||
}
|
||||
} else if (secondsAgo < 172800) {
|
||||
// Less than 48 hours ago
|
||||
return 'yesterday';
|
||||
return t('emails.dateFormat.yesterday');
|
||||
} else {
|
||||
// Older than 48 hours
|
||||
return emailDate.toLocaleDateString('en-GB', {
|
||||
@@ -107,19 +144,19 @@ const EmailsList: React.FC = () => {
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">Error: {error}</div>;
|
||||
return <div className="text-red-500">{t('common.error')}: {error}</div>;
|
||||
}
|
||||
|
||||
if (emails.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-gray-900 dark:text-white text-xl">Emails</h2>
|
||||
<h2 className="text-gray-900 dark:text-white text-xl">{t('emails.title')}</h2>
|
||||
<ReloadButton onClick={loadEmails} />
|
||||
</div>
|
||||
<div className="text-gray-500 dark:text-gray-400 space-y-2">
|
||||
<p className="text-sm">
|
||||
You have not received any emails at your private email addresses yet. When you receive a new email, it will appear here.
|
||||
{t('emails.noEmailsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,7 +166,7 @@ const EmailsList: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-gray-900 dark:text-white text-xl">Emails</h2>
|
||||
<h2 className="text-gray-900 dark:text-white text-xl">{t('emails.title')}</h2>
|
||||
<ReloadButton onClick={loadEmails} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import Unlock from '@/entrypoints/popup/pages/Unlock';
|
||||
import Login from '@/entrypoints/popup/pages/Login';
|
||||
import UnlockSuccess from '@/entrypoints/popup/pages/UnlockSuccess';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
|
||||
/**
|
||||
* Home page that shows the correct page based on the user's authentication state.
|
||||
*/
|
||||
const Home: React.FC = () => {
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
const navigate = useNavigate();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const [isInlineUnlockMode, setIsInlineUnlockMode] = useState(false);
|
||||
|
||||
// Initialization state.
|
||||
const isFullyInitialized = authContext.isInitialized && dbContext.dbInitialized;
|
||||
const isAuthenticated = authContext.isLoggedIn;
|
||||
const isDatabaseAvailable = dbContext.dbAvailable;
|
||||
const requireLoginOrUnlock = isFullyInitialized && (!isAuthenticated || !isDatabaseAvailable || isInlineUnlockMode);
|
||||
|
||||
useEffect(() => {
|
||||
// Detect if the user is coming from the unlock page with mode=inline_unlock.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isInlineUnlockMode = urlParams.get('mode') === 'inline_unlock';
|
||||
setIsInlineUnlockMode(isInlineUnlockMode);
|
||||
|
||||
// Redirect to credentials if fully initialized and doesn't need unlock.
|
||||
if (isFullyInitialized && !requireLoginOrUnlock) {
|
||||
navigate('/credentials', { replace: true });
|
||||
}
|
||||
}, [isFullyInitialized, requireLoginOrUnlock, isInlineUnlockMode, navigate]);
|
||||
|
||||
// Show loading state if not fully initialized or when about to redirect to credentials.
|
||||
if (!isFullyInitialized || (isFullyInitialized && !requireLoginOrUnlock)) {
|
||||
// Global loading spinner will be shown by the parent component.
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsInitialLoading(false);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
if (!isDatabaseAvailable) {
|
||||
return <Unlock />;
|
||||
}
|
||||
|
||||
if (isInlineUnlockMode) {
|
||||
return <UnlockSuccess onClose={() => setIsInlineUnlockMode(false)} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
20
apps/browser-extension/src/entrypoints/popup/pages/Index.tsx
Normal file
20
apps/browser-extension/src/entrypoints/popup/pages/Index.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { useNavigation } from '@/entrypoints/popup/context/NavigationContext';
|
||||
|
||||
/**
|
||||
* Home page that shows the correct page based on the user's authentication state.
|
||||
* Most of the navigation logic is now handled by NavigationContext.
|
||||
*/
|
||||
const Home: React.FC = () => {
|
||||
const { isFullyInitialized } = useNavigation();
|
||||
|
||||
if (!isFullyInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Navigate to="/reinitialize" replace />;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
@@ -1,30 +1,44 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Buffer } from 'buffer';
|
||||
import { storage } from '#imports';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import Button from '@/entrypoints/popup/components/Button';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import LoginServerInfo from '@/entrypoints/popup/components/LoginServerInfo';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
import { AppInfo } from '@/utils/AppInfo';
|
||||
import Button from '@/entrypoints/popup/components/Button';
|
||||
import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
import SrpUtility from '@/entrypoints/popup/utils/SrpUtility';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { VaultResponse } from '@/utils/types/webapi/VaultResponse';
|
||||
import { LoginResponse } from '@/utils/types/webapi/Login';
|
||||
import LoginServerInfo from '@/entrypoints/popup/components/LoginServerInfo';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
|
||||
import SrpUtility from '@/entrypoints/popup/utils/SrpUtility';
|
||||
|
||||
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 ConversionUtility from '../utils/ConversionUtility';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
/**
|
||||
* Login page
|
||||
*/
|
||||
const Login: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
const [credentials, setCredentials] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
const { showLoading, hideLoading } = useLoading();
|
||||
const { showLoading, hideLoading, setIsInitialLoading } = useLoading();
|
||||
const [rememberMe, setRememberMe] = useState(true);
|
||||
const [loginResponse, setLoginResponse] = useState<LoginResponse | null>(null);
|
||||
const [passwordHashString, setPasswordHashString] = useState<string | null>(null);
|
||||
@@ -36,6 +50,66 @@ const Login: React.FC = () => {
|
||||
const webApi = useWebApi();
|
||||
const srpUtil = new SrpUtility(webApi);
|
||||
|
||||
/**
|
||||
* Handle successful authentication by storing tokens and initializing the database
|
||||
*/
|
||||
const handleSuccessfulAuth = async (
|
||||
username: string,
|
||||
token: string,
|
||||
refreshToken: string,
|
||||
passwordHashBase64: string,
|
||||
loginResponse: LoginResponse
|
||||
) : Promise<void> => {
|
||||
// Try to get latest vault manually providing auth token.
|
||||
const vaultResponseJson = await webApi.authFetch<VaultResponse>('Vault', { method: 'GET', headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
} });
|
||||
|
||||
const vaultError = webApi.validateVaultResponse(vaultResponseJson, t);
|
||||
if (vaultError) {
|
||||
setError(vaultError);
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// All is good. Store auth info which is required to make requests to the web API.
|
||||
await authContext.setAuthTokens(username, token, refreshToken);
|
||||
|
||||
// Store the encryption key and derivation params separately
|
||||
await dbContext.storeEncryptionKey(passwordHashBase64);
|
||||
await dbContext.storeEncryptionKeyDerivationParams({
|
||||
salt: loginResponse.salt,
|
||||
encryptionType: loginResponse.encryptionType,
|
||||
encryptionSettings: loginResponse.encryptionSettings
|
||||
});
|
||||
|
||||
// Initialize the SQLite context with the new vault data.
|
||||
const sqliteClient = await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
|
||||
|
||||
// Set logged in status to true which refreshes the app.
|
||||
await authContext.login();
|
||||
|
||||
// If there are pending migrations, redirect to the upgrade page.
|
||||
try {
|
||||
if (await sqliteClient.hasPendingMigrations()) {
|
||||
navigate('/upgrade', { replace: true });
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
await authContext.logout();
|
||||
setError(err instanceof Error ? err.message : t('auth.errors.migrationError'));
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to reinitialize page which will take care of the proper redirect.
|
||||
navigate('/reinitialize', { replace: true });
|
||||
|
||||
// Show app.
|
||||
hideLoading();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Load the client URL from the storage.
|
||||
@@ -48,9 +122,29 @@ const Login: React.FC = () => {
|
||||
}
|
||||
|
||||
setClientUrl(clientUrl);
|
||||
setIsInitialLoading(false);
|
||||
};
|
||||
loadClientUrl();
|
||||
}, []);
|
||||
}, [setIsInitialLoading]);
|
||||
|
||||
// Set header buttons on mount and clear on unmount
|
||||
useEffect((): (() => void) => {
|
||||
const headerButtonsJSX = !PopoutUtility.isPopup() ? (
|
||||
<>
|
||||
<HeaderButton
|
||||
onClick={() => PopoutUtility.openInNewPopup()}
|
||||
title="Open in new window"
|
||||
iconType={HeaderIconType.EXPAND}
|
||||
/>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
setHeaderButtons(headerButtonsJSX);
|
||||
|
||||
return () => {
|
||||
setHeaderButtons(null);
|
||||
};
|
||||
}, [setHeaderButtons]);
|
||||
|
||||
/**
|
||||
* Handle submit
|
||||
@@ -66,7 +160,7 @@ const Login: React.FC = () => {
|
||||
authContext.clearGlobalMessage();
|
||||
|
||||
// Use the srpUtil instance instead of the imported singleton
|
||||
const loginResponse = await srpUtil.initiateLogin(credentials.username);
|
||||
const loginResponse = await srpUtil.initiateLogin(ConversionUtility.normalizeUsername(credentials.username));
|
||||
|
||||
// 1. Derive key from password using Argon2id
|
||||
const passwordHash = await EncryptionUtility.deriveKeyFromPassword(
|
||||
@@ -84,7 +178,7 @@ const Login: React.FC = () => {
|
||||
|
||||
// 2. Validate login with SRP protocol
|
||||
const validationResponse = await srpUtil.validateLogin(
|
||||
credentials.username,
|
||||
ConversionUtility.normalizeUsername(credentials.username),
|
||||
passwordHashString,
|
||||
rememberMe,
|
||||
loginResponse
|
||||
@@ -106,38 +200,23 @@ const Login: React.FC = () => {
|
||||
|
||||
// Check if token was returned.
|
||||
if (!validationResponse.token) {
|
||||
throw new Error('Login failed -- no token returned');
|
||||
throw new Error(t('auth.errors.noToken'));
|
||||
}
|
||||
|
||||
// Try to get latest vault manually providing auth token.
|
||||
const vaultResponseJson = await webApi.authFetch<VaultResponse>('Vault', { method: 'GET', headers: {
|
||||
'Authorization': `Bearer ${validationResponse.token.token}`
|
||||
} });
|
||||
|
||||
const vaultError = webApi.validateVaultResponse(vaultResponseJson);
|
||||
if (vaultError) {
|
||||
setError(vaultError);
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// All is good. Store auth info which is required to make requests to the web API.
|
||||
await authContext.setAuthTokens(credentials.username, validationResponse.token.token, validationResponse.token.refreshToken);
|
||||
|
||||
// Initialize the SQLite context with the new vault data.
|
||||
await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
|
||||
|
||||
// Set logged in status to true which refreshes the app.
|
||||
await authContext.login();
|
||||
|
||||
// Show app.
|
||||
hideLoading();
|
||||
// Handle successful authentication
|
||||
await handleSuccessfulAuth(
|
||||
ConversionUtility.normalizeUsername(credentials.username),
|
||||
validationResponse.token.token,
|
||||
validationResponse.token.refreshToken,
|
||||
passwordHashBase64,
|
||||
loginResponse
|
||||
);
|
||||
} catch (err) {
|
||||
// Show API authentication errors as-is.
|
||||
if (err instanceof ApiAuthError) {
|
||||
setError(err.message);
|
||||
setError(t('common.apiErrors.' + err.message));
|
||||
} else {
|
||||
setError('Could not reach AliasVault server. Please try again later or contact support if the problem persists.');
|
||||
setError(t('auth.errors.serverError'));
|
||||
}
|
||||
hideLoading();
|
||||
}
|
||||
@@ -154,17 +233,17 @@ const Login: React.FC = () => {
|
||||
showLoading();
|
||||
|
||||
if (!passwordHashString || !passwordHashBase64 || !loginResponse) {
|
||||
throw new Error('Required login data not found');
|
||||
throw new Error(t('auth.errors.loginDataMissing'));
|
||||
}
|
||||
|
||||
// Validate that 2FA code is a 6-digit number
|
||||
const code = twoFactorCode.trim();
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
throw new ApiAuthError('Please enter a valid 6-digit authentication code.');
|
||||
throw new Error(t('auth.errors.invalidCode'));
|
||||
}
|
||||
|
||||
const validationResponse = await srpUtil.validateLogin2Fa(
|
||||
credentials.username,
|
||||
ConversionUtility.normalizeUsername(credentials.username),
|
||||
passwordHashString,
|
||||
rememberMe,
|
||||
loginResponse,
|
||||
@@ -173,29 +252,17 @@ const Login: React.FC = () => {
|
||||
|
||||
// Check if token was returned.
|
||||
if (!validationResponse.token) {
|
||||
throw new Error('Login failed -- no token returned');
|
||||
throw new Error(t('auth.errors.noToken'));
|
||||
}
|
||||
|
||||
// Try to get latest vault manually providing auth token.
|
||||
const vaultResponseJson = await webApi.authFetch<VaultResponse>('Vault', { method: 'GET', headers: {
|
||||
'Authorization': `Bearer ${validationResponse.token.token}`
|
||||
} });
|
||||
|
||||
const vaultError = webApi.validateVaultResponse(vaultResponseJson);
|
||||
if (vaultError) {
|
||||
setError(vaultError);
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// All is good. Store auth info which is required to make requests to the web API.
|
||||
await authContext.setAuthTokens(credentials.username, validationResponse.token.token, validationResponse.token.refreshToken);
|
||||
|
||||
// Initialize the SQLite context with the new vault data.
|
||||
await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
|
||||
|
||||
// Set logged in status to true which refreshes the app.
|
||||
await authContext.login();
|
||||
// Handle successful authentication
|
||||
await handleSuccessfulAuth(
|
||||
ConversionUtility.normalizeUsername(credentials.username),
|
||||
validationResponse.token.token,
|
||||
validationResponse.token.refreshToken,
|
||||
passwordHashBase64,
|
||||
loginResponse
|
||||
);
|
||||
|
||||
// Reset 2FA state and login response as it's no longer needed
|
||||
setTwoFactorRequired(false);
|
||||
@@ -203,14 +270,13 @@ const Login: React.FC = () => {
|
||||
setPasswordHashString(null);
|
||||
setPasswordHashBase64(null);
|
||||
setLoginResponse(null);
|
||||
hideLoading();
|
||||
} catch (err) {
|
||||
// Show API authentication errors as-is.
|
||||
console.error('2FA error:', err);
|
||||
if (err instanceof ApiAuthError) {
|
||||
setError(err.message);
|
||||
setError(t('common.apiErrors.' + err.message));
|
||||
} else {
|
||||
setError('Could not reach AliasVault server. Please try again later or contact support if the problem persists.');
|
||||
setError(t('auth.errors.serverError'));
|
||||
}
|
||||
hideLoading();
|
||||
}
|
||||
@@ -229,7 +295,7 @@ const Login: React.FC = () => {
|
||||
|
||||
if (twoFactorRequired) {
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<div>
|
||||
<form onSubmit={handleTwoFactorSubmit} 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">
|
||||
@@ -238,10 +304,10 @@ const Login: React.FC = () => {
|
||||
)}
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700 dark:text-gray-200 mb-4">
|
||||
Please enter the authentication code from your authenticator app.
|
||||
{t('auth.twoFactorTitle')}
|
||||
</p>
|
||||
<label className="block text-gray-700 dark:text-gray-200 text-sm font-bold mb-2" htmlFor="twoFactorCode">
|
||||
Authentication Code
|
||||
{t('auth.authCode')}
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 leading-tight focus:outline-none focus:shadow-outline"
|
||||
@@ -249,13 +315,13 @@ const Login: React.FC = () => {
|
||||
type="text"
|
||||
value={twoFactorCode}
|
||||
onChange={(e) => setTwoFactorCode(e.target.value)}
|
||||
placeholder="Enter 6-digit code"
|
||||
placeholder={t('auth.authCodePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-full space-y-2">
|
||||
<Button type="submit">
|
||||
Verify
|
||||
{t('auth.verify')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -274,11 +340,11 @@ const Login: React.FC = () => {
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
Cancel
|
||||
{t('auth.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4 text-center">
|
||||
Note: if you don't have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website.
|
||||
{t('auth.twoFactorNote')}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
@@ -286,25 +352,25 @@ const Login: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<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>
|
||||
)}
|
||||
<h2 className="text-xl font-bold dark:text-gray-200">Log in to AliasVault</h2>
|
||||
<h2 className="text-xl font-bold dark:text-gray-200">{t('auth.loginTitle')}</h2>
|
||||
<LoginServerInfo />
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-200 text-sm font-bold mb-2" htmlFor="username">
|
||||
Username or email
|
||||
{t('auth.username')}
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="username"
|
||||
type="text"
|
||||
name="username"
|
||||
placeholder="name / name@company.com"
|
||||
placeholder={t('auth.usernamePlaceholder')}
|
||||
value={credentials.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
@@ -312,14 +378,14 @@ const Login: React.FC = () => {
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-200 text-sm font-bold mb-2" htmlFor="password">
|
||||
Password
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 mb-3 leading-tight focus:outline-none focus:shadow-outline"
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Enter your password"
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
value={credentials.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
@@ -333,24 +399,24 @@ const Login: React.FC = () => {
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-200">Remember me</span>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-200">{t('auth.rememberMe')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
<Button type="submit">
|
||||
Login
|
||||
{t('auth.loginButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
No account yet?{' '}
|
||||
{t('auth.noAccount')}{' '}
|
||||
<a
|
||||
href={clientUrl ?? ''}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-orange-500 hover:text-orange-600 dark:text-orange-400 dark:hover:text-orange-500"
|
||||
>
|
||||
Create new vault
|
||||
{t('auth.createVault')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -19,7 +20,7 @@ const Logout: React.FC = () => {
|
||||
*/
|
||||
const performLogout = async () : Promise<void> => {
|
||||
await webApi.logout();
|
||||
navigate('/');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
performLogout();
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
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 { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
const LAST_VISITED_PAGE_KEY = 'session:lastVisitedPage';
|
||||
const LAST_VISITED_TIME_KEY = 'session:lastVisitedTime';
|
||||
const NAVIGATION_HISTORY_KEY = 'session:navigationHistory';
|
||||
const PAGE_MEMORY_DURATION = 120 * 1000; // 2 minutes in milliseconds
|
||||
|
||||
type NavigationHistoryEntry = {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize component that handles initial application setup, authentication checks,
|
||||
* vault synchronization, and state restoration.
|
||||
*/
|
||||
const Reinitialize: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const { syncVault } = useVaultSync();
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
// Auth and DB state
|
||||
const { isInitialized: authInitialized, isLoggedIn } = useAuth();
|
||||
const { dbInitialized, dbAvailable } = useDb();
|
||||
|
||||
// Derived state
|
||||
const isFullyInitialized = authInitialized && dbInitialized;
|
||||
const requiresAuth = isFullyInitialized && (!isLoggedIn || !dbAvailable);
|
||||
|
||||
/**
|
||||
* Restore the last visited page and navigation history if it was visited within the memory duration.
|
||||
*/
|
||||
const restoreLastPage = useCallback(async (): Promise<void> => {
|
||||
const [lastPage, lastVisitTime, savedHistory] = await Promise.all([
|
||||
storage.getItem(LAST_VISITED_PAGE_KEY) as Promise<string>,
|
||||
storage.getItem(LAST_VISITED_TIME_KEY) as Promise<number>,
|
||||
storage.getItem(NAVIGATION_HISTORY_KEY) as Promise<NavigationHistoryEntry[]>,
|
||||
]);
|
||||
|
||||
if (lastPage && lastVisitTime) {
|
||||
const timeSinceLastVisit = Date.now() - lastVisitTime;
|
||||
if (timeSinceLastVisit <= PAGE_MEMORY_DURATION) {
|
||||
// Restore the navigation history
|
||||
if (savedHistory?.length) {
|
||||
// First navigate to credentials page as the base
|
||||
navigate('/credentials', { replace: true });
|
||||
|
||||
// Then restore the history stack
|
||||
for (const entry of savedHistory) {
|
||||
navigate(entry.pathname + entry.search + entry.hash);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to simple navigation if no history
|
||||
navigate('/credentials', { replace: true });
|
||||
navigate(lastPage, { replace: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Duration has expired, clear all stored navigation data
|
||||
await Promise.all([
|
||||
storage.removeItem(LAST_VISITED_PAGE_KEY),
|
||||
storage.removeItem(LAST_VISITED_TIME_KEY),
|
||||
storage.removeItem(NAVIGATION_HISTORY_KEY),
|
||||
sendMessage('CLEAR_PERSISTED_FORM_VALUES', null, 'background'),
|
||||
]);
|
||||
|
||||
// Navigate to the credentials page as default entry page
|
||||
navigate('/credentials', { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for inline unlock mode
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const inlineUnlock = urlParams.get('mode') === 'inline_unlock';
|
||||
|
||||
if (isFullyInitialized) {
|
||||
// Prevent multiple vault syncs (only run sync once)
|
||||
const shouldRunSync = !hasInitialized.current;
|
||||
|
||||
if (requiresAuth) {
|
||||
setIsInitialLoading(false);
|
||||
|
||||
// Determine which auth page to show
|
||||
if (!isLoggedIn) {
|
||||
navigate('/login', { replace: true });
|
||||
} else if (!dbAvailable) {
|
||||
navigate('/unlock', { replace: true });
|
||||
}
|
||||
} else if (shouldRunSync) {
|
||||
// Only perform vault sync once during initialization
|
||||
hasInitialized.current = true;
|
||||
|
||||
// Perform vault sync and restore state
|
||||
syncVault({
|
||||
initialSync: false,
|
||||
/**
|
||||
* Handle successful vault sync.
|
||||
*/
|
||||
onSuccess: async () => {
|
||||
// After successful sync, try to restore last page or go to credentials
|
||||
if (inlineUnlock) {
|
||||
setIsInitialLoading(false);
|
||||
navigate('/unlock-success', { replace: true });
|
||||
} else {
|
||||
await restoreLastPage();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handle vault sync error.
|
||||
* @param error Error message
|
||||
*/
|
||||
onError: (error) => {
|
||||
console.error('Vault sync error during initialization:', error);
|
||||
// Even if sync fails, continue with initialization
|
||||
restoreLastPage().then(() => {
|
||||
setIsInitialLoading(false);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Handle upgrade required.
|
||||
*/
|
||||
onUpgradeRequired: () => {
|
||||
navigate('/upgrade', { replace: true });
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// User is logged in and db is available, navigate to appropriate page
|
||||
setIsInitialLoading(false);
|
||||
restoreLastPage();
|
||||
}
|
||||
}
|
||||
}, [isFullyInitialized, requiresAuth, isLoggedIn, dbAvailable, navigate, setIsInitialLoading, syncVault, restoreLastPage]);
|
||||
|
||||
// This component doesn't render anything visible - it just handles initialization
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Reinitialize;
|
||||
@@ -1,172 +1,89 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { storage } from "#imports";
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
import { DISABLED_SITES_KEY, GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, GLOBAL_CONTEXT_MENU_ENABLED_KEY, TEMPORARY_DISABLED_SITES_KEY } from '@/utils/Constants';
|
||||
import { AppInfo } from '@/utils/AppInfo';
|
||||
import { useTheme } from '@/entrypoints/popup/context/ThemeContext';
|
||||
import { browser } from "#imports";
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Popup settings type.
|
||||
*/
|
||||
type PopupSettings = {
|
||||
disabledUrls: string[];
|
||||
temporaryDisabledUrls: Record<string, number>;
|
||||
currentUrl: string;
|
||||
isEnabled: boolean;
|
||||
isGloballyEnabled: boolean;
|
||||
isContextMenuEnabled: boolean;
|
||||
}
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { useTheme } from '@/entrypoints/popup/context/ThemeContext';
|
||||
import { useApiUrl } from '@/entrypoints/popup/utils/ApiUrlUtility';
|
||||
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
|
||||
|
||||
import { AppInfo } from '@/utils/AppInfo';
|
||||
|
||||
import { browser } from "#imports";
|
||||
|
||||
/**
|
||||
* Settings page component.
|
||||
*/
|
||||
const Settings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [settings, setSettings] = useState<PopupSettings>({
|
||||
disabledUrls: [],
|
||||
temporaryDisabledUrls: {},
|
||||
currentUrl: '',
|
||||
isEnabled: true,
|
||||
isGloballyEnabled: true,
|
||||
isContextMenuEnabled: true
|
||||
});
|
||||
const authContext = useAuth();
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const { loadApiUrl, getDisplayUrl } = useApiUrl();
|
||||
const navigate = useNavigate();
|
||||
|
||||
/**
|
||||
* Get current tab in browser.
|
||||
* Open the client tab.
|
||||
*/
|
||||
const getCurrentTab = async (): Promise<browser.Tabs.Tab> => {
|
||||
const queryOptions = { active: true, currentWindow: true };
|
||||
const [tab] = await browser.tabs.query(queryOptions);
|
||||
return tab;
|
||||
const openClientTab = async () : Promise<void> => {
|
||||
const settingClientUrl = await browser.storage.local.get('clientUrl');
|
||||
let clientUrl = AppInfo.DEFAULT_CLIENT_URL;
|
||||
if (settingClientUrl?.clientUrl && settingClientUrl.clientUrl.length > 0) {
|
||||
clientUrl = settingClientUrl.clientUrl;
|
||||
}
|
||||
|
||||
window.open(clientUrl, '_blank');
|
||||
};
|
||||
|
||||
// Set header buttons on mount and clear on unmount
|
||||
useEffect((): (() => void) => {
|
||||
const headerButtonsJSX = (
|
||||
<div className="flex items-center gap-2">
|
||||
{!PopoutUtility.isPopup() && (
|
||||
<>
|
||||
<HeaderButton
|
||||
onClick={() => PopoutUtility.openInNewPopup()}
|
||||
title={t('settings.openInNewWindow')}
|
||||
iconType={HeaderIconType.EXPAND}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<HeaderButton
|
||||
onClick={openClientTab}
|
||||
title={t('settings.openWebApp')}
|
||||
iconType={HeaderIconType.EXTERNAL_LINK}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
setHeaderButtons(headerButtonsJSX);
|
||||
return () => setHeaderButtons(null);
|
||||
}, [setHeaderButtons, t]);
|
||||
|
||||
/**
|
||||
* Load settings.
|
||||
*/
|
||||
const loadSettings = useCallback(async () : Promise<void> => {
|
||||
const tab = await getCurrentTab();
|
||||
const currentUrl = new URL(tab.url ?? '').hostname;
|
||||
|
||||
// Load settings local storage.
|
||||
const disabledUrls = await storage.getItem(DISABLED_SITES_KEY) as string[] ?? [];
|
||||
const temporaryDisabledUrls = await storage.getItem(TEMPORARY_DISABLED_SITES_KEY) as Record<string, number> ?? {};
|
||||
const isGloballyEnabled = await storage.getItem(GLOBAL_AUTOFILL_POPUP_ENABLED_KEY) !== false; // Default to true if not set
|
||||
const isContextMenuEnabled = await storage.getItem(GLOBAL_CONTEXT_MENU_ENABLED_KEY) !== false; // Default to true if not set
|
||||
|
||||
// Clean up expired temporary disables
|
||||
const now = Date.now();
|
||||
const cleanedTemporaryDisabledUrls = Object.fromEntries(
|
||||
Object.entries(temporaryDisabledUrls).filter(([_, expiry]) => expiry > now)
|
||||
);
|
||||
|
||||
if (Object.keys(cleanedTemporaryDisabledUrls).length !== Object.keys(temporaryDisabledUrls).length) {
|
||||
await storage.setItem(TEMPORARY_DISABLED_SITES_KEY, cleanedTemporaryDisabledUrls);
|
||||
}
|
||||
|
||||
setSettings({
|
||||
disabledUrls,
|
||||
temporaryDisabledUrls: cleanedTemporaryDisabledUrls,
|
||||
currentUrl,
|
||||
isEnabled: !disabledUrls.includes(currentUrl) && !(currentUrl in cleanedTemporaryDisabledUrls),
|
||||
isGloballyEnabled,
|
||||
isContextMenuEnabled
|
||||
});
|
||||
}, []);
|
||||
// Load API URL
|
||||
await loadApiUrl();
|
||||
setIsInitialLoading(false);
|
||||
}, [setIsInitialLoading, loadApiUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
/**
|
||||
* Toggle current site.
|
||||
*/
|
||||
const toggleCurrentSite = async () : Promise<void> => {
|
||||
const { currentUrl, disabledUrls, temporaryDisabledUrls, isEnabled } = settings;
|
||||
|
||||
let newDisabledUrls = [...disabledUrls];
|
||||
let newTemporaryDisabledUrls = { ...temporaryDisabledUrls };
|
||||
|
||||
if (isEnabled) {
|
||||
// When disabling, add to permanent disabled list
|
||||
if (!newDisabledUrls.includes(currentUrl)) {
|
||||
newDisabledUrls.push(currentUrl);
|
||||
}
|
||||
// Also remove from temporary disabled list if present
|
||||
delete newTemporaryDisabledUrls[currentUrl];
|
||||
} else {
|
||||
// When enabling, remove from both permanent and temporary disabled lists
|
||||
newDisabledUrls = newDisabledUrls.filter(url => url !== currentUrl);
|
||||
delete newTemporaryDisabledUrls[currentUrl];
|
||||
}
|
||||
|
||||
await storage.setItem(DISABLED_SITES_KEY, newDisabledUrls);
|
||||
await storage.setItem(TEMPORARY_DISABLED_SITES_KEY, newTemporaryDisabledUrls);
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
disabledUrls: newDisabledUrls,
|
||||
temporaryDisabledUrls: newTemporaryDisabledUrls,
|
||||
isEnabled: !isEnabled
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset settings.
|
||||
*/
|
||||
const resetSettings = async () : Promise<void> => {
|
||||
await storage.setItem(DISABLED_SITES_KEY, []);
|
||||
await storage.setItem(TEMPORARY_DISABLED_SITES_KEY, {});
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
disabledUrls: [],
|
||||
temporaryDisabledUrls: {},
|
||||
isEnabled: true
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle global popup.
|
||||
*/
|
||||
const toggleGlobalPopup = async () : Promise<void> => {
|
||||
const newGloballyEnabled = !settings.isGloballyEnabled;
|
||||
|
||||
await storage.setItem(GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, newGloballyEnabled);
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
isGloballyEnabled: newGloballyEnabled
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle context menu.
|
||||
*/
|
||||
const toggleContextMenu = async () : Promise<void> => {
|
||||
const newContextMenuEnabled = !settings.isContextMenuEnabled;
|
||||
|
||||
await storage.setItem(GLOBAL_CONTEXT_MENU_ENABLED_KEY, newContextMenuEnabled);
|
||||
await sendMessage('TOGGLE_CONTEXT_MENU', { enabled: newContextMenuEnabled }, 'background');
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
isContextMenuEnabled: newContextMenuEnabled
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Set theme preference.
|
||||
*/
|
||||
const setThemePreference = async (newTheme: 'system' | 'light' | 'dark') : Promise<void> => {
|
||||
// Use the ThemeContext to apply the theme
|
||||
setTheme(newTheme);
|
||||
|
||||
// Update local state
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
theme: newTheme
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -188,110 +105,276 @@ const Settings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle logout.
|
||||
*/
|
||||
const handleLogout = async () : Promise<void> => {
|
||||
navigate('/logout', { replace: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to autofill settings.
|
||||
*/
|
||||
const navigateToAutofillSettings = () : void => {
|
||||
navigate('/settings/autofill');
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to clipboard settings.
|
||||
*/
|
||||
const navigateToClipboardSettings = () : void => {
|
||||
navigate('/settings/clipboard');
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to language settings.
|
||||
*/
|
||||
const navigateToLanguageSettings = () : void => {
|
||||
navigate('/settings/language');
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to auto-lock settings.
|
||||
*/
|
||||
const navigateToAutoLockSettings = () : void => {
|
||||
navigate('/settings/auto-lock');
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to context menu settings.
|
||||
*/
|
||||
const navigateToContextMenuSettings = () : void => {
|
||||
navigate('/settings/context-menu');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-gray-900 dark:text-white text-xl">Settings</h2>
|
||||
<h2 className="text-gray-900 dark:text-white text-xl">{t('settings.title')}</h2>
|
||||
</div>
|
||||
|
||||
{/* Global Settings Section */}
|
||||
{/* User Menu Section */}
|
||||
<section>
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">Global Settings</h3>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Autofill popup</p>
|
||||
<p className={`text-sm mt-1 ${settings.isGloballyEnabled ? 'text-gray-600 dark:text-gray-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{settings.isGloballyEnabled ? 'Active on all sites (unless disabled below)' : 'Disabled on all sites'}
|
||||
</p>
|
||||
<div className="flex items-center space-x-3">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{authContext.username}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('settings.loggedIn')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleGlobalPopup}
|
||||
className={`px-4 py-2 rounded-md transition-colors ${
|
||||
settings.isGloballyEnabled
|
||||
? 'bg-green-500 hover:bg-green-600 text-white'
|
||||
: 'bg-red-500 hover:bg-red-600 text-white'
|
||||
}`}
|
||||
onClick={handleLogout}
|
||||
title={t('settings.logout')}
|
||||
className="p-2 bg-red-100 hover:bg-red-200 dark:bg-red-900/30 dark:hover:bg-red-900/50 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 rounded-md transition-colors"
|
||||
>
|
||||
{settings.isGloballyEnabled ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Right-click context menu</p>
|
||||
<p className={`text-sm mt-1 ${settings.isContextMenuEnabled ? 'text-gray-600 dark:text-gray-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{settings.isContextMenuEnabled ? 'Enabled' : 'Disabled'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleContextMenu}
|
||||
className={`px-4 py-2 rounded-md transition-colors ${
|
||||
settings.isContextMenuEnabled
|
||||
? 'bg-green-500 hover:bg-green-600 text-white'
|
||||
: 'bg-red-500 hover:bg-red-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{settings.isContextMenuEnabled ? 'Enabled' : 'Disabled'}
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-label={t('settings.logout')}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Site-Specific Settings Section */}
|
||||
{settings.isGloballyEnabled && (
|
||||
<section>
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">Site-Specific Settings</h3>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Autofill popup on: {settings.currentUrl}</p>
|
||||
<p className={`text-sm mt-1 ${settings.isEnabled ? 'text-gray-600 dark:text-gray-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{settings.isEnabled ? 'Enabled for this site' : 'Disabled for this site'}
|
||||
</p>
|
||||
{!settings.isEnabled && settings.temporaryDisabledUrls[settings.currentUrl] && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Temporarily disabled until {new Date(settings.temporaryDisabledUrls[settings.currentUrl]).toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{settings.isGloballyEnabled && (
|
||||
<button
|
||||
onClick={toggleCurrentSite}
|
||||
className={`px-4 py-2 rounded-md transition-colors ${
|
||||
settings.isEnabled
|
||||
? 'bg-green-500 hover:bg-green-600 text-white'
|
||||
: 'bg-red-500 hover:bg-red-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{settings.isEnabled ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={resetSettings}
|
||||
className="w-full px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-md text-gray-700 dark:text-gray-300 transition-colors text-sm"
|
||||
{/* Settings Navigation Section */}
|
||||
<section>
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.preferences')}</h3>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{/* Autofill Settings */}
|
||||
<button
|
||||
onClick={navigateToAutofillSettings}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
Reset all site-specific settings
|
||||
</button>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-900 dark:text-white">{t('settings.autofillSettings')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Context Menu Settings */}
|
||||
<button
|
||||
onClick={navigateToContextMenuSettings}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 6h16M4 12h16m-7 6h7"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-900 dark:text-white">{t('settings.contextMenuSettings')}</span>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Auto-lock Settings */}
|
||||
<button
|
||||
onClick={navigateToAutoLockSettings}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-900 dark:text-white">{t('settings.autoLockTimeout')}</span>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Clipboard Settings */}
|
||||
<button
|
||||
onClick={navigateToClipboardSettings}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-900 dark:text-white">{t('settings.clipboardSettings')}</span>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Language Settings */}
|
||||
<button
|
||||
onClick={navigateToLanguageSettings}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="w-5 h-5 mr-3 text-gray-600 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-900 dark:text-white">{t('settings.language')}</span>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Appearance Settings Section */}
|
||||
<section>
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">Appearance</h3>
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.appearance')}</h3>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white mb-2">Theme</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white mb-2">{t('settings.theme')}</p>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
@@ -302,7 +385,7 @@ const Settings: React.FC = () => {
|
||||
onChange={() => setThemePreference('system')}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Use default</span>
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">{t('settings.useDefault')}</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
@@ -313,7 +396,7 @@ const Settings: React.FC = () => {
|
||||
onChange={() => setThemePreference('light')}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Light</span>
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">{t('settings.light')}</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
@@ -324,7 +407,7 @@ const Settings: React.FC = () => {
|
||||
onChange={() => setThemePreference('dark')}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Dark</span>
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">{t('settings.dark')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,18 +418,18 @@ const Settings: React.FC = () => {
|
||||
{/* Keyboard Shortcuts Section */}
|
||||
{import.meta.env.CHROME && (
|
||||
<section>
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">Keyboard Shortcuts</h3>
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.keyboardShortcuts')}</h3>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Configure keyboard shortcuts</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('settings.configureKeyboardShortcuts')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openKeyboardShortcuts}
|
||||
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-md transition-colors"
|
||||
>
|
||||
Configure
|
||||
{t('settings.configure')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,7 +438,7 @@ const Settings: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div className="text-center text-gray-400 dark:text-gray-600">
|
||||
Version: {AppInfo.VERSION}
|
||||
{t('settings.versionPrefix')}{AppInfo.VERSION} ({getDisplayUrl()})
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,31 +1,42 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Buffer } from 'buffer';
|
||||
import { storage } from '#imports';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import Button from '@/entrypoints/popup/components/Button';
|
||||
import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
import SrpUtility from '@/entrypoints/popup/utils/SrpUtility';
|
||||
import { VaultResponse } from '@/utils/types/webapi/VaultResponse';
|
||||
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
|
||||
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
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 { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
|
||||
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 { storage } from '#imports';
|
||||
|
||||
/**
|
||||
* Unlock page
|
||||
*/
|
||||
const Unlock: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
const navigate = useNavigate();
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
|
||||
const webApi = useWebApi();
|
||||
const srpUtil = new SrpUtility(webApi);
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { showLoading, hideLoading } = useLoading();
|
||||
const { showLoading, hideLoading, setIsInitialLoading } = useLoading();
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
@@ -35,12 +46,31 @@ const Unlock: React.FC = () => {
|
||||
const statusResponse = await webApi.getStatus();
|
||||
const statusError = webApi.validateStatusResponse(statusResponse);
|
||||
if (statusError !== null) {
|
||||
await webApi.logout(statusError);
|
||||
await webApi.logout(t('common.errors.' + statusError));
|
||||
navigate('/logout');
|
||||
}
|
||||
setIsInitialLoading(false);
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
}, [webApi, authContext]);
|
||||
}, [webApi, authContext, setIsInitialLoading, navigate, t]);
|
||||
|
||||
// Set header buttons on mount and clear on unmount
|
||||
useEffect((): (() => void) => {
|
||||
const headerButtonsJSX = !PopoutUtility.isPopup() ? (
|
||||
<HeaderButton
|
||||
onClick={() => PopoutUtility.openInNewPopup()}
|
||||
title={t('common.openInNewWindow')}
|
||||
iconType={HeaderIconType.EXPAND}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
setHeaderButtons(headerButtonsJSX);
|
||||
|
||||
return () => {
|
||||
setHeaderButtons(null);
|
||||
};
|
||||
}, [setHeaderButtons, t]);
|
||||
|
||||
/**
|
||||
* Handle submit
|
||||
@@ -65,9 +95,9 @@ const Unlock: React.FC = () => {
|
||||
// Make API call to get latest vault
|
||||
const vaultResponseJson = await webApi.get<VaultResponse>('Vault');
|
||||
|
||||
const vaultError = webApi.validateVaultResponse(vaultResponseJson);
|
||||
const vaultError = webApi.validateVaultResponse(vaultResponseJson, t);
|
||||
if (vaultError) {
|
||||
setError(vaultError);
|
||||
setError(t('common.apiErrors.' + vaultError));
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
@@ -75,13 +105,19 @@ const Unlock: React.FC = () => {
|
||||
// Get the derived key as base64 string required for decryption.
|
||||
const passwordHashBase64 = Buffer.from(passwordHash).toString('base64');
|
||||
|
||||
// Store the encryption key in session storage.
|
||||
await dbContext.storeEncryptionKey(passwordHashBase64);
|
||||
|
||||
// Initialize the SQLite context with the new vault data.
|
||||
await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
|
||||
|
||||
// Clear dismiss until (which can be enabled after user has dimissed vault is locked popup) to ensure popup is shown.
|
||||
await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, 0);
|
||||
|
||||
// Redirect to reinitialize page
|
||||
navigate('/reinitialize', { replace: true });
|
||||
} catch (err) {
|
||||
setError('Failed to unlock vault. Please check your password and try again.');
|
||||
setError(t('auth.errors.wrongPassword'));
|
||||
console.error('Unlock error:', err);
|
||||
} finally {
|
||||
hideLoading();
|
||||
@@ -96,13 +132,31 @@ const Unlock: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-700 w-full shadow-md rounded px-8 pt-6 pb-8 mb-4">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white break-all overflow-hidden mb-4">{authContext.username}</h2>
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{authContext.username}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('auth.loggedIn')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-base text-gray-500 dark:text-gray-200 mb-6">
|
||||
Enter your master password to unlock your vault.
|
||||
</p>
|
||||
{/* Instruction Title */}
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('auth.unlockTitle')}
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 text-red-500 dark:text-red-400 text-sm">
|
||||
@@ -110,9 +164,9 @@ const Unlock: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="mb-2">
|
||||
<label className="block text-gray-700 dark:text-gray-200 text-sm font-bold mb-2" htmlFor="password">
|
||||
Password
|
||||
{t('auth.masterPassword')}
|
||||
</label>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-gray-800 dark:border-gray-600 mb-3 leading-tight focus:outline-none focus:shadow-outline"
|
||||
@@ -120,17 +174,18 @@ const Unlock: React.FC = () => {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit">
|
||||
Unlock
|
||||
{t('auth.unlockVault')}
|
||||
</Button>
|
||||
|
||||
<div className="text-sm font-medium text-gray-500 dark:text-gray-200 mt-6">
|
||||
Switch accounts? <button onClick={handleLogout} className="text-primary-700 hover:underline dark:text-primary-500">Log out</button>
|
||||
{t('auth.switchAccounts')} <button onClick={handleLogout} className="text-primary-700 hover:underline dark:text-primary-500">{t('auth.logout')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,45 +1,57 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Unlock success component shown when the vault is successfully unlocked in a separate popup
|
||||
* asking the user if they want to close the popup.
|
||||
*/
|
||||
const UnlockSuccess: React.FC<{
|
||||
onClose: () => void;
|
||||
}> = ({ onClose }) => (
|
||||
<div className="flex flex-col items-center justify-center p-6 text-center">
|
||||
<div className="mb-4 text-green-600 dark:text-green-400">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
const UnlockSuccess: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* Handle browsing vault contents - navigate to credentials page and reset mode parameter
|
||||
*/
|
||||
const handleBrowseVaultContents = (): void => {
|
||||
// Remove mode=inline from URL before navigating
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('mode');
|
||||
window.history.replaceState({}, '', url);
|
||||
|
||||
// Navigate to credentials page
|
||||
navigate('/credentials');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-6 text-center">
|
||||
<div className="mb-4 text-green-600 dark:text-green-400">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
{t('auth.unlockSuccessTitle')}
|
||||
</h2>
|
||||
<p className="mb-6 text-gray-600 dark:text-gray-400">
|
||||
{t('auth.unlockSuccessDescription')}
|
||||
</p>
|
||||
<div className="space-y-3 w-full">
|
||||
<button
|
||||
onClick={() => window.close()}
|
||||
className="w-full px-4 py-2 text-white bg-primary-600 rounded hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
>
|
||||
{t('auth.closePopup')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBrowseVaultContents}
|
||||
className="w-full px-4 py-2 text-gray-700 bg-gray-100 rounded hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
{t('auth.browseVault')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
Your vault is successfully unlocked
|
||||
</h2>
|
||||
<p className="mb-6 text-gray-600 dark:text-gray-400">
|
||||
You can now use autofill in login forms in your browser.
|
||||
</p>
|
||||
<div className="space-y-3 w-full">
|
||||
<button
|
||||
onClick={() => window.close()}
|
||||
className="w-full px-4 py-2 text-white bg-primary-600 rounded hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
>
|
||||
Close this popup
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Remove mode=inline from URL before closing
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('mode');
|
||||
window.history.replaceState({}, '', url);
|
||||
onClose();
|
||||
}}
|
||||
className="w-full px-4 py-2 text-gray-700 bg-gray-100 rounded hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
Browse vault contents
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default UnlockSuccess;
|
||||
|
||||
333
apps/browser-extension/src/entrypoints/popup/pages/Upgrade.tsx
Normal file
333
apps/browser-extension/src/entrypoints/popup/pages/Upgrade.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import Button from '@/entrypoints/popup/components/Button';
|
||||
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 { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
|
||||
import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
|
||||
import { useVaultSync } from '@/entrypoints/popup/hooks/useVaultSync';
|
||||
import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility';
|
||||
|
||||
import type { VaultVersion } from '@/utils/dist/shared/vault-sql';
|
||||
import { VaultSqlGenerator } from '@/utils/dist/shared/vault-sql';
|
||||
|
||||
/**
|
||||
* Upgrade page for handling vault version upgrades.
|
||||
*/
|
||||
const Upgrade: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { username } = useAuth();
|
||||
const dbContext = useDb();
|
||||
const { sqliteClient } = dbContext;
|
||||
const { setHeaderButtons } = useHeaderButtons();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentVersion, setCurrentVersion] = useState<VaultVersion | null>(null);
|
||||
const [latestVersion, setLatestVersion] = useState<VaultVersion | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSelfHostedWarning, setShowSelfHostedWarning] = useState(false);
|
||||
const [showVersionInfo, setShowVersionInfo] = useState(false);
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const webApi = useWebApi();
|
||||
const { executeVaultMutation, isLoading: isVaultMutationLoading, syncStatus } = useVaultMutate();
|
||||
const { syncVault } = useVaultSync();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Set header buttons on mount and clear on unmount
|
||||
useEffect((): (() => void) => {
|
||||
const headerButtonsJSX = !PopoutUtility.isPopup() ? (
|
||||
<>
|
||||
<HeaderButton
|
||||
onClick={() => PopoutUtility.openInNewPopup()}
|
||||
title={t('common.openInNewWindow')}
|
||||
iconType={HeaderIconType.EXPAND}
|
||||
/>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
setHeaderButtons(headerButtonsJSX);
|
||||
|
||||
return () => {
|
||||
setHeaderButtons(null);
|
||||
};
|
||||
}, [setHeaderButtons, t]);
|
||||
|
||||
/**
|
||||
* Load version information from the database.
|
||||
*/
|
||||
const loadVersionInfo = useCallback(async () => {
|
||||
try {
|
||||
if (sqliteClient) {
|
||||
const current = sqliteClient.getDatabaseVersion();
|
||||
const latest = await sqliteClient.getLatestDatabaseVersion();
|
||||
setCurrentVersion(current);
|
||||
setLatestVersion(latest);
|
||||
}
|
||||
setIsInitialLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load version information:', error);
|
||||
setError(t('upgrade.alerts.unableToGetVersionInfo'));
|
||||
}
|
||||
}, [sqliteClient, setIsInitialLoading, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadVersionInfo();
|
||||
}, [loadVersionInfo]);
|
||||
|
||||
/**
|
||||
* Handle the vault upgrade.
|
||||
*/
|
||||
const handleUpgrade = async (): Promise<void> => {
|
||||
if (!sqliteClient || !currentVersion || !latestVersion) {
|
||||
setError(t('upgrade.alerts.unableToGetVersionInfo'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a self-hosted instance and show warning if needed
|
||||
if (await webApi.isSelfHosted()) {
|
||||
setShowSelfHostedWarning(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await performUpgrade();
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform the actual vault upgrade.
|
||||
*/
|
||||
const performUpgrade = async (): Promise<void> => {
|
||||
if (!sqliteClient || !currentVersion || !latestVersion) {
|
||||
setError(t('upgrade.alerts.unableToGetVersionInfo'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get upgrade SQL commands from vault-sql shared library
|
||||
const vaultSqlGenerator = new VaultSqlGenerator();
|
||||
const upgradeResult = vaultSqlGenerator.getUpgradeVaultSql(currentVersion.revision, latestVersion.revision);
|
||||
|
||||
if (!upgradeResult.success) {
|
||||
throw new Error(upgradeResult.error ?? t('upgrade.alerts.upgradeFailed'));
|
||||
}
|
||||
|
||||
if (upgradeResult.sqlCommands.length === 0) {
|
||||
// No upgrade needed, vault is already up to date
|
||||
await handleUpgradeSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the useVaultMutate hook to handle the upgrade and vault upload
|
||||
await executeVaultMutation(async () => {
|
||||
// Begin transaction
|
||||
sqliteClient.beginTransaction();
|
||||
|
||||
// Execute each SQL command
|
||||
for (let i = 0; i < upgradeResult.sqlCommands.length; i++) {
|
||||
const sqlCommand = upgradeResult.sqlCommands[i];
|
||||
|
||||
try {
|
||||
sqliteClient.executeRaw(sqlCommand);
|
||||
} catch (error) {
|
||||
console.error(`Error executing SQL command ${i + 1}:`, sqlCommand, error);
|
||||
sqliteClient.rollbackTransaction();
|
||||
throw new Error(t('upgrade.alerts.failedToApplyMigration', { current: i + 1, total: upgradeResult.sqlCommands.length }));
|
||||
}
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
sqliteClient.commitTransaction();
|
||||
}, {
|
||||
skipSyncCheck: true, // Skip sync check during upgrade to prevent loop
|
||||
/**
|
||||
* Handle successful upgrade completion.
|
||||
*/
|
||||
onSuccess: () => {
|
||||
void handleUpgradeSuccess();
|
||||
},
|
||||
/**
|
||||
* Handle upgrade error.
|
||||
*/
|
||||
onError: (error: Error) => {
|
||||
console.error('Upgrade failed:', error);
|
||||
setError(error.message);
|
||||
}
|
||||
});
|
||||
console.debug('executeVaultMutation done?');
|
||||
} catch (error) {
|
||||
console.error('Upgrade failed:', error);
|
||||
setError(error instanceof Error ? error.message : t('upgrade.alerts.unknownErrorDuringUpgrade'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle successful upgrade completion.
|
||||
*/
|
||||
const handleUpgradeSuccess = async (): Promise<void> => {
|
||||
try {
|
||||
// Sync vault to ensure we have the latest data
|
||||
await syncVault({
|
||||
/**
|
||||
* Handle successful sync completion.
|
||||
*/
|
||||
onSuccess: () => {
|
||||
// Navigate to credentials page
|
||||
navigate('/credentials');
|
||||
},
|
||||
/**
|
||||
* Handle sync error.
|
||||
* @param error Error message
|
||||
*/
|
||||
onError: (error: string) => {
|
||||
console.error('Sync error after upgrade:', error);
|
||||
// Still navigate to credentials even if sync fails
|
||||
navigate('/credentials');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error during post-upgrade sync:', error);
|
||||
// Navigate to credentials even if sync fails
|
||||
navigate('/credentials');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle the logout.
|
||||
*/
|
||||
const handleLogout = async (): Promise<void> => {
|
||||
navigate('/logout');
|
||||
};
|
||||
|
||||
/**
|
||||
* Show version description dialog.
|
||||
*/
|
||||
const showVersionDialog = (): void => {
|
||||
setShowVersionInfo(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Full loading screen overlay */}
|
||||
{(isLoading || isVaultMutationLoading) && (
|
||||
<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 || t('upgrade.upgrading')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Self-hosted warning modal */}
|
||||
<Modal
|
||||
isOpen={showSelfHostedWarning}
|
||||
onClose={() => setShowSelfHostedWarning(false)}
|
||||
onConfirm={() => {
|
||||
setShowSelfHostedWarning(false);
|
||||
void performUpgrade();
|
||||
}}
|
||||
title={t('upgrade.alerts.selfHostedServer')}
|
||||
message={t('upgrade.alerts.selfHostedWarning')}
|
||||
confirmText={t('upgrade.alerts.continueUpgrade')}
|
||||
cancelText={t('upgrade.alerts.cancel')}
|
||||
/>
|
||||
|
||||
{/* Version info modal */}
|
||||
<Modal
|
||||
isOpen={showVersionInfo}
|
||||
onClose={() => setShowVersionInfo(false)}
|
||||
onConfirm={() => setShowVersionInfo(false)}
|
||||
title={t('upgrade.whatsNew')}
|
||||
message={`${t('upgrade.whatsNewDescription')}\n\n${latestVersion?.description ?? t('upgrade.noDescriptionAvailable')}`}
|
||||
/>
|
||||
|
||||
<form className="w-full px-2 pt-2 pb-2 mb-4">
|
||||
{error && (
|
||||
<div className="mb-4 text-red-500 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User display section like settings page */}
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<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">
|
||||
{username?.[0]?.toUpperCase() || '?'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-bold dark:text-gray-200 mb-4">{t('upgrade.title')}</h2>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700 dark:text-gray-200 text-sm mb-4">
|
||||
{t('upgrade.subtitle')}
|
||||
</p>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">{t('upgrade.versionInformation')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={showVersionDialog}
|
||||
className="bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200 rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold hover:bg-gray-300 dark:hover:bg-gray-500"
|
||||
title={t('upgrade.whatsNew')}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{t('upgrade.yourVault')}</span>
|
||||
<span className="text-sm font-bold text-orange-600 dark:text-orange-400">
|
||||
{currentVersion?.releaseVersion ?? '...'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{t('upgrade.newVersion')}</span>
|
||||
<span className="text-sm font-bold text-green-600 dark:text-green-400">
|
||||
{latestVersion?.releaseVersion ?? '...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-full space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleUpgrade}
|
||||
>
|
||||
{isLoading || isVaultMutationLoading ? (syncStatus || t('upgrade.upgrading')) : t('upgrade.upgrade')}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm font-medium py-2"
|
||||
disabled={isLoading || isVaultMutationLoading}
|
||||
>
|
||||
{t('upgrade.logout')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Upgrade;
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import HelpModal from '@/entrypoints/popup/components/HelpModal';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
|
||||
import { AUTO_LOCK_TIMEOUT_KEY } from '@/utils/Constants';
|
||||
|
||||
import { storage } from "#imports";
|
||||
|
||||
/**
|
||||
* Auto-lock settings page component.
|
||||
*/
|
||||
const AutoLockSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const [autoLockTimeout, setAutoLockTimeout] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Load auto-lock settings.
|
||||
*/
|
||||
const loadSettings = async () : Promise<void> => {
|
||||
// Load auto-lock timeout
|
||||
const autoLockTimeoutValue = await storage.getItem(AUTO_LOCK_TIMEOUT_KEY) as number ?? 0;
|
||||
setAutoLockTimeout(autoLockTimeoutValue);
|
||||
setIsInitialLoading(false);
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, [setIsInitialLoading]);
|
||||
|
||||
/**
|
||||
* Set auto-lock timeout.
|
||||
*/
|
||||
const setAutoLockTimeoutSetting = async (timeout: number) : Promise<void> => {
|
||||
await storage.setItem(AUTO_LOCK_TIMEOUT_KEY, timeout);
|
||||
await sendMessage('SET_AUTO_LOCK_TIMEOUT', timeout, 'background');
|
||||
setAutoLockTimeout(timeout);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.autoLockTimeout')}</h3>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4">
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('settings.autoLockTimeout')}</p>
|
||||
<HelpModal
|
||||
titleKey="settings.autoLockTimeout"
|
||||
contentKey="settings.autoLockTimeoutHelp"
|
||||
className="ml-2"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mb-3">{t('settings.autoLockTimeoutDescription')}</p>
|
||||
<select
|
||||
value={autoLockTimeout}
|
||||
onChange={(e) => setAutoLockTimeoutSetting(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="0">{t('settings.autoLockNever')}</option>
|
||||
<option value="15">{t('settings.autoLock15Seconds')}</option>
|
||||
<option value="60">{t('settings.autoLock1Minute')}</option>
|
||||
<option value="300">{t('settings.autoLock5Minutes')}</option>
|
||||
<option value="900">{t('settings.autoLock15Minutes')}</option>
|
||||
<option value="1800">{t('settings.autoLock30Minutes')}</option>
|
||||
<option value="3600">{t('settings.autoLock1Hour')}</option>
|
||||
<option value="14400">{t('settings.autoLock4Hours')}</option>
|
||||
<option value="28800">{t('settings.autoLock8Hours')}</option>
|
||||
<option value="86400">{t('settings.autoLock24Hours')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoLockSettings;
|
||||
@@ -0,0 +1,260 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AutofillMatchingMode } from '@/entrypoints/contentScript/Filter';
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
|
||||
import {
|
||||
DISABLED_SITES_KEY,
|
||||
GLOBAL_AUTOFILL_POPUP_ENABLED_KEY,
|
||||
TEMPORARY_DISABLED_SITES_KEY,
|
||||
AUTOFILL_MATCHING_MODE_KEY
|
||||
} from '@/utils/Constants';
|
||||
|
||||
import { storage, browser } from "#imports";
|
||||
|
||||
/**
|
||||
* Autofill settings type.
|
||||
*/
|
||||
type AutofillSettingsType = {
|
||||
disabledUrls: string[];
|
||||
temporaryDisabledUrls: Record<string, number>;
|
||||
currentUrl: string;
|
||||
isEnabled: boolean;
|
||||
isGloballyEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autofill settings page component.
|
||||
*/
|
||||
const AutofillSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const [settings, setSettings] = useState<AutofillSettingsType>({
|
||||
disabledUrls: [],
|
||||
temporaryDisabledUrls: {},
|
||||
currentUrl: '',
|
||||
isEnabled: true,
|
||||
isGloballyEnabled: true
|
||||
});
|
||||
const [autofillMatchingMode, setAutofillMatchingMode] = useState<AutofillMatchingMode>(AutofillMatchingMode.DEFAULT);
|
||||
|
||||
/**
|
||||
* Get current tab in browser.
|
||||
*/
|
||||
const getCurrentTab = async () : Promise<chrome.tabs.Tab> => {
|
||||
const queryOptions = { active: true, currentWindow: true };
|
||||
const [tab] = await browser.tabs.query(queryOptions);
|
||||
return tab;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load settings.
|
||||
*/
|
||||
const loadSettings = useCallback(async () : Promise<void> => {
|
||||
const tab = await getCurrentTab();
|
||||
const currentUrl = new URL(tab.url ?? '').hostname;
|
||||
|
||||
// Load settings local storage.
|
||||
const disabledUrls = await storage.getItem(DISABLED_SITES_KEY) as string[] ?? [];
|
||||
const temporaryDisabledUrls = await storage.getItem(TEMPORARY_DISABLED_SITES_KEY) as Record<string, number> ?? {};
|
||||
const isGloballyEnabled = await storage.getItem(GLOBAL_AUTOFILL_POPUP_ENABLED_KEY) !== false; // Default to true if not set
|
||||
|
||||
// Clean up expired temporary disables
|
||||
const now = Date.now();
|
||||
const cleanedTemporaryDisabledUrls = Object.fromEntries(
|
||||
Object.entries(temporaryDisabledUrls).filter(([_, expiry]) => expiry > now)
|
||||
);
|
||||
|
||||
if (Object.keys(cleanedTemporaryDisabledUrls).length !== Object.keys(temporaryDisabledUrls).length) {
|
||||
await storage.setItem(TEMPORARY_DISABLED_SITES_KEY, cleanedTemporaryDisabledUrls);
|
||||
}
|
||||
|
||||
// Load autofill matching mode
|
||||
const matchingModeValue = await storage.getItem(AUTOFILL_MATCHING_MODE_KEY) as AutofillMatchingMode ?? AutofillMatchingMode.DEFAULT;
|
||||
setAutofillMatchingMode(matchingModeValue);
|
||||
|
||||
setSettings({
|
||||
disabledUrls,
|
||||
temporaryDisabledUrls: cleanedTemporaryDisabledUrls,
|
||||
currentUrl,
|
||||
isEnabled: !disabledUrls.includes(currentUrl) && !(currentUrl in cleanedTemporaryDisabledUrls),
|
||||
isGloballyEnabled
|
||||
});
|
||||
setIsInitialLoading(false);
|
||||
}, [setIsInitialLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
/**
|
||||
* Toggle current site.
|
||||
*/
|
||||
const toggleCurrentSite = async () : Promise<void> => {
|
||||
const { currentUrl, disabledUrls, temporaryDisabledUrls, isEnabled } = settings;
|
||||
|
||||
let newDisabledUrls = [...disabledUrls];
|
||||
let newTemporaryDisabledUrls = { ...temporaryDisabledUrls };
|
||||
|
||||
if (isEnabled) {
|
||||
// When disabling, add to permanent disabled list
|
||||
if (!newDisabledUrls.includes(currentUrl)) {
|
||||
newDisabledUrls.push(currentUrl);
|
||||
}
|
||||
// Also remove from temporary disabled list if present
|
||||
delete newTemporaryDisabledUrls[currentUrl];
|
||||
} else {
|
||||
// When enabling, remove from both permanent and temporary disabled lists
|
||||
newDisabledUrls = newDisabledUrls.filter(url => url !== currentUrl);
|
||||
delete newTemporaryDisabledUrls[currentUrl];
|
||||
}
|
||||
|
||||
await storage.setItem(DISABLED_SITES_KEY, newDisabledUrls);
|
||||
await storage.setItem(TEMPORARY_DISABLED_SITES_KEY, newTemporaryDisabledUrls);
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
disabledUrls: newDisabledUrls,
|
||||
temporaryDisabledUrls: newTemporaryDisabledUrls,
|
||||
isEnabled: !isEnabled
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset settings.
|
||||
*/
|
||||
const resetSettings = async () : Promise<void> => {
|
||||
await storage.setItem(DISABLED_SITES_KEY, []);
|
||||
await storage.setItem(TEMPORARY_DISABLED_SITES_KEY, {});
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
disabledUrls: [],
|
||||
temporaryDisabledUrls: {},
|
||||
isEnabled: true
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle global popup.
|
||||
*/
|
||||
const toggleGlobalPopup = async () : Promise<void> => {
|
||||
const newGloballyEnabled = !settings.isGloballyEnabled;
|
||||
|
||||
await storage.setItem(GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, newGloballyEnabled);
|
||||
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
isGloballyEnabled: newGloballyEnabled
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Set autofill matching mode.
|
||||
*/
|
||||
const setAutofillMatchingModeSetting = async (mode: AutofillMatchingMode) : Promise<void> => {
|
||||
await storage.setItem(AUTOFILL_MATCHING_MODE_KEY, mode);
|
||||
setAutofillMatchingMode(mode);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Global Settings Section */}
|
||||
<section>
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.globalSettings')}</h3>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('settings.autofillPopup')}</p>
|
||||
<p className={`text-xs mt-1 ${settings.isGloballyEnabled ? 'text-gray-600 dark:text-gray-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{settings.isGloballyEnabled ? t('settings.activeOnAllSites') : t('settings.disabledOnAllSites')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleGlobalPopup}
|
||||
className={`px-4 py-2 rounded-md transition-colors ${
|
||||
settings.isGloballyEnabled
|
||||
? 'bg-green-500 hover:bg-green-600 text-white'
|
||||
: 'bg-red-500 hover:bg-red-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{settings.isGloballyEnabled ? t('settings.enabled') : t('settings.disabled')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Site-Specific Settings Section */}
|
||||
{settings.isGloballyEnabled && (
|
||||
<section>
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.siteSpecificSettings')}</h3>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('settings.autofillPopupOn')}{settings.currentUrl}</p>
|
||||
<p className={`text-xs mt-1 ${settings.isEnabled ? 'text-gray-600 dark:text-gray-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{settings.isEnabled ? t('settings.enabledForThisSite') : t('settings.disabledForThisSite')}
|
||||
</p>
|
||||
{!settings.isEnabled && settings.temporaryDisabledUrls[settings.currentUrl] && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('settings.temporarilyDisabledUntil')}{new Date(settings.temporaryDisabledUrls[settings.currentUrl]).toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{settings.isGloballyEnabled && (
|
||||
<button
|
||||
onClick={toggleCurrentSite}
|
||||
className={`px-4 py-2 ml-1 rounded-md transition-colors ${
|
||||
settings.isEnabled
|
||||
? 'bg-green-500 hover:bg-green-600 text-white'
|
||||
: 'bg-red-500 hover:bg-red-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{settings.isEnabled ? t('settings.enabled') : t('settings.disabled')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={resetSettings}
|
||||
className="w-full px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-md text-gray-700 dark:text-gray-300 transition-colors text-sm"
|
||||
>
|
||||
{t('settings.resetAllSiteSettings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Autofill Matching Settings Section */}
|
||||
<section>
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.autofillMatching')}</h3>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white mb-2">{t('settings.autofillMatchingMode')}</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mb-3">{t('settings.autofillMatchingModeDescription')}</p>
|
||||
<select
|
||||
value={autofillMatchingMode}
|
||||
onChange={(e) => setAutofillMatchingModeSetting(e.target.value as AutofillMatchingMode)}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value={AutofillMatchingMode.DEFAULT}>{t('settings.autofillMatchingDefault')}</option>
|
||||
<option value={AutofillMatchingMode.URL_SUBDOMAIN}>{t('settings.autofillMatchingUrlSubdomain')}</option>
|
||||
<option value={AutofillMatchingMode.URL_EXACT}>{t('settings.autofillMatchingUrlExact')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutofillSettings;
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
|
||||
import { CLIPBOARD_CLEAR_TIMEOUT_KEY } from '@/utils/Constants';
|
||||
|
||||
import { storage } from "#imports";
|
||||
|
||||
/**
|
||||
* Clipboard settings page component.
|
||||
*/
|
||||
const ClipboardSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const [clipboardTimeout, setClipboardTimeout] = useState<number>(10);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Load clipboard settings.
|
||||
*/
|
||||
const loadSettings = async () : Promise<void> => {
|
||||
// Load clipboard clear timeout
|
||||
const timeout = await storage.getItem(CLIPBOARD_CLEAR_TIMEOUT_KEY) as number ?? 10;
|
||||
setClipboardTimeout(timeout);
|
||||
setIsInitialLoading(false);
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, [setIsInitialLoading]);
|
||||
|
||||
/**
|
||||
* Set clipboard clear timeout.
|
||||
*/
|
||||
const setClipboardClearTimeout = async (timeout: number) : Promise<void> => {
|
||||
await storage.setItem(CLIPBOARD_CLEAR_TIMEOUT_KEY, timeout);
|
||||
await sendMessage('SET_CLIPBOARD_CLEAR_TIMEOUT', timeout, 'background');
|
||||
setClipboardTimeout(timeout);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.clipboardSettings')}</h3>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white mb-2">{t('settings.clipboardClearTimeout')}</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mb-3">{t('settings.clipboardClearTimeoutDescription')}</p>
|
||||
<select
|
||||
value={clipboardTimeout}
|
||||
onChange={(e) => setClipboardClearTimeout(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="0">{t('settings.clipboardClearDisabled')}</option>
|
||||
<option value="5">{t('settings.clipboardClear5Seconds')}</option>
|
||||
<option value="10">{t('settings.clipboardClear10Seconds')}</option>
|
||||
<option value="15">{t('settings.clipboardClear15Seconds')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClipboardSettings;
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
|
||||
|
||||
import { GLOBAL_CONTEXT_MENU_ENABLED_KEY } from '@/utils/Constants';
|
||||
|
||||
import { storage } from "#imports";
|
||||
|
||||
/**
|
||||
* Context menu settings page component.
|
||||
*/
|
||||
const ContextMenuSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setIsInitialLoading } = useLoading();
|
||||
const [isContextMenuEnabled, setIsContextMenuEnabled] = useState<boolean>(true);
|
||||
|
||||
/**
|
||||
* Load settings.
|
||||
*/
|
||||
const loadSettings = useCallback(async () : Promise<void> => {
|
||||
const isEnabled = await storage.getItem(GLOBAL_CONTEXT_MENU_ENABLED_KEY) !== false; // Default to true if not set
|
||||
setIsContextMenuEnabled(isEnabled);
|
||||
setIsInitialLoading(false);
|
||||
}, [setIsInitialLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
/**
|
||||
* Toggle context menu.
|
||||
*/
|
||||
const toggleContextMenu = async () : Promise<void> => {
|
||||
const newContextMenuEnabled = !isContextMenuEnabled;
|
||||
|
||||
await storage.setItem(GLOBAL_CONTEXT_MENU_ENABLED_KEY, newContextMenuEnabled);
|
||||
await sendMessage('TOGGLE_CONTEXT_MENU', { enabled: newContextMenuEnabled }, 'background');
|
||||
|
||||
setIsContextMenuEnabled(newContextMenuEnabled);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.contextMenu')}</h3>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{t('settings.rightClickContextMenu')}</p>
|
||||
<p className={`text-xs mt-1 ${isContextMenuEnabled ? 'text-gray-600 dark:text-gray-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{isContextMenuEnabled ? t('settings.contextMenuEnabled') : t('settings.contextMenuDisabled')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
{t('settings.contextMenuDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleContextMenu}
|
||||
className={`px-4 py-2 rounded-md transition-colors ${
|
||||
isContextMenuEnabled
|
||||
? 'bg-green-500 hover:bg-green-600 text-white'
|
||||
: 'bg-red-500 hover:bg-red-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{isContextMenuEnabled ? t('settings.enabled') : t('settings.disabled')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextMenuSettings;
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import LanguageSwitcher from '@/entrypoints/popup/components/LanguageSwitcher';
|
||||
|
||||
/**
|
||||
* Language settings page component.
|
||||
*/
|
||||
const LanguageSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-3">{t('settings.language')}</h3>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white mb-3">{t('settings.selectLanguage')}</p>
|
||||
<LanguageSwitcher variant="dropdown" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSettings;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { AppInfo } from '@/utils/AppInfo';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
/**
|
||||
* Hook to manage API URL state and display logic.
|
||||
* @returns Object containing apiUrl state and utility functions
|
||||
*/
|
||||
export const useApiUrl = (): {
|
||||
apiUrl: string;
|
||||
setApiUrl: (url: string) => void;
|
||||
loadApiUrl: () => Promise<void>;
|
||||
getDisplayUrl: () => string;
|
||||
} => {
|
||||
const [apiUrl, setApiUrl] = useState<string>(AppInfo.DEFAULT_API_URL);
|
||||
|
||||
/**
|
||||
* Load the API URL from storage.
|
||||
*/
|
||||
const loadApiUrl = async (): Promise<void> => {
|
||||
const storedUrl = await storage.getItem('local:apiUrl') as string;
|
||||
if (storedUrl && storedUrl.length > 0) {
|
||||
setApiUrl(storedUrl);
|
||||
} else {
|
||||
setApiUrl(AppInfo.DEFAULT_API_URL);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the display URL for UI presentation.
|
||||
* @returns Formatted display URL
|
||||
*/
|
||||
const getDisplayUrl = (): string => {
|
||||
const cleanUrl = apiUrl.replace('https://', '').replace('http://', '').replace(':443', '').replace('/api', '');
|
||||
return cleanUrl === 'app.aliasvault.net' ? 'aliasvault.net' : cleanUrl;
|
||||
};
|
||||
|
||||
return {
|
||||
apiUrl,
|
||||
setApiUrl,
|
||||
loadApiUrl,
|
||||
getDisplayUrl,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Utility class for conversion operations.
|
||||
* TODO: make this a shared utility class in root /shared/ folder so we can reuse it between browser extension/mobile app
|
||||
* and possibly WASM client.
|
||||
*/
|
||||
class ConversionUtility {
|
||||
/**
|
||||
@@ -49,6 +51,15 @@ class ConversionUtility {
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a username by converting it to lowercase and trimming whitespace.
|
||||
* @param username The username to normalize.
|
||||
* @returns The normalized username.
|
||||
*/
|
||||
public normalizeUsername(username: string): string {
|
||||
return username.toLowerCase().trim();
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConversionUtility();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Utility class for handling popup window operations
|
||||
*/
|
||||
export class PopoutUtility {
|
||||
/**
|
||||
* Check if the current page is an expanded popup.
|
||||
* Uses both URL parameter detection and window width as fallback.
|
||||
*/
|
||||
public static isPopup(): boolean {
|
||||
// Primary method: Check URL parameter
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('expanded') === 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback method: Check window width (popout windows are 800px wide)
|
||||
* Regular popup extension windows are typically narrower (around 375-400px)
|
||||
*/
|
||||
return window.innerWidth > 390;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the current page in a new expanded popup window.
|
||||
* @param path - The path to open in the popup (defaults to current path)
|
||||
*/
|
||||
public static openInNewPopup(path?: string): void {
|
||||
const width = 800;
|
||||
const height = 1000;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
const currentPath = path || window.location.hash.replace('#', '');
|
||||
const popupUrl = `popup.html?expanded=true#${currentPath}`;
|
||||
|
||||
window.open(
|
||||
popupUrl,
|
||||
'AliasVaultPopup',
|
||||
`width=${width},height=${height},left=${left},top=${top},popup=true`
|
||||
);
|
||||
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import srp from 'secure-remote-password/client'
|
||||
import { WebApiService } from '@/utils/WebApiService';
|
||||
import { LoginRequest, LoginResponse } from '@/utils/types/webapi/Login';
|
||||
import { ValidateLoginRequest, ValidateLoginRequest2Fa, ValidateLoginResponse } from '@/utils/types/webapi/ValidateLogin';
|
||||
import BadRequestResponse from '@/utils/types/webapi/BadRequestResponse';
|
||||
|
||||
import type { LoginRequest, LoginResponse, ValidateLoginRequest, ValidateLoginRequest2Fa, ValidateLoginResponse, BadRequestResponse } from '@/utils/dist/shared/models/webapi';
|
||||
import { ApiAuthError } from '@/utils/types/errors/ApiAuthError';
|
||||
import { WebApiService } from '@/utils/WebApiService';
|
||||
|
||||
/**
|
||||
* Utility class for SRP authentication operations.
|
||||
|
||||
78
apps/browser-extension/src/i18n/StandaloneI18n.ts
Normal file
78
apps/browser-extension/src/i18n/StandaloneI18n.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Standalone i18n for non-React contexts.
|
||||
* This is used to translate strings in non-React contexts, such as the background and content scripts.
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_LANGUAGE,
|
||||
LANGUAGE_CODES,
|
||||
loadTranslations,
|
||||
getNestedValue
|
||||
} from './config';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
/**
|
||||
* Get current language from storage
|
||||
*/
|
||||
export async function getCurrentLanguage(): Promise<string> {
|
||||
try {
|
||||
// Use extension storage API exclusively (reliable across all contexts)
|
||||
const langFromStorage = await storage.getItem('local:language') as string;
|
||||
if (langFromStorage && LANGUAGE_CODES.includes(langFromStorage)) {
|
||||
return langFromStorage;
|
||||
}
|
||||
|
||||
// If no language is set in storage, detect browser language and save it
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
const detectedLanguage = LANGUAGE_CODES.includes(browserLang) ? browserLang : DEFAULT_LANGUAGE;
|
||||
|
||||
// Save the detected language to storage for future use
|
||||
await storage.setItem('local:language', detectedLanguage);
|
||||
|
||||
return detectedLanguage;
|
||||
} catch (error) {
|
||||
console.error('Failed to get current language:', error);
|
||||
return DEFAULT_LANGUAGE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translation function for non-React contexts
|
||||
*
|
||||
* @param key - Translation key (supports nested keys like 'auth.loginButton' or 'common.errors.networkError')
|
||||
* @param fallback - Fallback text if translation is not found
|
||||
* @returns Promise<string> - Translated text
|
||||
*/
|
||||
export async function t(
|
||||
key: string,
|
||||
fallback?: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const language = await getCurrentLanguage();
|
||||
const translations = await loadTranslations(language);
|
||||
|
||||
// Support nested keys like 'auth.loginButton' or 'common.errors.networkError'
|
||||
const value = getNestedValue(translations, key);
|
||||
|
||||
if (value && typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// If translation not found and we're not using English, try English fallback
|
||||
if (language !== DEFAULT_LANGUAGE) {
|
||||
const englishTranslations = await loadTranslations(DEFAULT_LANGUAGE);
|
||||
const englishValue = getNestedValue(englishTranslations, key);
|
||||
|
||||
if (englishValue && typeof englishValue === 'string') {
|
||||
return englishValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Return fallback or key if no translation found
|
||||
return fallback || key;
|
||||
} catch (error) {
|
||||
console.error('Translation error:', error);
|
||||
return fallback || key;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user