Compare commits
764 Commits
v1.0
...
feat/refre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86788f4248 | ||
|
|
76c203aae6 | ||
|
|
bb5611ad40 | ||
|
|
b152dbe25e | ||
|
|
3db67c1212 | ||
|
|
dc8224ee9f | ||
|
|
8d0e815b0f | ||
|
|
65cc26fd02 | ||
|
|
9b9b8a85e7 | ||
|
|
852fc7ed7a | ||
|
|
51938293f1 | ||
|
|
34527c1842 | ||
|
|
e3b589516b | ||
|
|
1dd3a398d8 | ||
|
|
587433fcd5 | ||
|
|
c2d7954467 | ||
|
|
1c3cdf3165 | ||
|
|
d2a6cbd681 | ||
|
|
dc3d85f1a2 | ||
|
|
b4f4f4fc72 | ||
|
|
24440c5cc4 | ||
|
|
c20397206a | ||
|
|
d686dcfa90 | ||
|
|
dcee4eff58 | ||
|
|
48d7afe9f4 | ||
|
|
3009088b0e | ||
|
|
db01b1afdd | ||
|
|
53a323ccf8 | ||
|
|
76c8dfa907 | ||
|
|
16af21ba25 | ||
|
|
57ad16cc10 | ||
|
|
edfc67c050 | ||
|
|
1f953457db | ||
|
|
b5ec13eba1 | ||
|
|
bf87800fbf | ||
|
|
f078dd2f6c | ||
|
|
1e4f1ad4ae | ||
|
|
5bae42cf0f | ||
|
|
ee003e45b4 | ||
|
|
2f718edf9b | ||
|
|
09b956825f | ||
|
|
b576d6cede | ||
|
|
dbff10e3de | ||
|
|
34e707b925 | ||
|
|
dba4c31ce3 | ||
|
|
9f5ee0d9ac | ||
|
|
64d6923e05 | ||
|
|
3c6196f49c | ||
|
|
7ee9cb3f93 | ||
|
|
f05cc2e652 | ||
|
|
a047e230b9 | ||
|
|
c98a860140 | ||
|
|
f7a64eb97b | ||
|
|
dc5a1819f1 | ||
|
|
b75e50065e | ||
|
|
ef8fcd4ea9 | ||
|
|
bf3747cd7b | ||
|
|
3ff8a99250 | ||
|
|
fb64496c7d | ||
|
|
407fa0047c | ||
|
|
a2902ee7c0 | ||
|
|
7599b84833 | ||
|
|
769f543be1 | ||
|
|
d6447ffa82 | ||
|
|
f29ac73085 | ||
|
|
cb61898764 | ||
|
|
1d632200d7 | ||
|
|
fd8f6da2c6 | ||
|
|
0b50176178 | ||
|
|
c02640e104 | ||
|
|
091960f269 | ||
|
|
594f3ec16a | ||
|
|
a89da06842 | ||
|
|
db1d11309f | ||
|
|
616cf1c287 | ||
|
|
ba665edb1e | ||
|
|
1c34eddd10 | ||
|
|
1fbf6ed4ba | ||
|
|
1d6f7f9e37 | ||
|
|
e3c3ec7825 | ||
|
|
08d176c803 | ||
|
|
b952ed9035 | ||
|
|
43062dda12 | ||
|
|
1b53ff9c2d | ||
|
|
acd3e6d613 | ||
|
|
e33b02df56 | ||
|
|
2c6849ca76 | ||
|
|
9434f9e2e4 | ||
|
|
f6daef7333 | ||
|
|
c3df8d4c2a | ||
|
|
4b09693862 | ||
|
|
8d2e50d1fe | ||
|
|
bfc749f30b | ||
|
|
e0d4255700 | ||
|
|
6ba4ff1c17 | ||
|
|
652af26754 | ||
|
|
093488a67c | ||
|
|
0d026f3476 | ||
|
|
3e89ec1afc | ||
|
|
df0d18fc81 | ||
|
|
dd25c355ff | ||
|
|
6f851833b2 | ||
|
|
442b089d7f | ||
|
|
1c58b21383 | ||
|
|
a6e13faf7b | ||
|
|
ede9eb052d | ||
|
|
fab006078c | ||
|
|
4d5275f915 | ||
|
|
901e83af58 | ||
|
|
e1bee1591f | ||
|
|
a354c23231 | ||
|
|
f728dd13c5 | ||
|
|
7927b87259 | ||
|
|
55301a9d53 | ||
|
|
c0dd57a4d2 | ||
|
|
cc80773402 | ||
|
|
c803d2c4ba | ||
|
|
eb5f5f7a90 | ||
|
|
2ea5ff2c94 | ||
|
|
bc9a6fa50a | ||
|
|
e02ab7d256 | ||
|
|
01e6367b72 | ||
|
|
565602f8e1 | ||
|
|
9e0d281883 | ||
|
|
67629dd735 | ||
|
|
2a89278c7b | ||
|
|
8f4ba6eb8a | ||
|
|
8dfcfb99e0 | ||
|
|
1299aa51ee | ||
|
|
e0271cace3 | ||
|
|
a11440a7f3 | ||
|
|
cc8c4b8297 | ||
|
|
a287aab4f4 | ||
|
|
4c518df60d | ||
|
|
d4ae733e9e | ||
|
|
b5ed703553 | ||
|
|
f49880125a | ||
|
|
8380c357d9 | ||
|
|
4e010c5624 | ||
|
|
f53cdbace9 | ||
|
|
b4d04e2ce9 | ||
|
|
2470aeee1f | ||
|
|
fd07b47325 | ||
|
|
9257a05831 | ||
|
|
1faa6f2944 | ||
|
|
5584bbe9ca | ||
|
|
cc65537ea3 | ||
|
|
5f14f87406 | ||
|
|
04a80b7c03 | ||
|
|
2b44e54d99 | ||
|
|
c71a89d1b7 | ||
|
|
e2abfd2312 | ||
|
|
49d55227e8 | ||
|
|
0dadec3b8d | ||
|
|
e2d8591d66 | ||
|
|
aecc703317 | ||
|
|
2422c3e7be | ||
|
|
4e1994a0c8 | ||
|
|
a3dce67117 | ||
|
|
64dcd451e9 | ||
|
|
a85523ecfc | ||
|
|
85b32bb15b | ||
|
|
742ad86b10 | ||
|
|
39ff11a59d | ||
|
|
4f5976479a | ||
|
|
eac7aa84b0 | ||
|
|
bd941202c8 | ||
|
|
b854f0eedc | ||
|
|
1814bd4167 | ||
|
|
b6f9d70fec | ||
|
|
7c54913bf5 | ||
|
|
e8d5044ac5 | ||
|
|
ddf097ede3 | ||
|
|
1bad85e1d6 | ||
|
|
68458b50d2 | ||
|
|
e00f28cf87 | ||
|
|
4cc34ec50a | ||
|
|
693249916d | ||
|
|
381a248543 | ||
|
|
f637381198 | ||
|
|
071335cc66 | ||
|
|
4d4b011146 | ||
|
|
d10713b477 | ||
|
|
2efaabd2c3 | ||
|
|
7bc1e9dcc8 | ||
|
|
8848df701c | ||
|
|
2e800d0eed | ||
|
|
70ecc9a4a8 | ||
|
|
97dfacd133 | ||
|
|
87a5bab734 | ||
|
|
b5fc6e1aaf | ||
|
|
87f70fa290 | ||
|
|
fa61bb660e | ||
|
|
83dd079e03 | ||
|
|
f7933d8a4d | ||
|
|
4bd0cfd283 | ||
|
|
86f39f3824 | ||
|
|
94216f5219 | ||
|
|
a2e6187dae | ||
|
|
c2cda0f06e | ||
|
|
f1bc772985 | ||
|
|
15e3926ce4 | ||
|
|
69738d7ed3 | ||
|
|
649620c1c9 | ||
|
|
99df006019 | ||
|
|
be40e28f47 | ||
|
|
8899d82e1b | ||
|
|
0527641a4f | ||
|
|
54e32af1d4 | ||
|
|
b1645ec09a | ||
|
|
5b9ce55a6d | ||
|
|
833584bd4f | ||
|
|
ca52a22bcd | ||
|
|
ba611b9adb | ||
|
|
e8643850a0 | ||
|
|
bec7376fe8 | ||
|
|
7363bbc8f7 | ||
|
|
d0f679e177 | ||
|
|
e9769904ab | ||
|
|
a2746742a7 | ||
|
|
459e3dc45b | ||
|
|
21fedab707 | ||
|
|
323f1974ab | ||
|
|
5bc9d625c2 | ||
|
|
4885cf5154 | ||
|
|
2f66eca925 | ||
|
|
af042a62cd | ||
|
|
2bfc2b0c1f | ||
|
|
df8bdda718 | ||
|
|
873f99ae86 | ||
|
|
17fe135027 | ||
|
|
2eed0ae063 | ||
|
|
f4ae0389d8 | ||
|
|
9bdff9a61f | ||
|
|
f003a2864e | ||
|
|
ab26af19b8 | ||
|
|
dc512600dc | ||
|
|
cdb71c3a62 | ||
|
|
39d7b3ca58 | ||
|
|
748c3636d5 | ||
|
|
6e2d1fb148 | ||
|
|
7bd847c0d3 | ||
|
|
81f9b2f776 | ||
|
|
334671ef85 | ||
|
|
a20b1e2f6a | ||
|
|
572f9d5ad6 | ||
|
|
e49c102e8c | ||
|
|
2348221b03 | ||
|
|
a0c327cfcf | ||
|
|
ddbeb9e3db | ||
|
|
e8205c1390 | ||
|
|
fbb53fc8c3 | ||
|
|
d7fed5a5dc | ||
|
|
3285881586 | ||
|
|
3ff61607a2 | ||
|
|
2a124b03e9 | ||
|
|
e1aa23bc55 | ||
|
|
818c5c7ba4 | ||
|
|
668011d1c7 | ||
|
|
562fd043a9 | ||
|
|
b4781c011c | ||
|
|
5c7d3d5503 | ||
|
|
0f2b6c0ebf | ||
|
|
ab5bdfeae4 | ||
|
|
e5fa2f4490 | ||
|
|
2d2615447e | ||
|
|
46465acd73 | ||
|
|
2dea9ec3e7 | ||
|
|
584d5bd8ea | ||
|
|
e4b6d42672 | ||
|
|
109ad190cf | ||
|
|
78498793fa | ||
|
|
b2e916378d | ||
|
|
d950634aab | ||
|
|
a52c19355a | ||
|
|
e67e96333b | ||
|
|
7aa75b0c64 | ||
|
|
9b1069f208 | ||
|
|
abe20b8dcf | ||
|
|
58509c54a9 | ||
|
|
fd545cee0c | ||
|
|
1f92bffe7d | ||
|
|
5615627001 | ||
|
|
78c55875ef | ||
|
|
a3674947b8 | ||
|
|
63cc57b035 | ||
|
|
d4bc1eb0d1 | ||
|
|
c9f5496acb | ||
|
|
99481b6144 | ||
|
|
cee147bc9a | ||
|
|
3a825391b9 | ||
|
|
bb347e4614 | ||
|
|
d0fad4775a | ||
|
|
68d624ac78 | ||
|
|
8012d665ae | ||
|
|
0732aa317d | ||
|
|
fde53e355f | ||
|
|
8e0a10298e | ||
|
|
eef63c9adc | ||
|
|
1e5ecd79c2 | ||
|
|
58f10268e2 | ||
|
|
43ce76d928 | ||
|
|
c3d52c68e7 | ||
|
|
201ba65e1c | ||
|
|
0d130b17c8 | ||
|
|
2c90f767fd | ||
|
|
946b2fe129 | ||
|
|
2a2dbb65c6 | ||
|
|
ef2300a600 | ||
|
|
eea99ac871 | ||
|
|
f4c7799537 | ||
|
|
764c6b88c5 | ||
|
|
989d7a351f | ||
|
|
78325d9b39 | ||
|
|
679fb80f9a | ||
|
|
dc49277bf9 | ||
|
|
07bf780c3e | ||
|
|
190ae18edc | ||
|
|
82ffbeeb21 | ||
|
|
9affd7b7fa | ||
|
|
897ba629df | ||
|
|
b3f26055d9 | ||
|
|
775de16d0a | ||
|
|
1b276a0469 | ||
|
|
17fbf2673c | ||
|
|
cdc50ec876 | ||
|
|
181af24b78 | ||
|
|
4b13a42731 | ||
|
|
e330e90688 | ||
|
|
f2d3c51651 | ||
|
|
c9c111cdf2 | ||
|
|
c247295131 | ||
|
|
d417255910 | ||
|
|
677a15327b | ||
|
|
d5238939ad | ||
|
|
6860726e83 | ||
|
|
a55197fb2d | ||
|
|
7eed5c7c96 | ||
|
|
1c629af651 | ||
|
|
4b8aa3298b | ||
|
|
bcb163693a | ||
|
|
e6e8de62c8 | ||
|
|
71c7a6ee8c | ||
|
|
ecc8e59c8c | ||
|
|
d0f027c4ea | ||
|
|
2c667f785c | ||
|
|
1adf7e183e | ||
|
|
7771d7acbe | ||
|
|
58580c7fac | ||
|
|
4cd56fa98c | ||
|
|
68020006b4 | ||
|
|
70cb65d266 | ||
|
|
cef8cad14c | ||
|
|
def8f45f8b | ||
|
|
ca325cc90b | ||
|
|
a1ce6f696a | ||
|
|
4d485940ea | ||
|
|
cbe118b74f | ||
|
|
de9116e9b2 | ||
|
|
09c7f9dde8 | ||
|
|
0060b9da8c | ||
|
|
bad88a2a83 | ||
|
|
96a79b8879 | ||
|
|
60ef9df721 | ||
|
|
2d8ca8fea0 | ||
|
|
b411db40da | ||
|
|
1be0b9e01f | ||
|
|
d41ca8e0e6 | ||
|
|
eaa3e0a303 | ||
|
|
a283c88d7f | ||
|
|
d4659eee07 | ||
|
|
b93e3c0b52 | ||
|
|
079963cde8 | ||
|
|
45f447c796 | ||
|
|
2327b15e0d | ||
|
|
166cbc150f | ||
|
|
f561ef3cda | ||
|
|
027a588604 | ||
|
|
773566f193 | ||
|
|
29bd4cb9c3 | ||
|
|
1237944b71 | ||
|
|
b331e3c780 | ||
|
|
7f641e3e73 | ||
|
|
b84f0548d2 | ||
|
|
0f92534f00 | ||
|
|
7a489f241a | ||
|
|
f88e529111 | ||
|
|
47d55a5eab | ||
|
|
9dcab76cd5 | ||
|
|
c2a9647c90 | ||
|
|
02424596db | ||
|
|
776324c875 | ||
|
|
6f4c280583 | ||
|
|
dfebdfccda | ||
|
|
5809109c05 | ||
|
|
a6400eb6c9 | ||
|
|
39958ed22c | ||
|
|
c3d9cac43f | ||
|
|
74355244a4 | ||
|
|
7c4ba1b1ea | ||
|
|
f588897531 | ||
|
|
5629e08f83 | ||
|
|
37394c054c | ||
|
|
8be52e2fa3 | ||
|
|
0d702e9189 | ||
|
|
425db8fc1f | ||
|
|
2356f58e7b | ||
|
|
6c12ed4afc | ||
|
|
d76ee7f33c | ||
|
|
f8534b2c3d | ||
|
|
9014f01276 | ||
|
|
71818c0f1f | ||
|
|
974dc74073 | ||
|
|
b255eb21e5 | ||
|
|
9a58178ea5 | ||
|
|
3c36eedfba | ||
|
|
46dfaa70a3 | ||
|
|
61da354a48 | ||
|
|
fbb332fb35 | ||
|
|
7e1cce9155 | ||
|
|
ed4cbe9fa6 | ||
|
|
599e857a1e | ||
|
|
581f08c59b | ||
|
|
24a2e9e6d4 | ||
|
|
e8796a7d86 | ||
|
|
db3f75c42f | ||
|
|
b7c0df67b1 | ||
|
|
e8b5b3b24a | ||
|
|
00574325b9 | ||
|
|
99706e0ed6 | ||
|
|
326743d8a1 | ||
|
|
3f67b0f27e | ||
|
|
24036b0f24 | ||
|
|
fbf32404a6 | ||
|
|
975d52a07e | ||
|
|
f8a193c0f8 | ||
|
|
9186cb4d7b | ||
|
|
898f5a629c | ||
|
|
933076fa3f | ||
|
|
27edcebef6 | ||
|
|
abc91f7eac | ||
|
|
5862af3034 | ||
|
|
35acf05997 | ||
|
|
2bad1b9f55 | ||
|
|
73b0dc315e | ||
|
|
5969f148c8 | ||
|
|
660f5894a6 | ||
|
|
77058220a8 | ||
|
|
6cdba45396 | ||
|
|
67571158e8 | ||
|
|
171a5ba4ee | ||
|
|
525ff21563 | ||
|
|
863e53a2d5 | ||
|
|
da2033692c | ||
|
|
dbbe17a0a8 | ||
|
|
a2ef9468ae | ||
|
|
0c145fab0b | ||
|
|
f6e49e3f21 | ||
|
|
1f027d75d3 | ||
|
|
1ba7767f8e | ||
|
|
8220b2f086 | ||
|
|
a74374e39f | ||
|
|
ff957a2f82 | ||
|
|
6640f0496a | ||
|
|
e47ab838c5 | ||
|
|
551918ab9b | ||
|
|
739f3763dd | ||
|
|
0cdd980a4b | ||
|
|
b43ddcbea2 | ||
|
|
75feaefbf2 | ||
|
|
de3ebe16ee | ||
|
|
84a2d3baf6 | ||
|
|
74180defd1 | ||
|
|
aeeaaf0d8d | ||
|
|
2b84293c4e | ||
|
|
b38ef6c0a7 | ||
|
|
d524ea77ab | ||
|
|
17af4d25bd | ||
|
|
6e095921e6 | ||
|
|
150c42b246 | ||
|
|
aecf2f32b9 | ||
|
|
b23967d777 | ||
|
|
2524458b0c | ||
|
|
12c45fb882 | ||
|
|
118483b6cc | ||
|
|
fd6350b397 | ||
|
|
b3291c65bc | ||
|
|
4b849e286c | ||
|
|
7bcc26a987 | ||
|
|
692722d32e | ||
|
|
e4f06d8e30 | ||
|
|
c799380787 | ||
|
|
5540fcf0d2 | ||
|
|
d9da09c1e7 | ||
|
|
fe90aa3b7b | ||
|
|
0c680e0111 | ||
|
|
7bcf5fbd86 | ||
|
|
7218b950fe | ||
|
|
8d4df7d3dd | ||
|
|
f1f6e2e40a | ||
|
|
901013fdc6 | ||
|
|
5c9017f3cd | ||
|
|
34e962cc48 | ||
|
|
bf9254597a | ||
|
|
b5efa0d3ea | ||
|
|
a2bdb46076 | ||
|
|
ed150d9574 | ||
|
|
e756a21fda | ||
|
|
13084049da | ||
|
|
055e723777 | ||
|
|
419318c151 | ||
|
|
7722e63e1b | ||
|
|
40274021ba | ||
|
|
8529ac3ffe | ||
|
|
7ec8e762b0 | ||
|
|
2acada6dc7 | ||
|
|
d4d76dce03 | ||
|
|
3832ce2c80 | ||
|
|
fd36e39a38 | ||
|
|
14fd0eb906 | ||
|
|
af6c62d0bf | ||
|
|
8d7d6a19e7 | ||
|
|
463dc48ea6 | ||
|
|
d8f6a25059 | ||
|
|
93962625ed | ||
|
|
249211bd4f | ||
|
|
bfe0d50661 | ||
|
|
5d4a07bcc5 | ||
|
|
d28bb5de99 | ||
|
|
83a83164d4 | ||
|
|
d71e43c5d6 | ||
|
|
ed6fa4dc2a | ||
|
|
4f3970c361 | ||
|
|
40767430d9 | ||
|
|
1edfe9548d | ||
|
|
fead48c2f0 | ||
|
|
0abd3da7fd | ||
|
|
2f78922421 | ||
|
|
3df0f61947 | ||
|
|
8e42dcb7ee | ||
|
|
1888ee97e6 | ||
|
|
068aef665d | ||
|
|
2772fc1678 | ||
|
|
66dfdc5ad0 | ||
|
|
b45978374b | ||
|
|
81c3e701e2 | ||
|
|
874d919a6a | ||
|
|
e8559cecd5 | ||
|
|
8c4120f0a2 | ||
|
|
9f93af6134 | ||
|
|
3cbc722b94 | ||
|
|
3440c47c3c | ||
|
|
68a5a9da1e | ||
|
|
1f8d5e45e1 | ||
|
|
8fd9730e2b | ||
|
|
04f6df6839 | ||
|
|
ca40e983e3 | ||
|
|
9257454a96 | ||
|
|
ba054ae915 | ||
|
|
1d1c6e5a55 | ||
|
|
8844143ff5 | ||
|
|
c161a8109b | ||
|
|
e340c4ed6f | ||
|
|
b5f96ee2fc | ||
|
|
3c1790ba83 | ||
|
|
f41c78e8e3 | ||
|
|
b8b8b4dbad | ||
|
|
d195dc1a46 | ||
|
|
3ac29d8da3 | ||
|
|
2418612507 | ||
|
|
e8336ae9b4 | ||
|
|
aad52a3e2e | ||
|
|
829122c486 | ||
|
|
090752c539 | ||
|
|
fad6414995 | ||
|
|
c817c67a1c | ||
|
|
c7001e62f3 | ||
|
|
bf71d2a14e | ||
|
|
163911255e | ||
|
|
24e38a3bbc | ||
|
|
dfd714f16a | ||
|
|
722081f89e | ||
|
|
f0e1df22b8 | ||
|
|
615cb263fb | ||
|
|
18faaf49d9 | ||
|
|
650b69ae56 | ||
|
|
eb4be963e3 | ||
|
|
27c27743e3 | ||
|
|
92930a2f63 | ||
|
|
7ad3365b0e | ||
|
|
7de7624477 | ||
|
|
7c6b5ac59d | ||
|
|
9c45ce61b8 | ||
|
|
f8bf4fea36 | ||
|
|
10cd8144eb | ||
|
|
66973a3745 | ||
|
|
85677bb792 | ||
|
|
7ae99d2038 | ||
|
|
70a5105783 | ||
|
|
420372ac9e | ||
|
|
6b00282a87 | ||
|
|
dae1001cbb | ||
|
|
af81d99b2a | ||
|
|
2751adc463 | ||
|
|
396ce9f3f3 | ||
|
|
3f4f66d878 | ||
|
|
d6751d7a26 | ||
|
|
0e32baff0b | ||
|
|
f76bf4c2c7 | ||
|
|
0d8532ab6d | ||
|
|
490d3d51e1 | ||
|
|
2ab796910e | ||
|
|
07102588be | ||
|
|
04f9422f24 | ||
|
|
05a7f5e178 | ||
|
|
7bae814f96 | ||
|
|
ea45e38fa0 | ||
|
|
6d9a85112f | ||
|
|
346efd19db | ||
|
|
617143a47f | ||
|
|
66f067276e | ||
|
|
fc10d0449f | ||
|
|
083f3e7108 | ||
|
|
af307a2a49 | ||
|
|
b063758ee5 | ||
|
|
4964b252e3 | ||
|
|
e468f5bbc9 | ||
|
|
c5b7b8a18a | ||
|
|
a8a1fbb829 | ||
|
|
3c2a4892e7 | ||
|
|
6d9e84d327 | ||
|
|
73b4e30c97 | ||
|
|
bd01545a70 | ||
|
|
6d360e581d | ||
|
|
ba95818da4 | ||
|
|
d0720f4c70 | ||
|
|
f60cb22f11 | ||
|
|
e0cb4314fb | ||
|
|
0571137a60 | ||
|
|
30aabf50eb | ||
|
|
8441a5eb98 | ||
|
|
259ab49bc1 | ||
|
|
2f2d5dfc0b | ||
|
|
0f27f4261b | ||
|
|
9b92cad2db | ||
|
|
ad1ff6159c | ||
|
|
f633b17f17 | ||
|
|
8fa16001e6 | ||
|
|
e111234460 | ||
|
|
034072f50e | ||
|
|
a7664d79fd | ||
|
|
8ed2393300 | ||
|
|
94cf150ffd | ||
|
|
c571a3d0d9 | ||
|
|
1c7431b859 | ||
|
|
2f7d6548ef | ||
|
|
a16525be5e | ||
|
|
21e377d3ff | ||
|
|
45d0d3f7e8 | ||
|
|
6e62eb8d81 | ||
|
|
e098449af4 | ||
|
|
47d0030cf0 | ||
|
|
b564e5e72f | ||
|
|
e46607c1cb | ||
|
|
77c5db169a | ||
|
|
0157bf9576 | ||
|
|
454c2f45bd | ||
|
|
badb897c06 | ||
|
|
9bb5768598 | ||
|
|
951de8baf5 | ||
|
|
48ceb1e5c7 | ||
|
|
96e8962956 | ||
|
|
f3259aedea | ||
|
|
9fdc9dcbf7 | ||
|
|
0a30403719 | ||
|
|
0e1fcd86e6 | ||
|
|
8038f3ad00 | ||
|
|
44369ee7f6 | ||
|
|
7e46cb0d8e | ||
|
|
10e39246ce | ||
|
|
5e45767e44 | ||
|
|
61d7f7cbcd | ||
|
|
8f3c47d659 | ||
|
|
3b9c57fe5c | ||
|
|
6017d35cfd | ||
|
|
90e28cd3a4 | ||
|
|
e743e56787 | ||
|
|
ba25ea1370 | ||
|
|
b7543298e1 | ||
|
|
29b4cb7793 | ||
|
|
f7c3190346 | ||
|
|
5130dc5f31 | ||
|
|
d3cdd2c317 | ||
|
|
96f7ca4e36 | ||
|
|
e2471a8eb4 | ||
|
|
5f33b1da1e | ||
|
|
bca048c026 | ||
|
|
f999ca48f6 | ||
|
|
f0c607d87a | ||
|
|
6f394138f5 | ||
|
|
b710693009 | ||
|
|
fb4b96a838 | ||
|
|
af2dae8822 | ||
|
|
e400dbe2ea | ||
|
|
b718bdeb15 | ||
|
|
9ca84f5ede | ||
|
|
1a31cc321c | ||
|
|
2ec3cebcc9 | ||
|
|
659af87592 | ||
|
|
beb1a4c214 | ||
|
|
16a7030922 | ||
|
|
c036858d45 | ||
|
|
84d295e324 | ||
|
|
90eb54f768 | ||
|
|
547ed337a6 | ||
|
|
3f5937717f | ||
|
|
3cdfde5e0f | ||
|
|
6b1fcb8193 | ||
|
|
9431e7f0ad | ||
|
|
817569a333 | ||
|
|
0bbaa64080 | ||
|
|
c403812389 | ||
|
|
25d7390b27 | ||
|
|
918018c7ca | ||
|
|
58baf5ddf4 | ||
|
|
32dcd4aa0e | ||
|
|
cacfc2c535 | ||
|
|
8115ea3bf2 | ||
|
|
c64ff8ec95 | ||
|
|
38323b5ea5 | ||
|
|
34d10bd313 | ||
|
|
d743f8411a | ||
|
|
d8a6aa2686 | ||
|
|
dfcfe1d8d2 | ||
|
|
6038c3cc78 | ||
|
|
97efcf3d62 | ||
|
|
889ad1c49f | ||
|
|
b3fa837967 | ||
|
|
a5334ca6e6 | ||
|
|
0ad0524157 | ||
|
|
b50f64d4ad | ||
|
|
88d15376e3 | ||
|
|
aa884310eb | ||
|
|
dbcf7771b9 | ||
|
|
60b150cc58 | ||
|
|
bd0db0f8fd | ||
|
|
2e8e39c5a9 | ||
|
|
f22baca569 | ||
|
|
d8a094a324 | ||
|
|
4b063b68ab | ||
|
|
3c02331cb9 | ||
|
|
eea09dcfac | ||
|
|
3aea62e898 | ||
|
|
76674523c5 | ||
|
|
e0e2f3e440 | ||
|
|
d1bc948f3c | ||
|
|
2b84636993 | ||
|
|
05238f096b | ||
|
|
dd83d4607c | ||
|
|
07d13c74f5 | ||
|
|
64d1d6df37 | ||
|
|
877a579533 | ||
|
|
b0e364acf4 | ||
|
|
803ebccee3 |
17
.env.example
17
.env.example
@@ -2,6 +2,11 @@
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="secret"
|
||||
|
||||
# [[CRYPTO]]
|
||||
# Application Key for symmetric encryption and decryption
|
||||
# This should be a random string of at least 32 characters
|
||||
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
|
||||
|
||||
# [[AUTH OPTIONAL]]
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||
@@ -18,21 +23,21 @@ NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/
|
||||
# [[E2E Tests]]
|
||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
|
||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||
|
||||
# [[STORAGE]]
|
||||
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
||||
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
|
||||
NEXT_PRIVATE_UPLOAD_ENDPOINT=
|
||||
NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002"
|
||||
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
|
||||
NEXT_PRIVATE_UPLOAD_REGION=
|
||||
NEXT_PRIVATE_UPLOAD_REGION="unknown"
|
||||
# REQUIRED: Defines the bucket to use for the S3 storage transport.
|
||||
NEXT_PRIVATE_UPLOAD_BUCKET=
|
||||
NEXT_PRIVATE_UPLOAD_BUCKET="documenso"
|
||||
# OPTIONAL: Defines the access key ID to use for the S3 storage transport.
|
||||
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=
|
||||
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID="documenso"
|
||||
# OPTIONAL: Defines the secret access key to use for the S3 storage transport.
|
||||
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=
|
||||
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY="password"
|
||||
|
||||
# [[SMTP]]
|
||||
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: "Bug Report"
|
||||
labels: ["bug"]
|
||||
labels: "bug"
|
||||
description: Create a bug report to help us improve
|
||||
body:
|
||||
- type: markdown
|
||||
@@ -45,4 +45,4 @@ body:
|
||||
- label: I have included relevant environment information.
|
||||
- label: I have included any relevant screenshots.
|
||||
- label: I understand that this is a voluntary contribution and that there is no guarantee of resolution.
|
||||
- label: I want to work on creating a PR for this issue if approved
|
||||
- label: I want to work on creating a PR for this issue if approved
|
||||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -5,7 +5,7 @@ updates:
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "main"
|
||||
target-branch: "feat/refresh"
|
||||
labels:
|
||||
- "ci dependencies"
|
||||
- "ci"
|
||||
@@ -15,7 +15,7 @@ updates:
|
||||
directory: "/apps/marketing"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "main"
|
||||
target-branch: "feat/refresh"
|
||||
labels:
|
||||
- "npm dependencies"
|
||||
- "frontend"
|
||||
@@ -25,7 +25,7 @@ updates:
|
||||
directory: "/apps/web"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "main"
|
||||
target-branch: "feat/refresh"
|
||||
labels:
|
||||
- "npm dependencies"
|
||||
- "frontend"
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -2,9 +2,9 @@ name: "Continuous Integration"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
branches: [ "feat/refresh" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
branches: [ "feat/refresh" ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
|
||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -3,9 +3,9 @@ name: "CodeQL"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
branches: [ feat/refresh ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
branches: [ feat/refresh ]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
|
||||
4
.github/workflows/e2e-tests.yml
vendored
4
.github/workflows/e2e-tests.yml
vendored
@@ -1,9 +1,9 @@
|
||||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
branches: [feat/refresh]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
branches: [feat/refresh]
|
||||
jobs:
|
||||
e2e_tests:
|
||||
timeout-minutes: 60
|
||||
|
||||
16
.prettierignore
Normal file
16
.prettierignore
Normal file
@@ -0,0 +1,16 @@
|
||||
node_modules
|
||||
.next
|
||||
public
|
||||
**/**/node_modules
|
||||
**/**/.next
|
||||
**/**/public
|
||||
|
||||
*.lock
|
||||
*.log
|
||||
*.test.ts
|
||||
|
||||
.gitignore
|
||||
.npmignore
|
||||
.prettierignore
|
||||
.DS_Store
|
||||
.eslintignore
|
||||
@@ -37,7 +37,7 @@ The development branch is <code>main</code>. All pull requests should be made ag
|
||||
- Create a new branch (include the issue id and something readable):
|
||||
|
||||
```sh
|
||||
git checkout -b feat/doc-999-somefeature-that-rocks
|
||||
git checkout -b doc-999-my-feature-or-fix
|
||||
```
|
||||
|
||||
3. See the [Developer Setup](https://github.com/documenso/documenso/blob/main/README.md#developer-setup) for more setup details.
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# The Documenso Manifest
|
||||
Signing documents is a fundamental building block of private, economic, and government interactions. Access to easy and secure signing to participate in society should therefore be a fundamental right for everyone. The technology to enable this should be accessible and widespread.
|
||||
|
||||
We know that open source is the key to solving this need once and for all to benefit all humankind. Using open source kickstarts innovation by putting the open sharing of ideas and solutions first. With Documenso, we will create an open and globally accessible signing platform to empower users, customers, and developers to fulfill their needs. Documenso is built by and for the global community, listening and implementing what is needed. Being transparent with the code and the processes that use it brings trust and security to the platform.
|
||||
|
||||
We build Documenso for longevity and scale by embracing the capital efficiency and inclusiveness of the Commercial Open Source (COSS) movement. We are building a global commodity for the world.
|
||||
@@ -1,3 +1,5 @@
|
||||
🚨 We are live on Product Hunt with Single Player Mode and the new free tier: [https://www.producthunt.com/products/documenso](https://www.producthunt.com/posts/documenso-singleplayer-mode)
|
||||
|
||||
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
|
||||
|
||||
<p align="center" style="margin-top: 20px">
|
||||
@@ -139,11 +141,13 @@ npm run d
|
||||
|
||||
1. **App** - http://localhost:3000
|
||||
2. **Incoming Mail Access** - http://localhost:9000
|
||||
|
||||
3. **Database Connection Details**
|
||||
|
||||
- **Port**: 54320
|
||||
- **Connection**: Use your favorite database client to connect using the provided port.
|
||||
|
||||
4. **S3 Storage Dashboard** - http://localhost:9001
|
||||
|
||||
## Developer Setup
|
||||
|
||||
### Manual Setup
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start -p 3001",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"clean": "rimraf .next && rimraf node_modules",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
|
||||
@@ -18,6 +18,14 @@ export const revalidate = 3600;
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const GITHUB_HEADERS: Record<string, string> = {
|
||||
accept: 'application/vnd.github.v3+json',
|
||||
};
|
||||
|
||||
if (process.env.NEXT_PRIVATE_GITHUB_TOKEN) {
|
||||
GITHUB_HEADERS.authorization = `Bearer ${process.env.NEXT_PRIVATE_GITHUB_TOKEN}`;
|
||||
}
|
||||
|
||||
const ZGithubStatsResponse = z.object({
|
||||
stargazers_count: z.number(),
|
||||
forks_count: z.number(),
|
||||
@@ -28,6 +36,10 @@ const ZMergedPullRequestsResponse = z.object({
|
||||
total_count: z.number(),
|
||||
});
|
||||
|
||||
const ZOpenIssuesResponse = z.object({
|
||||
total_count: z.number(),
|
||||
});
|
||||
|
||||
const ZStargazersLiveResponse = z.record(
|
||||
z.object({
|
||||
stars: z.number(),
|
||||
@@ -48,49 +60,76 @@ const ZEarlyAdoptersResponse = z.record(
|
||||
export type StargazersType = z.infer<typeof ZStargazersLiveResponse>;
|
||||
export type EarlyAdoptersType = z.infer<typeof ZEarlyAdoptersResponse>;
|
||||
|
||||
export default async function OpenPage() {
|
||||
const GITHUB_HEADERS: Record<string, string> = {
|
||||
accept: 'application/vnd.github.v3+json',
|
||||
};
|
||||
|
||||
if (process.env.NEXT_PRIVATE_GITHUB_TOKEN) {
|
||||
GITHUB_HEADERS.authorization = `Bearer ${process.env.NEXT_PRIVATE_GITHUB_TOKEN}`;
|
||||
}
|
||||
|
||||
const {
|
||||
forks_count: forksCount,
|
||||
open_issues: openIssues,
|
||||
stargazers_count: stargazersCount,
|
||||
} = await fetch('https://api.github.com/repos/documenso/documenso', {
|
||||
headers: GITHUB_HEADERS,
|
||||
const fetchGithubStats = async () => {
|
||||
return await fetch('https://api.github.com/repos/documenso/documenso', {
|
||||
headers: {
|
||||
...GITHUB_HEADERS,
|
||||
},
|
||||
})
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => ZGithubStatsResponse.parse(res));
|
||||
};
|
||||
|
||||
const { total_count: mergedPullRequests } = await fetch(
|
||||
const fetchOpenIssues = async () => {
|
||||
return await fetch(
|
||||
'https://api.github.com/search/issues?q=repo:documenso/documenso+type:issue+state:open&page=0&per_page=1',
|
||||
{
|
||||
headers: {
|
||||
...GITHUB_HEADERS,
|
||||
},
|
||||
},
|
||||
)
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => ZOpenIssuesResponse.parse(res));
|
||||
};
|
||||
|
||||
const fetchMergedPullRequests = async () => {
|
||||
return await fetch(
|
||||
'https://api.github.com/search/issues?q=repo:documenso/documenso/+is:pr+merged:>=2010-01-01&page=0&per_page=1',
|
||||
{
|
||||
headers: GITHUB_HEADERS,
|
||||
headers: {
|
||||
...GITHUB_HEADERS,
|
||||
},
|
||||
},
|
||||
)
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => ZMergedPullRequestsResponse.parse(res));
|
||||
};
|
||||
|
||||
const STARGAZERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats', {
|
||||
const fetchStargazers = async () => {
|
||||
return await fetch('https://stargrazer-live.onrender.com/api/stats', {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
},
|
||||
})
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => ZStargazersLiveResponse.parse(res));
|
||||
};
|
||||
|
||||
const EARLY_ADOPTERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats/stripe', {
|
||||
const fetchEarlyAdopters = async () => {
|
||||
return await fetch('https://stargrazer-live.onrender.com/api/stats/stripe', {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
},
|
||||
})
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => ZEarlyAdoptersResponse.parse(res));
|
||||
};
|
||||
|
||||
export default async function OpenPage() {
|
||||
const [
|
||||
{ forks_count: forksCount, stargazers_count: stargazersCount },
|
||||
{ total_count: openIssues },
|
||||
{ total_count: mergedPullRequests },
|
||||
STARGAZERS_DATA,
|
||||
EARLY_ADOPTERS_DATA,
|
||||
] = await Promise.all([
|
||||
fetchGithubStats(),
|
||||
fetchOpenIssues(),
|
||||
fetchMergedPullRequests(),
|
||||
fetchStargazers(),
|
||||
fetchEarlyAdopters(),
|
||||
]);
|
||||
|
||||
const MONTHLY_USERS = await getUserMonthlyGrowth();
|
||||
|
||||
|
||||
@@ -8,18 +8,19 @@ import { useRouter } from 'next/navigation';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { base64 } from '@documenso/lib/universal/base64';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { DocumentDataType, Field, Prisma, Recipient } from '@documenso/prisma/client';
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import { DocumentDataType, Prisma } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
|
||||
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
||||
import type { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
||||
import {
|
||||
DocumentFlowFormContainer,
|
||||
DocumentFlowFormContainerHeader,
|
||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
|
||||
@@ -5,13 +5,12 @@ import { HTMLAttributes } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { FaXTwitter } from 'react-icons/fa6';
|
||||
import { LiaDiscord } from 'react-icons/lia';
|
||||
import { LuGithub } from 'react-icons/lu';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||
|
||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
@@ -34,8 +33,6 @@ const FOOTER_LINKS = [
|
||||
];
|
||||
|
||||
export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className={cn('border-t py-12', className)} {...props}>
|
||||
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
|
||||
@@ -77,21 +74,13 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap justify-between gap-4 px-8 md:mt-12 lg:mt-24">
|
||||
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap items-center justify-between gap-4 px-8 md:mt-12 lg:mt-24">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
|
||||
<button type="button" className="text-muted-foreground" onClick={() => setTheme('light')}>
|
||||
<Sun className="h-5 w-5" />
|
||||
<span className="sr-only">Light</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="text-muted-foreground" onClick={() => setTheme('dark')}>
|
||||
<Moon className="h-5 w-5" />
|
||||
<span className="sr-only">Dark</span>
|
||||
</button>
|
||||
<div className="flex flex-wrap">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { z } from 'zod';
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
|
||||
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
|
||||
import { alphaid } from '@documenso/lib/universal/id';
|
||||
@@ -215,7 +216,7 @@ const mapField = (
|
||||
signer: TCreateSinglePlayerDocumentSchema['signer'],
|
||||
) => {
|
||||
const customText = match(field.type)
|
||||
.with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a'))
|
||||
.with(FieldType.DATE, () => DateTime.now().toFormat(DEFAULT_DOCUMENT_DATE_FORMAT))
|
||||
.with(FieldType.EMAIL, () => signer.email)
|
||||
.with(FieldType.NAME, () => signer.name)
|
||||
.otherwise(() => '');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import { readFileSync } from 'fs';
|
||||
@@ -7,7 +7,8 @@ import { buffer } from 'micro';
|
||||
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
|
||||
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
|
||||
import { redis } from '@documenso/lib/server-only/redis';
|
||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { updateFile } from '@documenso/lib/universal/upload/update-file';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@@ -12,6 +12,7 @@ ENV_FILES.forEach((file) => {
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||
experimental: {
|
||||
serverActionsBodySizeLimit: '50mb',
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"clean": "rimraf .next && rimraf node_modules",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
@@ -42,6 +43,7 @@
|
||||
"sharp": "0.32.5",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"typescript": "5.2.2",
|
||||
"uqr": "^0.1.2",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
|
||||
import { AdminNav } from './nav';
|
||||
|
||||
@@ -4,12 +4,11 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
||||
@@ -117,7 +117,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
<MultiSelectCombobox
|
||||
listValues={roles}
|
||||
onChange={(values: string[]) => onChange(values)}
|
||||
/>
|
||||
|
||||
@@ -4,21 +4,21 @@ import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
||||
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
import {
|
||||
DocumentFlowFormContainer,
|
||||
DocumentFlowFormContainerHeader,
|
||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -117,14 +117,16 @@ export const EditDocumentForm = ({
|
||||
};
|
||||
|
||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||
const { subject, message } = data.email;
|
||||
const { subject, message, timezone, dateFormat } = data.meta;
|
||||
|
||||
try {
|
||||
await completeDocument({
|
||||
documentId: document.id,
|
||||
email: {
|
||||
meta: {
|
||||
subject,
|
||||
message,
|
||||
timezone,
|
||||
dateFormat,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { History } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
|
||||
|
||||
const FORM_ID = 'resend-email';
|
||||
|
||||
export type ResendDocumentActionItemProps = {
|
||||
document: Document;
|
||||
recipients: Recipient[];
|
||||
};
|
||||
|
||||
export const ZResendDocumentFormSchema = z.object({
|
||||
recipients: z.array(z.number()).min(1, {
|
||||
message: 'You must select at least one item.',
|
||||
}),
|
||||
});
|
||||
|
||||
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
|
||||
|
||||
export const ResendDocumentActionItem = ({
|
||||
document,
|
||||
recipients,
|
||||
}: ResendDocumentActionItemProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const isDisabled =
|
||||
document.status !== 'PENDING' ||
|
||||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
||||
|
||||
const { mutateAsync: resendDocument } = trpcReact.document.resendDocument.useMutation();
|
||||
|
||||
const form = useForm<TResendDocumentFormSchema>({
|
||||
resolver: zodResolver(ZResendDocumentFormSchema),
|
||||
defaultValues: {
|
||||
recipients: [],
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = form;
|
||||
|
||||
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
||||
try {
|
||||
await resendDocument({ documentId: document.id, recipients });
|
||||
|
||||
toast({
|
||||
title: 'Document re-sent',
|
||||
description: 'Your document has been re-sent successfully.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'This document could not be re-sent at this time. Please try again.',
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Resend
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-sm" hideClose>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<h1 className="text-center text-xl">Who do you want to remind?</h1>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipients"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
{recipients.map((recipient) => (
|
||||
<FormItem
|
||||
key={recipient.id}
|
||||
className="flex flex-row items-center justify-between gap-x-3"
|
||||
>
|
||||
<FormLabel
|
||||
className={cn('my-2 flex items-center gap-2 font-normal', {
|
||||
'opacity-50': !value.includes(recipient.id),
|
||||
})}
|
||||
>
|
||||
<StackAvatar
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
{recipient.email}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
|
||||
checkClassName="text-white"
|
||||
value={recipient.id}
|
||||
checked={value.includes(recipient.id)}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
checked
|
||||
? onChange([...value, recipient.id])
|
||||
: onChange(value.filter((v) => v !== recipient.id))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
||||
Send reminder
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Copy,
|
||||
Download,
|
||||
Edit,
|
||||
History,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@@ -19,8 +18,9 @@ import {
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import {
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
import { ResendDocumentActionItem } from './_action-items/resend-document';
|
||||
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
|
||||
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
|
||||
|
||||
@@ -96,6 +97,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
};
|
||||
|
||||
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
@@ -141,10 +143,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
|
||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Resend
|
||||
</DropdownMenuItem>
|
||||
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} />
|
||||
|
||||
<DocumentShareButton
|
||||
documentId={row.id}
|
||||
|
||||
@@ -26,16 +26,11 @@ export const DuplicateDocumentDialog = ({
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
||||
id,
|
||||
});
|
||||
|
||||
const documentData = data?.documentData
|
||||
? {
|
||||
...data.documentData,
|
||||
data: data.documentData.initialData,
|
||||
}
|
||||
: undefined;
|
||||
const documentData = document?.documentData;
|
||||
|
||||
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
||||
trpcReact.document.duplicateDocument.useMutation({
|
||||
@@ -78,7 +73,7 @@ export const DuplicateDocumentDialog = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll ">
|
||||
<LazyPDFViewer key={data?.id} documentData={documentData} />
|
||||
<LazyPDFViewer key={document?.id} documentData={documentData} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
@@ -8,10 +8,8 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||
import {
|
||||
PeriodSelectorValue,
|
||||
isPeriodSelectorValue,
|
||||
} from '~/components/(dashboard)/period-selector/types';
|
||||
import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
import { DocumentsDataTable } from './data-table';
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
@@ -23,6 +24,8 @@ export type UploadDocumentProps = {
|
||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { data: session } = useSession();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { quota, remaining } = useLimits();
|
||||
@@ -79,7 +82,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
<div className={cn('relative', className)}>
|
||||
<DocumentDropzone
|
||||
className="min-h-[40vh]"
|
||||
disabled={remaining.documents === 0}
|
||||
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||
onDrop={onFileDrop}
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import { getServerSession } from 'next-auth';
|
||||
|
||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
import { CommandMenu } from '~/components/(dashboard)/common/command-menu';
|
||||
import { Header } from '~/components/(dashboard)/layout/header';
|
||||
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||
import { NextAuthProvider } from '~/providers/next-auth';
|
||||
|
||||
@@ -30,6 +32,8 @@ export default async function AuthenticatedDashboardLayout({
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<LimitsProvider>
|
||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||
<CommandMenu />
|
||||
<Header user={user} />
|
||||
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
getStripeCustomerById,
|
||||
} from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
|
||||
export const createBillingPortal = async () => {
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
getStripeCustomerById,
|
||||
} from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
|
||||
export type CreateCheckoutOptions = {
|
||||
|
||||
@@ -4,9 +4,9 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
@@ -41,7 +41,7 @@ export default async function BillingSettingsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Billing</h3>
|
||||
<h3 className="text-2xl font-semibold">Billing</h3>
|
||||
|
||||
<div className="text-muted-foreground mt-2 text-sm">
|
||||
{isMissingOrInactiveOrFreePlan && (
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
|
||||
export default async function PasswordSettingsPage() {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Password</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">Here you can update your password.</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<PasswordForm user={user} className="max-w-xl" />
|
||||
</div>
|
||||
);
|
||||
export default function PasswordSettingsPage() {
|
||||
redirect('/settings/security');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
import { ProfileForm } from '~/components/forms/profile';
|
||||
|
||||
@@ -7,7 +7,7 @@ export default async function ProfileSettingsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Profile</h3>
|
||||
<h3 className="text-2xl font-semibold">Profile</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
||||
|
||||
|
||||
46
apps/web/src/app/(dashboard)/settings/security/page.tsx
Normal file
46
apps/web/src/app/(dashboard)/settings/security/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
||||
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
|
||||
export default async function SecuritySettingsPage() {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">Security</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Here you can manage your password and security settings.
|
||||
</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<PasswordForm user={user} className="max-w-xl" />
|
||||
|
||||
<hr className="mb-4 mt-8" />
|
||||
|
||||
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Add and manage your two factor security settings to add an extra layer of security to your
|
||||
account!
|
||||
</p>
|
||||
|
||||
<div className="mt-4 max-w-xl">
|
||||
<h5 className="font-medium">Two-factor methods</h5>
|
||||
|
||||
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||
</div>
|
||||
|
||||
{user.twoFactorEnabled && (
|
||||
<div className="mt-4 max-w-xl">
|
||||
<h5 className="font-medium">Recovery methods</h5>
|
||||
|
||||
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,13 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import {
|
||||
DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
convertToLocalSystemFormat,
|
||||
} from '@documenso/lib/constants/date-formats';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -16,9 +21,16 @@ import { SigningFieldContainer } from './signing-field-container';
|
||||
export type DateFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
dateFormat?: string | null;
|
||||
timezone?: string | null;
|
||||
};
|
||||
|
||||
export const DateField = ({ field, recipient }: DateFieldProps) => {
|
||||
export const DateField = ({
|
||||
field,
|
||||
recipient,
|
||||
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
}: DateFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
@@ -35,12 +47,18 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone);
|
||||
|
||||
const isDifferentTimeZone = field.inserted && localDateString !== field.customText;
|
||||
|
||||
const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`;
|
||||
|
||||
const onSign = async () => {
|
||||
try {
|
||||
await signFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: '',
|
||||
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
});
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
@@ -75,7 +93,13 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
<SigningFieldContainer
|
||||
field={field}
|
||||
onSign={onSign}
|
||||
onRemove={onRemove}
|
||||
type="Date"
|
||||
tooltipText={isDifferentTimeZone ? tooltipText : undefined}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
@@ -87,7 +111,7 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground text-sm duration-200">{field.customText}</p>
|
||||
<p className="text-muted-foreground text-sm duration-200">{localDateString}</p>
|
||||
)}
|
||||
</SigningFieldContainer>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -79,7 +79,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
|
||||
|
||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { Document, Field, Recipient } from '@documenso/prisma/client';
|
||||
import type { Document, Field, Recipient } from '@documenso/prisma/client';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -19,6 +19,7 @@ import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { SignDialog } from './sign-dialog';
|
||||
|
||||
export type SigningFormProps = {
|
||||
document: Document;
|
||||
@@ -45,6 +46,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
setValidateUninsertedFields(true);
|
||||
|
||||
const isFieldsValid = validateFieldsInserted(fields);
|
||||
|
||||
if (!isFieldsValid) {
|
||||
@@ -80,7 +82,11 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
disabled={isSubmitting}
|
||||
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
|
||||
>
|
||||
<div className={cn('flex flex-1 flex-col')}>
|
||||
<div
|
||||
className={cn(
|
||||
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
||||
)}
|
||||
>
|
||||
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
@@ -132,9 +138,12 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button className="w-full" type="submit" size="lg" loading={isSubmitting}>
|
||||
Complete
|
||||
</Button>
|
||||
<SignDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||
document={document}
|
||||
fields={fields}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
|
||||
import { NextAuthProvider } from '~/providers/next-auth';
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
@@ -98,7 +98,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Name">
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
|
||||
@@ -2,9 +2,12 @@ import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
|
||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
@@ -40,6 +43,8 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
viewedDocument({ token }).catch(() => null),
|
||||
]);
|
||||
|
||||
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
|
||||
|
||||
if (!document || !document.documentData || !recipient) {
|
||||
return notFound();
|
||||
}
|
||||
@@ -97,7 +102,13 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
<NameField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.with(FieldType.DATE, () => (
|
||||
<DateField key={field.id} field={field} recipient={recipient} />
|
||||
<DateField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.EMAIL, () => (
|
||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||
|
||||
77
apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx
Normal file
77
apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Document, Field } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
export type SignDialogProps = {
|
||||
isSubmitting: boolean;
|
||||
document: Document;
|
||||
fields: Field[];
|
||||
onSignatureComplete: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export const SignDialog = ({
|
||||
isSubmitting,
|
||||
document,
|
||||
fields,
|
||||
onSignatureComplete,
|
||||
}: SignDialogProps) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
|
||||
const isComplete = fields.every((field) => field.inserted);
|
||||
|
||||
return (
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
size="lg"
|
||||
disabled={!isComplete}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Complete
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-semibold text-neutral-800">Sign Document</div>
|
||||
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
|
||||
You are about to finish signing "{document.title}". Are you sure?
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowDialog(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={!isComplete}
|
||||
loading={isSubmitting}
|
||||
onClick={onSignatureComplete}
|
||||
>
|
||||
Sign
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
@@ -121,7 +121,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
export type SignatureFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
@@ -11,6 +12,8 @@ export type SignatureFieldProps = {
|
||||
children: React.ReactNode;
|
||||
onSign?: () => Promise<void> | void;
|
||||
onRemove?: () => Promise<void> | void;
|
||||
type?: 'Date' | 'Email' | 'Name' | 'Signature';
|
||||
tooltipText?: string | null;
|
||||
};
|
||||
|
||||
export const SigningFieldContainer = ({
|
||||
@@ -19,6 +22,8 @@ export const SigningFieldContainer = ({
|
||||
onSign,
|
||||
onRemove,
|
||||
children,
|
||||
type,
|
||||
tooltipText,
|
||||
}: SignatureFieldProps) => {
|
||||
const onSignFieldClick = async () => {
|
||||
if (field.inserted) {
|
||||
@@ -46,7 +51,22 @@ export const SigningFieldContainer = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.inserted && !loading && (
|
||||
{type === 'Date' && field.inserted && !loading && (
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
|
||||
onClick={onRemoveSignedFieldClick}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
||||
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{type !== 'Date' && field.inserted && !loading && (
|
||||
<button
|
||||
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
|
||||
onClick={onRemoveSignedFieldClick}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { AlertTriangle, CheckCircle2, XCircle, XOctagon } from 'lucide-react';
|
||||
|
||||
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type PageProps = {
|
||||
params: {
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function VerifyEmailPage({ params: { token } }: PageProps) {
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-4 text-red-300">
|
||||
<XOctagon />
|
||||
</div>
|
||||
|
||||
<h2 className="text-4xl font-semibold">No token provided</h2>
|
||||
<p className="text-muted-foreground mt-2 text-base">
|
||||
It seems that there is no token provided. Please check your email and try again.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const verified = await verifyEmail({ token });
|
||||
|
||||
if (verified === null) {
|
||||
return (
|
||||
<div className="flex w-full items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold md:text-4xl">Something went wrong</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
We were unable to verify your email. If your email is not verified already, please try
|
||||
again.
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/">Go back home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
return (
|
||||
<div className="flex w-full items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold md:text-4xl">Your token has expired!</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
It seems that the provided token has expired. We've just sent you another token, please
|
||||
check your email and try again.
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/">Go back home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold md:text-4xl">Email Confirmed!</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
Your email has been successfully confirmed! You can now use all features of Documenso.
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/">Go back home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
apps/web/src/app/(unauthenticated)/verify-email/page.tsx
Normal file
28
apps/web/src/app/(unauthenticated)/verify-email/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { XCircle } from 'lucide-react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export default function EmailVerificationWithoutTokenPage() {
|
||||
return (
|
||||
<div className="flex w-full items-start">
|
||||
<div className="mr-4 mt-1 hidden md:block">
|
||||
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold md:text-4xl">Uh oh! Looks like you're missing a token</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4">
|
||||
It seems that there is no token provided, if you are trying to verify your email please
|
||||
follow the link in your email.
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/">Go back home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import NotFoundPartial from '~/components/partials/not-found';
|
||||
|
||||
@@ -40,61 +40,22 @@ const SETTINGS_PAGES = [
|
||||
{ label: 'Password', path: '/settings/password' },
|
||||
];
|
||||
|
||||
export type CommandMenuProps = {
|
||||
open?: boolean;
|
||||
onOpenChange?: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
export function CommandMenu() {
|
||||
const { setTheme } = useTheme();
|
||||
const router = useRouter();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(() => open ?? false);
|
||||
const { push } = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
|
||||
const currentPage = pages[pages.length - 1];
|
||||
|
||||
const toggleOpen = () => {
|
||||
setIsOpen((isOpen) => !isOpen);
|
||||
onOpenChange?.(!isOpen);
|
||||
|
||||
if (isOpen) {
|
||||
setPages([]);
|
||||
setSearch('');
|
||||
}
|
||||
};
|
||||
|
||||
const setOpen = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsOpen(open);
|
||||
onOpenChange?.(open);
|
||||
|
||||
if (!open) {
|
||||
setPages([]);
|
||||
setSearch('');
|
||||
}
|
||||
},
|
||||
[onOpenChange],
|
||||
);
|
||||
|
||||
const push = useCallback(
|
||||
(path: string) => {
|
||||
router.push(path);
|
||||
setOpen(false);
|
||||
},
|
||||
[router, setOpen],
|
||||
);
|
||||
|
||||
const addPage = (page: string) => {
|
||||
setPages((pages) => [...pages, page]);
|
||||
setSearch('');
|
||||
setOpen((open) => !open);
|
||||
};
|
||||
|
||||
const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]);
|
||||
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
|
||||
|
||||
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen);
|
||||
useHotkeys('ctrl+k', toggleOpen);
|
||||
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
|
||||
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
|
||||
|
||||
@@ -103,11 +64,9 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
// Backspace goes to previous page when search is empty
|
||||
if (e.key === 'Escape' || (e.key === 'Backspace' && !search)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (currentPage === undefined) {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
setPages((pages) => pages.slice(0, -1));
|
||||
}
|
||||
};
|
||||
@@ -119,7 +78,6 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
onValueChange={setSearch}
|
||||
placeholder="Type a command or search..."
|
||||
/>
|
||||
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{!currentPage && (
|
||||
@@ -131,7 +89,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Preferences">
|
||||
<CommandItem onSelect={() => addPage('theme')}>Change theme</CommandItem>
|
||||
<CommandItem onSelect={() => setPages([...pages, 'theme'])}>Change theme</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,44 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, useState } from 'react';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { CommandMenu } from '../common/command-menu';
|
||||
|
||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
// const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('ml-8 hidden flex-1 gap-x-6 md:flex md:justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<CommandMenu open={open} onOpenChange={setOpen} />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
|
||||
onClick={() => setOpen((open) => !open)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Search className="mr-2 h-5 w-5" />
|
||||
Search
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-muted-foreground bg-muted rounded-md px-1.5 py-0.5 font-mono text-xs">
|
||||
Ctrl+K
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<div className={cn('ml-8 hidden flex-1 gap-x-6 md:flex', className)} {...props}>
|
||||
{/* We have no other subpaths rn */}
|
||||
{/* <Link
|
||||
href="/documents"
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from 'next/link';
|
||||
|
||||
import {
|
||||
CreditCard,
|
||||
Key,
|
||||
Lock,
|
||||
LogOut,
|
||||
User as LucideUser,
|
||||
Monitor,
|
||||
@@ -87,9 +87,9 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/password" className="cursor-pointer">
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
Password
|
||||
<Link href="/settings/security" className="cursor-pointer">
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Security
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { ONE_SECOND } from '@documenso/lib/constants/time';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type VerifyEmailBannerProps = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;
|
||||
|
||||
export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
|
||||
const { toast } = useToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
|
||||
|
||||
const { mutateAsync: sendConfirmationEmail, isLoading } =
|
||||
trpc.profile.sendConfirmationEmail.useMutation();
|
||||
|
||||
const onResendConfirmationEmail = async () => {
|
||||
try {
|
||||
setIsButtonDisabled(true);
|
||||
|
||||
await sendConfirmationEmail({ email: email });
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Verification email sent successfully.',
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
setTimeout(() => setIsButtonDisabled(false), RESEND_CONFIRMATION_EMAIL_TIMEOUT);
|
||||
} catch (err) {
|
||||
setIsButtonDisabled(false);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Something went wrong while sending the confirmation email.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check localStorage to see if we've recently automatically displayed the dialog
|
||||
// if it was within the past 24 hours, don't show it again
|
||||
// otherwise, show it again and update the localStorage timestamp
|
||||
const emailVerificationDialogLastShown = localStorage.getItem(
|
||||
'emailVerificationDialogLastShown',
|
||||
);
|
||||
|
||||
if (emailVerificationDialogLastShown) {
|
||||
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
|
||||
|
||||
if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsOpen(true);
|
||||
|
||||
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-yellow-200 dark:bg-yellow-400">
|
||||
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium text-yellow-900">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="mr-2.5 h-5 w-5" />
|
||||
Verify your email address to unlock all features.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto px-2.5 py-1.5 text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500"
|
||||
disabled={isButtonDisabled}
|
||||
onClick={() => setIsOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
{isButtonDisabled ? 'Verification Email Sent' : 'Verify Now'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogTitle>Verify your email address</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
We've sent a confirmation email to <strong>{email}</strong>. Please check your inbox and
|
||||
click the link in the email to verify your account.
|
||||
</DialogDescription>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
disabled={isButtonDisabled}
|
||||
loading={isLoading}
|
||||
onClick={onResendConfirmationEmail}
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Resend Confirmation Email'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Key, User } from 'lucide-react';
|
||||
import { CreditCard, Lock, User } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -35,16 +35,16 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/password">
|
||||
<Link href="/settings/security">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/password') && 'bg-secondary',
|
||||
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Key className="mr-2 h-5 w-5" />
|
||||
Password
|
||||
<Lock className="mr-2 h-5 w-5" />
|
||||
Security
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Key, User } from 'lucide-react';
|
||||
import { CreditCard, Lock, User } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -38,16 +38,16 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/password">
|
||||
<Link href="/settings/security">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/password') && 'bg-secondary',
|
||||
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Key className="mr-2 h-5 w-5" />
|
||||
Password
|
||||
<Lock className="mr-2 h-5 w-5" />
|
||||
Security
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { CheckCircle2, Clock, File } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, useEffect, useState } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { DateTime, DateTimeFormatOptions } from 'luxon';
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
||||
|
||||
|
||||
58
apps/web/src/components/forms/2fa/authenticator-app.tsx
Normal file
58
apps/web/src/components/forms/2fa/authenticator-app.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { DisableAuthenticatorAppDialog } from './disable-authenticator-app-dialog';
|
||||
import { EnableAuthenticatorAppDialog } from './enable-authenticator-app-dialog';
|
||||
|
||||
type AuthenticatorAppProps = {
|
||||
isTwoFactorEnabled: boolean;
|
||||
};
|
||||
|
||||
export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps) => {
|
||||
const [modalState, setModalState] = useState<'enable' | 'disable' | null>(null);
|
||||
|
||||
const isEnableDialogOpen = modalState === 'enable';
|
||||
const isDisableDialogOpen = modalState === 'disable';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
||||
<div className="flex-1">
|
||||
<p>Authenticator app</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
||||
Create one-time passwords that serve as a secondary authentication method for confirming
|
||||
your identity when requested during the sign-in process.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isTwoFactorEnabled ? (
|
||||
<Button variant="destructive" onClick={() => setModalState('disable')} size="sm">
|
||||
Disable 2FA
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setModalState('enable')} size="sm">
|
||||
Enable 2FA
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnableAuthenticatorAppDialog
|
||||
key={isEnableDialogOpen ? 'open' : 'closed'}
|
||||
open={isEnableDialogOpen}
|
||||
onOpenChange={(open) => !open && setModalState(null)}
|
||||
/>
|
||||
|
||||
<DisableAuthenticatorAppDialog
|
||||
key={isDisableDialogOpen ? 'open' : 'closed'}
|
||||
open={isDisableDialogOpen}
|
||||
onOpenChange={(open) => !open && setModalState(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZDisableTwoFactorAuthenticationForm = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
backupCode: z.string(),
|
||||
});
|
||||
|
||||
export type TDisableTwoFactorAuthenticationForm = z.infer<
|
||||
typeof ZDisableTwoFactorAuthenticationForm
|
||||
>;
|
||||
|
||||
export type DisableAuthenticatorAppDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DisableAuthenticatorAppDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DisableAuthenticatorAppDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: disableTwoFactorAuthentication } =
|
||||
trpc.twoFactorAuthentication.disable.useMutation();
|
||||
|
||||
const disableTwoFactorAuthenticationForm = useForm<TDisableTwoFactorAuthenticationForm>({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
backupCode: '',
|
||||
},
|
||||
resolver: zodResolver(ZDisableTwoFactorAuthenticationForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isDisableTwoFactorAuthenticationSubmitting } =
|
||||
disableTwoFactorAuthenticationForm.formState;
|
||||
|
||||
const onDisableTwoFactorAuthenticationFormSubmit = async ({
|
||||
password,
|
||||
backupCode,
|
||||
}: TDisableTwoFactorAuthenticationForm) => {
|
||||
try {
|
||||
await disableTwoFactorAuthentication({ password, backupCode });
|
||||
|
||||
toast({
|
||||
title: 'Two-factor authentication disabled',
|
||||
description:
|
||||
'Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.',
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
onOpenChange(false);
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to disable two-factor authentication',
|
||||
description:
|
||||
'We were unable to disable two-factor authentication for your account. Please ensure that you have entered your password and backup code correctly and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Disable Authenticator App</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
To disable the Authenticator App for your account, please enter your password and a
|
||||
backup code. If you do not have a backup code available, please contact support.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...disableTwoFactorAuthenticationForm}>
|
||||
<form
|
||||
onSubmit={disableTwoFactorAuthenticationForm.handleSubmit(
|
||||
onDisableTwoFactorAuthenticationFormSubmit,
|
||||
)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<FormField
|
||||
name="password"
|
||||
control={disableTwoFactorAuthenticationForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="backupCode"
|
||||
control={disableTwoFactorAuthenticationForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Backup Code</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isDisableTwoFactorAuthenticationSubmitting}
|
||||
>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { renderSVG } from 'uqr';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
|
||||
export const ZSetupTwoFactorAuthenticationForm = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
});
|
||||
|
||||
export type TSetupTwoFactorAuthenticationForm = z.infer<typeof ZSetupTwoFactorAuthenticationForm>;
|
||||
|
||||
export const ZEnableTwoFactorAuthenticationForm = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export type TEnableTwoFactorAuthenticationForm = z.infer<typeof ZEnableTwoFactorAuthenticationForm>;
|
||||
|
||||
export type EnableAuthenticatorAppDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const EnableAuthenticatorAppDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: EnableAuthenticatorAppDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
|
||||
trpc.twoFactorAuthentication.setup.useMutation();
|
||||
|
||||
const { mutateAsync: enableTwoFactorAuthentication, data: enableTwoFactorAuthenticationData } =
|
||||
trpc.twoFactorAuthentication.enable.useMutation();
|
||||
|
||||
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
},
|
||||
resolver: zodResolver(ZSetupTwoFactorAuthenticationForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } =
|
||||
setupTwoFactorAuthenticationForm.formState;
|
||||
|
||||
const enableTwoFactorAuthenticationForm = useForm<TEnableTwoFactorAuthenticationForm>({
|
||||
defaultValues: {
|
||||
token: '',
|
||||
},
|
||||
resolver: zodResolver(ZEnableTwoFactorAuthenticationForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } =
|
||||
enableTwoFactorAuthenticationForm.formState;
|
||||
|
||||
const step = useMemo(() => {
|
||||
if (!setupTwoFactorAuthenticationData || isSetupTwoFactorAuthenticationSubmitting) {
|
||||
return 'setup';
|
||||
}
|
||||
|
||||
if (!enableTwoFactorAuthenticationData || isEnableTwoFactorAuthenticationSubmitting) {
|
||||
return 'enable';
|
||||
}
|
||||
|
||||
return 'view';
|
||||
}, [
|
||||
setupTwoFactorAuthenticationData,
|
||||
isSetupTwoFactorAuthenticationSubmitting,
|
||||
enableTwoFactorAuthenticationData,
|
||||
isEnableTwoFactorAuthenticationSubmitting,
|
||||
]);
|
||||
|
||||
const onSetupTwoFactorAuthenticationFormSubmit = async ({
|
||||
password,
|
||||
}: TSetupTwoFactorAuthenticationForm) => {
|
||||
try {
|
||||
await setupTwoFactorAuthentication({ password });
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to setup two-factor authentication',
|
||||
description:
|
||||
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onEnableTwoFactorAuthenticationFormSubmit = async ({
|
||||
token,
|
||||
}: TEnableTwoFactorAuthenticationForm) => {
|
||||
try {
|
||||
await enableTwoFactorAuthentication({ code: token });
|
||||
|
||||
toast({
|
||||
title: 'Two-factor authentication enabled',
|
||||
description:
|
||||
'Two-factor authentication has been enabled for your account. You will now be required to enter a code from your authenticator app when signing in.',
|
||||
});
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to setup two-factor authentication',
|
||||
description:
|
||||
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onCompleteClick = () => {
|
||||
flushSync(() => {
|
||||
onOpenChange(false);
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Authenticator App</DialogTitle>
|
||||
|
||||
{step === 'setup' && (
|
||||
<DialogDescription>
|
||||
To enable two-factor authentication, please enter your password below.
|
||||
</DialogDescription>
|
||||
)}
|
||||
|
||||
{step === 'view' && (
|
||||
<DialogDescription>
|
||||
Your recovery codes are listed below. Please store them in a safe place.
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{match(step)
|
||||
.with('setup', () => {
|
||||
return (
|
||||
<Form {...setupTwoFactorAuthenticationForm}>
|
||||
<form
|
||||
onSubmit={setupTwoFactorAuthenticationForm.handleSubmit(
|
||||
onSetupTwoFactorAuthenticationFormSubmit,
|
||||
)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<FormField
|
||||
name="password"
|
||||
control={setupTwoFactorAuthenticationForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
})
|
||||
.with('enable', () => (
|
||||
<Form {...enableTwoFactorAuthenticationForm}>
|
||||
<form
|
||||
onSubmit={enableTwoFactorAuthenticationForm.handleSubmit(
|
||||
onEnableTwoFactorAuthenticationFormSubmit,
|
||||
)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
To enable two-factor authentication, scan the following QR code using your
|
||||
authenticator app.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="flex h-36 justify-center"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderSVG(setupTwoFactorAuthenticationData?.uri ?? ''),
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
If your authenticator app does not support QR codes, you can use the following
|
||||
code instead:
|
||||
</p>
|
||||
|
||||
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
|
||||
{setupTwoFactorAuthenticationData?.secret}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Once you have scanned the QR code or entered the code manually, enter the code
|
||||
provided by your authenticator app below.
|
||||
</p>
|
||||
|
||||
<FormField
|
||||
name="token"
|
||||
control={enableTwoFactorAuthenticationForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
))
|
||||
.with('view', () => (
|
||||
<div>
|
||||
{enableTwoFactorAuthenticationData?.recoveryCodes && (
|
||||
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex w-full flex-row-reverse items-center justify-between">
|
||||
<Button type="button" onClick={() => onCompleteClick()}>
|
||||
Complete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
57
apps/web/src/components/forms/2fa/recovery-code-list.tsx
Normal file
57
apps/web/src/components/forms/2fa/recovery-code-list.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Copy } from 'lucide-react';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type RecoveryCodeListProps = {
|
||||
recoveryCodes: string[];
|
||||
};
|
||||
|
||||
export const RecoveryCodeList = ({ recoveryCodes }: RecoveryCodeListProps) => {
|
||||
const { toast } = useToast();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const onCopyRecoveryCodeClick = async (code: string) => {
|
||||
try {
|
||||
const result = await copyToClipboard(code);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Unable to copy recovery code');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Recovery code copied',
|
||||
description: 'Your recovery code has been copied to your clipboard.',
|
||||
});
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to copy recovery code',
|
||||
description:
|
||||
'We were unable to copy your recovery code to your clipboard. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{recoveryCodes.map((code) => (
|
||||
<div
|
||||
key={code}
|
||||
className="bg-muted text-muted-foreground relative rounded-lg p-4 font-mono md:text-center"
|
||||
>
|
||||
<span>{code}</span>
|
||||
|
||||
<div className="absolute inset-y-0 right-4 flex items-center justify-center">
|
||||
<button
|
||||
className="opacity-60 hover:opacity-80"
|
||||
onClick={() => void onCopyRecoveryCodeClick(code)}
|
||||
>
|
||||
<Copy className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
43
apps/web/src/components/forms/2fa/recovery-codes.tsx
Normal file
43
apps/web/src/components/forms/2fa/recovery-codes.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
|
||||
|
||||
type RecoveryCodesProps = {
|
||||
// backupCodes: string[] | null;
|
||||
isTwoFactorEnabled: boolean;
|
||||
};
|
||||
|
||||
export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
||||
<div className="flex-1">
|
||||
<p>Recovery Codes</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
||||
Recovery codes are used to access your account in the event that you lose access to your
|
||||
authenticator app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button onClick={() => setIsOpen(true)} disabled={!isTwoFactorEnabled} size="sm">
|
||||
View Codes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ViewRecoveryCodesDialog
|
||||
key={isOpen ? 'open' : 'closed'}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
151
apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx
Normal file
151
apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
|
||||
export const ZViewRecoveryCodesForm = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
});
|
||||
|
||||
export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>;
|
||||
|
||||
export type ViewRecoveryCodesDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } =
|
||||
trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
||||
|
||||
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
},
|
||||
resolver: zodResolver(ZViewRecoveryCodesForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isViewRecoveryCodesSubmitting } = viewRecoveryCodesForm.formState;
|
||||
|
||||
const step = useMemo(() => {
|
||||
if (!viewRecoveryCodesData || isViewRecoveryCodesSubmitting) {
|
||||
return 'authenticate';
|
||||
}
|
||||
|
||||
return 'view';
|
||||
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
|
||||
|
||||
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
|
||||
try {
|
||||
await viewRecoveryCodes({ password });
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to view recovery codes',
|
||||
description:
|
||||
'We were unable to view your recovery codes. Please ensure that you have entered your password correctly and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>View Recovery Codes</DialogTitle>
|
||||
|
||||
{step === 'authenticate' && (
|
||||
<DialogDescription>
|
||||
To view your recovery codes, please enter your password below.
|
||||
</DialogDescription>
|
||||
)}
|
||||
|
||||
{step === 'view' && (
|
||||
<DialogDescription>
|
||||
Your recovery codes are listed below. Please store them in a safe place.
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{match(step)
|
||||
.with('authenticate', () => {
|
||||
return (
|
||||
<Form {...viewRecoveryCodesForm}>
|
||||
<form
|
||||
onSubmit={viewRecoveryCodesForm.handleSubmit(onViewRecoveryCodesFormSubmit)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<FormField
|
||||
name="password"
|
||||
control={viewRecoveryCodesForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
})
|
||||
.with('view', () => (
|
||||
<div>
|
||||
{viewRecoveryCodesData?.recoveryCodes && (
|
||||
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-row-reverse items-center justify-between">
|
||||
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
|
||||
export type AddFieldsActionInput = TAddFieldsFormSchema & {
|
||||
documentId: number;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||
|
||||
export type AddSignersActionInput = TAddSignersFormSchema & {
|
||||
documentId: number;
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
|
||||
export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const completeDocument = async ({ documentId, email }: CompleteDocumentActionInput) => {
|
||||
export const completeDocument = async ({ documentId, meta }: CompleteDocumentActionInput) => {
|
||||
'use server';
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
if (email.message || email.subject) {
|
||||
if (meta.message || meta.subject || meta.dateFormat || meta.timezone) {
|
||||
await upsertDocumentMeta({
|
||||
documentId,
|
||||
subject: email.subject,
|
||||
message: email.message,
|
||||
subject: meta.subject,
|
||||
message: meta.message,
|
||||
timezone: meta.timezone,
|
||||
dateFormat: meta.dateFormat,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { z } from 'zod';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
@@ -20,9 +21,9 @@ import { FormErrorMessage } from '../form/form-error-message';
|
||||
|
||||
export const ZPasswordFormSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(6).max(72),
|
||||
password: z.string().min(6).max(72),
|
||||
repeatedPassword: z.string().min(6).max(72),
|
||||
currentPassword: ZCurrentPasswordSchema,
|
||||
password: ZPasswordSchema,
|
||||
repeatedPassword: ZPasswordSchema,
|
||||
})
|
||||
.refine((data) => data.password === data.repeatedPassword, {
|
||||
message: 'Passwords do not match',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { z } from 'zod';
|
||||
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
@@ -20,8 +21,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZResetPasswordFormSchema = z
|
||||
.object({
|
||||
password: z.string().min(6).max(72),
|
||||
repeatedPassword: z.string().min(6).max(72),
|
||||
password: ZPasswordSchema,
|
||||
repeatedPassword: ZPasswordSchema,
|
||||
})
|
||||
.refine((data) => data.password === data.repeatedPassword, {
|
||||
path: ['repeatedPassword'],
|
||||
|
||||
@@ -3,32 +3,39 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Input, PasswordInput } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
||||
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
|
||||
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
|
||||
[ErrorCode.USER_MISSING_PASSWORD]:
|
||||
'This account appears to be using a social login method, please sign in using that method',
|
||||
[ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect',
|
||||
[ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect',
|
||||
};
|
||||
|
||||
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
|
||||
|
||||
const LOGIN_REDIRECT_PATH = '/documents';
|
||||
|
||||
export const ZSignInFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
password: z.string().min(6).max(72),
|
||||
password: ZCurrentPasswordSchema,
|
||||
totpCode: z.string().trim().optional(),
|
||||
backupCode: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
||||
@@ -39,33 +46,84 @@ export type SignInFormProps = {
|
||||
|
||||
export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||
'totp' | 'backup'
|
||||
>('totp');
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TSignInFormSchema>({
|
||||
values: {
|
||||
email: '',
|
||||
password: '',
|
||||
totpCode: '',
|
||||
backupCode: '',
|
||||
},
|
||||
resolver: zodResolver(ZSignInFormSchema),
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ email, password }: TSignInFormSchema) => {
|
||||
const onCloseTwoFactorAuthenticationDialog = () => {
|
||||
setValue('totpCode', '');
|
||||
setValue('backupCode', '');
|
||||
|
||||
setIsTwoFactorAuthenticationDialogOpen(false);
|
||||
};
|
||||
|
||||
const onToggleTwoFactorAuthenticationMethodClick = () => {
|
||||
const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp';
|
||||
|
||||
if (method === 'totp') {
|
||||
setValue('backupCode', '');
|
||||
}
|
||||
|
||||
if (method === 'backup') {
|
||||
setValue('totpCode', '');
|
||||
}
|
||||
|
||||
setTwoFactorAuthenticationMethod(method);
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
const credentials: Record<string, string> = {
|
||||
email,
|
||||
password,
|
||||
};
|
||||
|
||||
if (totpCode) {
|
||||
credentials.totpCode = totpCode;
|
||||
}
|
||||
|
||||
if (backupCode) {
|
||||
credentials.backupCode = backupCode;
|
||||
}
|
||||
|
||||
const result = await signIn('credentials', {
|
||||
...credentials,
|
||||
|
||||
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error && isErrorCode(result.error)) {
|
||||
if (result.error === TwoFactorEnabledErrorCode) {
|
||||
setIsTwoFactorAuthenticationDialogOpen(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = ERROR_MESSAGES[result.error];
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
description: ERROR_MESSAGES[result.error],
|
||||
title: 'Unable to sign in',
|
||||
description: errorMessage ?? 'An unknown error occurred',
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -118,31 +176,14 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
<span>Password</span>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="current-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
className="bg-background mt-2"
|
||||
autoComplete="current-password"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||
</div>
|
||||
@@ -173,6 +214,67 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
Google
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
open={isTwoFactorAuthenticationDialogOpen}
|
||||
onOpenChange={onCloseTwoFactorAuthenticationDialog}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Two-Factor Authentication</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
{twoFactorAuthenticationMethod === 'totp' && (
|
||||
<div>
|
||||
<Label htmlFor="totpCode" className="text-muted-forground">
|
||||
Authentication Token
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="totpCode"
|
||||
type="text"
|
||||
className="bg-background mt-2"
|
||||
{...register('totpCode')}
|
||||
/>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.totpCode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{twoFactorAuthenticationMethod === 'backup' && (
|
||||
<div>
|
||||
<Label htmlFor="backupCode" className="text-muted-forground">
|
||||
Backup Code
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="backupCode"
|
||||
type="text"
|
||||
className="bg-background mt-2"
|
||||
{...register('backupCode')}
|
||||
/>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.backupCode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onToggleTwoFactorAuthenticationMethodClick}
|
||||
>
|
||||
{twoFactorAuthenticationMethod === 'totp' ? 'Use Backup Code' : 'Use Authenticator'}
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { z } from 'zod';
|
||||
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
@@ -18,12 +19,22 @@ import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZSignUpFormSchema = z.object({
|
||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||
email: z.string().email().min(1),
|
||||
password: z.string().min(6).max(72),
|
||||
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
|
||||
});
|
||||
export const ZSignUpFormSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||
email: z.string().email().min(1),
|
||||
password: ZPasswordSchema,
|
||||
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const { name, email, password } = data;
|
||||
return !password.includes(name) && !password.includes(email.split('@')[0]);
|
||||
},
|
||||
{
|
||||
message: 'Password should not be common or based on personal information',
|
||||
},
|
||||
);
|
||||
|
||||
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
||||
|
||||
|
||||
@@ -1,53 +1,86 @@
|
||||
###########################
|
||||
# BASE CONTAINER #
|
||||
###########################
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS production_deps
|
||||
WORKDIR /app
|
||||
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
# Copy our current monorepo
|
||||
COPY . .
|
||||
|
||||
RUN npm ci --production
|
||||
|
||||
# Install dependencies only when needed
|
||||
###########################
|
||||
# BUILDER CONTAINER #
|
||||
###########################
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk add --no-cache jq
|
||||
WORKDIR /app
|
||||
|
||||
# Copy our current monorepo
|
||||
COPY . .
|
||||
|
||||
RUN TURBO_VERSION="$(npm list --package-lock-only --json turbo | jq -r '.dependencies.turbo.version')"
|
||||
RUN npm install -g "turbo@$TURBO_VERSION"
|
||||
|
||||
# Outputs to the /out folder
|
||||
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
|
||||
RUN turbo prune --scope=@documenso/web --docker
|
||||
|
||||
###########################
|
||||
# INSTALLER CONTAINER #
|
||||
###########################
|
||||
FROM base AS installer
|
||||
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk add --no-cache jq
|
||||
# Required for node_modules/aws-crt
|
||||
RUN apk add --no-cache make cmake g++
|
||||
WORKDIR /app
|
||||
|
||||
# Disable husky from installing hooks
|
||||
ENV HUSKY 0
|
||||
ENV DOCKER_OUTPUT 1
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# Uncomment and use build args to enable remote caching
|
||||
# ARG TURBO_TEAM
|
||||
# ENV TURBO_TEAM=$TURBO_TEAM
|
||||
# ARG TURBO_TOKEN
|
||||
# ENV TURBO_TOKEN=$TURBO_TOKEN
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/package-lock.json ./package-lock.json
|
||||
|
||||
RUN npm ci
|
||||
|
||||
RUN npm run build --workspaces
|
||||
# Then copy all the source code (as it changes more often)
|
||||
COPY --from=builder /app/out/full/ .
|
||||
# Finally copy the turbo.json file so that we can run turbo commands
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
RUN TURBO_VERSION="$(npm list --package-lock-only --json turbo | jq -r '.dependencies.turbo.version')"
|
||||
RUN npm install -g "turbo@$TURBO_VERSION"
|
||||
|
||||
RUN turbo run build --filter=@documenso/web...
|
||||
|
||||
###########################
|
||||
# RUNNER CONTAINER #
|
||||
###########################
|
||||
FROM base AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
USER nextjs
|
||||
|
||||
COPY --from=production_deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=production_deps --chown=nextjs:nodejs /app/package-lock.json ./package-lock.json
|
||||
COPY --from=installer /app/apps/web/next.config.js .
|
||||
COPY --from=installer /app/apps/web/package.json .
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./package.json
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next ./.next
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
CMD node apps/web/server.js
|
||||
|
||||
@@ -17,3 +17,20 @@ services:
|
||||
- 9000:9000
|
||||
- 2500:2500
|
||||
- 1100:1100
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
||||
container_name: minio
|
||||
ports:
|
||||
- 9002:9002
|
||||
- 9001:9001
|
||||
volumes:
|
||||
- minio:/data
|
||||
environment:
|
||||
MINIO_ROOT_USER: documenso
|
||||
MINIO_ROOT_PASSWORD: password
|
||||
entrypoint: sh
|
||||
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
|
||||
|
||||
volumes:
|
||||
minio:
|
||||
|
||||
32
docker/compose-test.yml
Normal file
32
docker/compose-test.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
name: documenso_test
|
||||
services:
|
||||
database:
|
||||
image: postgres:15
|
||||
environment:
|
||||
- POSTGRES_USER=documenso
|
||||
- POSTGRES_PASSWORD=password
|
||||
- POSTGRES_DB=documenso
|
||||
ports:
|
||||
- 54322:5432
|
||||
|
||||
inbucket:
|
||||
image: inbucket/inbucket
|
||||
# ports:
|
||||
# - 9000:9000
|
||||
# - 2500:2500
|
||||
# - 1100:1100
|
||||
|
||||
documenso:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: docker/Dockerfile
|
||||
depends_on:
|
||||
- database
|
||||
- inbucket
|
||||
env_file:
|
||||
- ../.env.example
|
||||
environment:
|
||||
- NEXT_PRIVATE_DATABASE_URL=postgres://documenso:password@database:5432/documenso
|
||||
- NEXT_PRIVATE_DIRECT_DATABASE_URL=postgres://documenso:password@database:5432/documenso
|
||||
ports:
|
||||
- 3000:3000
|
||||
@@ -1,3 +1,4 @@
|
||||
module.exports = {
|
||||
'**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}': ['prettier --write'],
|
||||
'**/*/package.json': ['npm run precommit'],
|
||||
};
|
||||
|
||||
1755
package-lock.json
generated
1755
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@
|
||||
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing",
|
||||
"start": "cd apps && cd web && next start",
|
||||
"lint": "turbo run lint",
|
||||
"lint:fix": "turbo run lint:fix",
|
||||
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",
|
||||
"prepare": "husky install",
|
||||
"commitlint": "commitlint --edit",
|
||||
@@ -20,7 +21,8 @@
|
||||
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
|
||||
"prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma",
|
||||
"with:env": "dotenv -e .env -e .env.local --",
|
||||
"reset:hard": "npm run clean && npm i && npm run prisma:generate"
|
||||
"reset:hard": "npm run clean && npm i && npm run prisma:generate",
|
||||
"precommit": "npm install && git add package.json package-lock.json"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8.6.0",
|
||||
@@ -46,7 +48,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"recharts": "^2.7.2"
|
||||
"recharts": "^2.7.2",
|
||||
"react-hotkeys-hook": "^4.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
"worker:test": "tsup worker/index.ts --format esm"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/nodemailer-resend": "1.0.0",
|
||||
"@react-email/components": "^0.0.7",
|
||||
"@documenso/nodemailer-resend": "2.0.0",
|
||||
"@react-email/components": "^0.0.11",
|
||||
"nodemailer": "^6.9.3",
|
||||
"react-email": "^1.9.4",
|
||||
"resend": "^1.1.0"
|
||||
"react-email": "^1.9.5",
|
||||
"resend": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tailwind-config": "*",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Button, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export type TemplateConfirmationEmailProps = {
|
||||
confirmationLink: string;
|
||||
assetBaseUrl: string;
|
||||
};
|
||||
|
||||
export const TemplateConfirmationEmail = ({
|
||||
confirmationLink,
|
||||
assetBaseUrl,
|
||||
}: TemplateConfirmationEmailProps) => {
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
Welcome to Documenso!
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
Before you get started, please confirm your email address by clicking the button below:
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={confirmationLink}
|
||||
>
|
||||
Confirm email
|
||||
</Button>
|
||||
<Text className="mt-8 text-center text-sm italic text-slate-400">
|
||||
You can also copy and paste this link into your browser: {confirmationLink} (link
|
||||
expires in 1 hour)
|
||||
</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
69
packages/email/templates/confirm-email.tsx
Normal file
69
packages/email/templates/confirm-email.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
} from '@react-email/components';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
TemplateConfirmationEmail,
|
||||
TemplateConfirmationEmailProps,
|
||||
} from '../template-components/template-confirmation-email';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export const ConfirmEmailTemplate = ({
|
||||
confirmationLink,
|
||||
assetBaseUrl,
|
||||
}: TemplateConfirmationEmailProps) => {
|
||||
const previewText = `Please confirm your email address`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
|
||||
<TemplateConfirmationEmail
|
||||
confirmationLink={confirmationLink}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
<div className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
TemplateDocumentInvite,
|
||||
TemplateDocumentInviteProps,
|
||||
} from '../template-components/template-document-invite';
|
||||
import type { TemplateDocumentInviteProps } from '../template-components/template-document-invite';
|
||||
import { TemplateDocumentInvite } from '../template-components/template-document-invite';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
|
||||
|
||||
@@ -2,14 +2,13 @@ module.exports = {
|
||||
extends: [
|
||||
'next',
|
||||
'turbo',
|
||||
'prettier',
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'plugin:package-json/recommended',
|
||||
],
|
||||
|
||||
plugins: ['prettier', 'package-json'],
|
||||
plugins: ['prettier', 'package-json', 'unused-imports'],
|
||||
|
||||
env: {
|
||||
node: true,
|
||||
@@ -30,12 +29,22 @@ module.exports = {
|
||||
},
|
||||
|
||||
rules: {
|
||||
'@next/next/no-html-link-for-pages': 'off',
|
||||
'react/no-unescaped-entities': 'off',
|
||||
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'unused-imports/no-unused-imports': 'warn',
|
||||
'unused-imports/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
vars: 'all',
|
||||
varsIgnorePattern: '^_',
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
|
||||
'no-duplicate-imports': 'error',
|
||||
'no-multi-spaces': [
|
||||
'error',
|
||||
{
|
||||
@@ -67,5 +76,14 @@ module.exports = {
|
||||
// To handle this we want this rule to catch usages and highlight them as
|
||||
// warnings so we can write appropriate interfaces and guards later.
|
||||
'@typescript-eslint/consistent-type-assertions': ['warn', { assertionStyle: 'never' }],
|
||||
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'warn',
|
||||
{
|
||||
prefer: 'type-imports',
|
||||
fixStyle: 'separate-type-imports',
|
||||
disallowTypeAnnotations: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"eslint-plugin-package-json": "^0.1.4",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-unused-imports": "^3.0.0",
|
||||
"typescript": "5.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReadStatus, Recipient, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export const getRecipientType = (recipient: Recipient) => {
|
||||
if (
|
||||
|
||||
1
packages/lib/constants/crypto.ts
Normal file
1
packages/lib/constants/crypto.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
|
||||
71
packages/lib/constants/date-formats.ts
Normal file
71
packages/lib/constants/date-formats.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from './time-zones';
|
||||
|
||||
export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd hh:mm a';
|
||||
|
||||
export const DATE_FORMATS = [
|
||||
{
|
||||
key: 'YYYYMMDD',
|
||||
label: 'YYYY-MM-DD',
|
||||
value: DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
},
|
||||
{
|
||||
key: 'DDMMYYYY',
|
||||
label: 'DD/MM/YYYY',
|
||||
value: 'dd/MM/yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'MMDDYYYY',
|
||||
label: 'MM/DD/YYYY',
|
||||
value: 'MM/dd/yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'YYYYMMDDHHmm',
|
||||
label: 'YYYY-MM-DD HH:mm',
|
||||
value: 'yyyy-MM-dd HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'YYMMDD',
|
||||
label: 'YY-MM-DD',
|
||||
value: 'yy-MM-dd hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'YYYYMMDDhhmmss',
|
||||
label: 'YYYY-MM-DD HH:mm:ss',
|
||||
value: 'yyyy-MM-dd HH:mm:ss',
|
||||
},
|
||||
{
|
||||
key: 'MonthDateYear',
|
||||
label: 'Month Date, Year',
|
||||
value: 'MMMM dd, yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'DayMonthYear',
|
||||
label: 'Day, Month Year',
|
||||
value: 'EEEE, MMMM dd, yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'ISO8601',
|
||||
label: 'ISO 8601',
|
||||
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
||||
},
|
||||
];
|
||||
|
||||
export const convertToLocalSystemFormat = (
|
||||
customText: string,
|
||||
dateFormat: string | null = DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
timeZone: string | null = DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
): string => {
|
||||
const parsedDate = DateTime.fromFormat(customText, dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, {
|
||||
zone: timeZone ?? DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
});
|
||||
|
||||
if (!parsedDate.isValid) {
|
||||
return 'Invalid date';
|
||||
}
|
||||
|
||||
const formattedDate = parsedDate.toLocal().toFormat(dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
|
||||
|
||||
return formattedDate;
|
||||
};
|
||||
44
packages/lib/constants/time-zones.ts
Normal file
44
packages/lib/constants/time-zones.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { rawTimeZones, timeZonesNames } from '@vvo/tzdb';
|
||||
|
||||
export const TIME_ZONE_DATA = rawTimeZones;
|
||||
|
||||
export const DEFAULT_DOCUMENT_TIME_ZONE = 'Etc/UTC';
|
||||
|
||||
export type TimeZone = {
|
||||
name: string;
|
||||
rawOffsetInMinutes: number;
|
||||
};
|
||||
|
||||
export const minutesToHours = (minutes: number): string => {
|
||||
const hours = Math.abs(Math.floor(minutes / 60));
|
||||
const min = Math.abs(minutes % 60);
|
||||
const sign = minutes >= 0 ? '+' : '-';
|
||||
|
||||
return `${sign}${String(hours).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const getGMTOffsets = (timezones: TimeZone[]): string[] => {
|
||||
const gmtOffsets: string[] = [];
|
||||
|
||||
for (const timezone of timezones) {
|
||||
const offsetValue = minutesToHours(timezone.rawOffsetInMinutes);
|
||||
const gmtText = `(${offsetValue})`;
|
||||
|
||||
gmtOffsets.push(`${timezone.name} ${gmtText}`);
|
||||
}
|
||||
|
||||
return gmtOffsets;
|
||||
};
|
||||
|
||||
export const splitTimeZone = (input: string | null): string => {
|
||||
if (input === null) {
|
||||
return '';
|
||||
}
|
||||
const [timeZone] = input.split('(');
|
||||
|
||||
return timeZone.trim();
|
||||
};
|
||||
|
||||
export const TIME_ZONES_FULL = getGMTOffsets(TIME_ZONE_DATA);
|
||||
|
||||
export const TIME_ZONES = ['Etc/UTC', ...timeZonesNames];
|
||||
@@ -1,12 +1,15 @@
|
||||
import { PrismaAdapter } from '@next-auth/prisma-adapter';
|
||||
import { compare } from 'bcrypt';
|
||||
import { DateTime } from 'luxon';
|
||||
import { AuthOptions, Session, User } from 'next-auth';
|
||||
import type { AuthOptions, Session, User } from 'next-auth';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import GoogleProvider, { GoogleProfile } from 'next-auth/providers/google';
|
||||
import type { GoogleProfile } from 'next-auth/providers/google';
|
||||
import GoogleProvider from 'next-auth/providers/google';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
||||
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||
import { ErrorCode } from './error-codes';
|
||||
|
||||
@@ -22,13 +25,19 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
totpCode: {
|
||||
label: 'Two-factor Code',
|
||||
type: 'input',
|
||||
placeholder: 'Code from authenticator app',
|
||||
},
|
||||
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
|
||||
},
|
||||
authorize: async (credentials, _req) => {
|
||||
if (!credentials) {
|
||||
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
||||
}
|
||||
|
||||
const { email, password } = credentials;
|
||||
const { email, password, backupCode, totpCode } = credentials;
|
||||
|
||||
const user = await getUserByEmail({ email }).catch(() => {
|
||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||
@@ -44,6 +53,20 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||
}
|
||||
|
||||
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
|
||||
|
||||
if (is2faEnabled) {
|
||||
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error(
|
||||
totpCode
|
||||
? ErrorCode.INCORRECT_TWO_FACTOR_CODE
|
||||
: ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(user.id),
|
||||
email: user.email,
|
||||
@@ -88,6 +111,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
merged.id = retrieved.id;
|
||||
merged.name = retrieved.name;
|
||||
merged.email = retrieved.email;
|
||||
merged.emailVerified = retrieved.emailVerified;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -112,6 +136,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
name: merged.name,
|
||||
email: merged.email,
|
||||
lastSignedIn: merged.lastSignedIn,
|
||||
emailVerified: merged.emailVerified,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -123,6 +148,8 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
id: Number(token.id),
|
||||
name: token.name,
|
||||
email: token.email,
|
||||
emailVerified:
|
||||
typeof token.emailVerified === 'string' ? new Date(token.emailVerified) : null,
|
||||
},
|
||||
} satisfies Session;
|
||||
}
|
||||
|
||||
@@ -8,4 +8,15 @@ export const ErrorCode = {
|
||||
INCORRECT_EMAIL_PASSWORD: 'INCORRECT_EMAIL_PASSWORD',
|
||||
USER_MISSING_PASSWORD: 'USER_MISSING_PASSWORD',
|
||||
CREDENTIALS_NOT_FOUND: 'CREDENTIALS_NOT_FOUND',
|
||||
INTERNAL_SEVER_ERROR: 'INTERNAL_SEVER_ERROR',
|
||||
TWO_FACTOR_ALREADY_ENABLED: 'TWO_FACTOR_ALREADY_ENABLED',
|
||||
TWO_FACTOR_SETUP_REQUIRED: 'TWO_FACTOR_SETUP_REQUIRED',
|
||||
TWO_FACTOR_MISSING_SECRET: 'TWO_FACTOR_MISSING_SECRET',
|
||||
TWO_FACTOR_MISSING_CREDENTIALS: 'TWO_FACTOR_MISSING_CREDENTIALS',
|
||||
INCORRECT_TWO_FACTOR_CODE: 'INCORRECT_TWO_FACTOR_CODE',
|
||||
INCORRECT_TWO_FACTOR_BACKUP_CODE: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
|
||||
INCORRECT_IDENTITY_PROVIDER: 'INCORRECT_IDENTITY_PROVIDER',
|
||||
INCORRECT_PASSWORD: 'INCORRECT_PASSWORD',
|
||||
MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY',
|
||||
MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE',
|
||||
} as const;
|
||||
|
||||
35
packages/lib/next-auth/get-server-component-session.ts
Normal file
35
packages/lib/next-auth/get-server-component-session.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
'use server';
|
||||
|
||||
import { cache } from 'react';
|
||||
|
||||
import { getServerSession as getNextAuthServerSession } from 'next-auth';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { NEXT_AUTH_OPTIONS } from './auth-options';
|
||||
|
||||
export const getServerComponentSession = cache(async () => {
|
||||
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);
|
||||
|
||||
if (!session || !session.user?.email) {
|
||||
return { user: null, session: null };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: session.user.email,
|
||||
},
|
||||
});
|
||||
|
||||
return { user, session };
|
||||
});
|
||||
|
||||
export const getRequiredServerComponentSession = cache(async () => {
|
||||
const { user, session } = await getServerComponentSession();
|
||||
|
||||
if (!user || !session) {
|
||||
throw new Error('No session found');
|
||||
}
|
||||
|
||||
return { user, session };
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
|
||||
'use server';
|
||||
|
||||
import type { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { getServerSession as getNextAuthServerSession } from 'next-auth';
|
||||
|
||||
@@ -26,29 +28,3 @@ export const getServerSession = async ({ req, res }: GetServerSessionOptions) =>
|
||||
|
||||
return { user, session };
|
||||
};
|
||||
|
||||
export const getServerComponentSession = async () => {
|
||||
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);
|
||||
|
||||
if (!session || !session.user?.email) {
|
||||
return { user: null, session: null };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: session.user.email,
|
||||
},
|
||||
});
|
||||
|
||||
return { user, session };
|
||||
};
|
||||
|
||||
export const getRequiredServerComponentSession = async () => {
|
||||
const { user, session } = await getServerComponentSession();
|
||||
|
||||
if (!user || !session) {
|
||||
throw new Error('No session found');
|
||||
}
|
||||
|
||||
return { user, session };
|
||||
};
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"next-auth/"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -22,15 +24,19 @@
|
||||
"@documenso/prisma": "*",
|
||||
"@documenso/signing": "*",
|
||||
"@next-auth/prisma-adapter": "1.0.7",
|
||||
"@noble/ciphers": "0.4.0",
|
||||
"@noble/hashes": "1.3.2",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@scure/base": "^1.1.3",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@upstash/redis": "^1.20.6",
|
||||
"@vvo/tzdb": "^6.117.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"luxon": "^3.4.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "14.0.0",
|
||||
"next-auth": "4.24.3",
|
||||
"oslo": "^0.17.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"react": "18.2.0",
|
||||
"remeda": "^1.27.1",
|
||||
|
||||
48
packages/lib/server-only/2fa/disable-2fa.ts
Normal file
48
packages/lib/server-only/2fa/disable-2fa.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { compare } from 'bcrypt';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { ErrorCode } from '../../next-auth/error-codes';
|
||||
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||
|
||||
type DisableTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
backupCode: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export const disableTwoFactorAuthentication = async ({
|
||||
backupCode,
|
||||
user,
|
||||
password,
|
||||
}: DisableTwoFactorAuthenticationOptions) => {
|
||||
if (!user.password) {
|
||||
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||
}
|
||||
|
||||
const isCorrectPassword = await compare(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
throw new Error(ErrorCode.INCORRECT_PASSWORD);
|
||||
}
|
||||
|
||||
const isValid = await validateTwoFactorAuthentication({ backupCode, user });
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorBackupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
47
packages/lib/server-only/2fa/enable-2fa.ts
Normal file
47
packages/lib/server-only/2fa/enable-2fa.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { getBackupCodes } from './get-backup-code';
|
||||
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||
|
||||
type EnableTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export const enableTwoFactorAuthentication = async ({
|
||||
user,
|
||||
code,
|
||||
}: EnableTwoFactorAuthenticationOptions) => {
|
||||
if (user.identityProvider !== 'DOCUMENSO') {
|
||||
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED);
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
|
||||
}
|
||||
|
||||
const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code });
|
||||
|
||||
if (!isValidToken) {
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const recoveryCodes = getBackupCodes({ user: updatedUser });
|
||||
|
||||
return { recoveryCodes };
|
||||
};
|
||||
38
packages/lib/server-only/2fa/get-backup-code.ts
Normal file
38
packages/lib/server-only/2fa/get-backup-code.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
import { symmetricDecrypt } from '../../universal/crypto';
|
||||
|
||||
interface GetBackupCodesOptions {
|
||||
user: User;
|
||||
}
|
||||
|
||||
const ZBackupCodeSchema = z.array(z.string());
|
||||
|
||||
export const getBackupCodes = ({ user }: GetBackupCodesOptions) => {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!user.twoFactorEnabled) {
|
||||
throw new Error('User has not enabled 2FA');
|
||||
}
|
||||
|
||||
if (!user.twoFactorBackupCodes) {
|
||||
throw new Error('User has no backup codes');
|
||||
}
|
||||
|
||||
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorBackupCodes })).toString(
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const data = JSON.parse(secret);
|
||||
|
||||
const result = ZBackupCodeSchema.safeParse(data);
|
||||
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
17
packages/lib/server-only/2fa/is-2fa-availble.ts
Normal file
17
packages/lib/server-only/2fa/is-2fa-availble.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
|
||||
type IsTwoFactorAuthenticationEnabledOptions = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const isTwoFactorAuthenticationEnabled = ({
|
||||
user,
|
||||
}: IsTwoFactorAuthenticationEnabledOptions) => {
|
||||
return (
|
||||
user.twoFactorEnabled &&
|
||||
user.identityProvider === 'DOCUMENSO' &&
|
||||
typeof DOCUMENSO_ENCRYPTION_KEY === 'string'
|
||||
);
|
||||
};
|
||||
76
packages/lib/server-only/2fa/setup-2fa.ts
Normal file
76
packages/lib/server-only/2fa/setup-2fa.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { base32 } from '@scure/base';
|
||||
import { compare } from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
import { createTOTPKeyURI } from 'oslo/otp';
|
||||
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
import { symmetricEncrypt } from '../../universal/crypto';
|
||||
|
||||
type SetupTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const ISSUER = 'Documenso';
|
||||
|
||||
export const setupTwoFactorAuthentication = async ({
|
||||
user,
|
||||
password,
|
||||
}: SetupTwoFactorAuthenticationOptions) => {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!key) {
|
||||
throw new Error(ErrorCode.MISSING_ENCRYPTION_KEY);
|
||||
}
|
||||
|
||||
if (user.identityProvider !== 'DOCUMENSO') {
|
||||
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||
}
|
||||
|
||||
const isCorrectPassword = await compare(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
throw new Error(ErrorCode.INCORRECT_PASSWORD);
|
||||
}
|
||||
|
||||
const secret = crypto.randomBytes(10);
|
||||
|
||||
const backupCodes = new Array(10)
|
||||
.fill(null)
|
||||
.map(() => crypto.randomBytes(5).toString('hex'))
|
||||
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase());
|
||||
|
||||
const accountName = user.email;
|
||||
const uri = createTOTPKeyURI(ISSUER, accountName, secret);
|
||||
const encodedSecret = base32.encode(secret);
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorBackupCodes: symmetricEncrypt({
|
||||
data: JSON.stringify(backupCodes),
|
||||
key: key,
|
||||
}),
|
||||
twoFactorSecret: symmetricEncrypt({
|
||||
data: encodedSecret,
|
||||
key: key,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
secret: encodedSecret,
|
||||
uri,
|
||||
};
|
||||
};
|
||||
35
packages/lib/server-only/2fa/validate-2fa.ts
Normal file
35
packages/lib/server-only/2fa/validate-2fa.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { ErrorCode } from '../../next-auth/error-codes';
|
||||
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||
import { verifyBackupCode } from './verify-backup-code';
|
||||
|
||||
type ValidateTwoFactorAuthenticationOptions = {
|
||||
totpCode?: string;
|
||||
backupCode?: string;
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const validateTwoFactorAuthentication = async ({
|
||||
backupCode,
|
||||
totpCode,
|
||||
user,
|
||||
}: ValidateTwoFactorAuthenticationOptions) => {
|
||||
if (!user.twoFactorEnabled) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_MISSING_SECRET);
|
||||
}
|
||||
|
||||
if (totpCode) {
|
||||
return await verifyTwoFactorAuthenticationToken({ user, totpCode });
|
||||
}
|
||||
|
||||
if (backupCode) {
|
||||
return await verifyBackupCode({ user, backupCode });
|
||||
}
|
||||
|
||||
throw new Error(ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS);
|
||||
};
|
||||
33
packages/lib/server-only/2fa/verify-2fa-token.ts
Normal file
33
packages/lib/server-only/2fa/verify-2fa-token.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { base32 } from '@scure/base';
|
||||
import { TOTPController } from 'oslo/otp';
|
||||
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
import { symmetricDecrypt } from '../../universal/crypto';
|
||||
|
||||
const totp = new TOTPController();
|
||||
|
||||
type VerifyTwoFactorAuthenticationTokenOptions = {
|
||||
user: User;
|
||||
totpCode: string;
|
||||
};
|
||||
|
||||
export const verifyTwoFactorAuthenticationToken = async ({
|
||||
user,
|
||||
totpCode,
|
||||
}: VerifyTwoFactorAuthenticationTokenOptions) => {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
throw new Error('user missing 2fa secret');
|
||||
}
|
||||
|
||||
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorSecret })).toString(
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const isValidToken = await totp.verify(totpCode, base32.decode(secret));
|
||||
|
||||
return isValidToken;
|
||||
};
|
||||
18
packages/lib/server-only/2fa/verify-backup-code.ts
Normal file
18
packages/lib/server-only/2fa/verify-backup-code.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { getBackupCodes } from './get-backup-code';
|
||||
|
||||
type VerifyBackupCodeParams = {
|
||||
user: User;
|
||||
backupCode: string;
|
||||
};
|
||||
|
||||
export const verifyBackupCode = async ({ user, backupCode }: VerifyBackupCodeParams) => {
|
||||
const userBackupCodes = await getBackupCodes({ user });
|
||||
|
||||
if (!userBackupCodes) {
|
||||
throw new Error('User has no backup codes');
|
||||
}
|
||||
|
||||
return userBackupCodes.includes(backupCode);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hashSync as bcryptHashSync } from 'bcrypt';
|
||||
import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt';
|
||||
|
||||
import { SALT_ROUNDS } from '../../constants/auth';
|
||||
|
||||
@@ -8,3 +8,7 @@ import { SALT_ROUNDS } from '../../constants/auth';
|
||||
export const hashSync = (password: string) => {
|
||||
return bcryptHashSync(password, SALT_ROUNDS);
|
||||
};
|
||||
|
||||
export const compareSync = (password: string, hash: string) => {
|
||||
return bcryptCompareSync(password, hash);
|
||||
};
|
||||
|
||||
56
packages/lib/server-only/auth/send-confirmation-email.ts
Normal file
56
packages/lib/server-only/auth/send-confirmation-email.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { ConfirmEmailTemplate } from '@documenso/email/templates/confirm-email';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface SendConfirmationEmailProps {
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
VerificationToken: {
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [verificationToken] = user.VerificationToken;
|
||||
|
||||
if (!verificationToken?.token) {
|
||||
throw new Error('Verification token not found for the user');
|
||||
}
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
|
||||
const senderName = process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
|
||||
const senderAdress = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
|
||||
|
||||
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
|
||||
assetBaseUrl,
|
||||
confirmationLink,
|
||||
});
|
||||
|
||||
return mailer.sendMail({
|
||||
to: {
|
||||
address: user.email,
|
||||
name: user.name || '',
|
||||
},
|
||||
from: {
|
||||
name: senderName,
|
||||
address: senderAdress,
|
||||
},
|
||||
subject: 'Please confirm your email',
|
||||
html: render(confirmationTemplate),
|
||||
text: render(confirmationTemplate, { plainText: true }),
|
||||
});
|
||||
};
|
||||
@@ -6,11 +6,15 @@ export type CreateDocumentMetaOptions = {
|
||||
documentId: number;
|
||||
subject: string;
|
||||
message: string;
|
||||
timezone: string;
|
||||
dateFormat: string;
|
||||
};
|
||||
|
||||
export const upsertDocumentMeta = async ({
|
||||
subject,
|
||||
message,
|
||||
timezone,
|
||||
dateFormat,
|
||||
documentId,
|
||||
}: CreateDocumentMetaOptions) => {
|
||||
return await prisma.documentMeta.upsert({
|
||||
@@ -20,11 +24,15 @@ export const upsertDocumentMeta = async ({
|
||||
create: {
|
||||
subject,
|
||||
message,
|
||||
dateFormat,
|
||||
timezone,
|
||||
documentId,
|
||||
},
|
||||
update: {
|
||||
subject,
|
||||
message,
|
||||
dateFormat,
|
||||
timezone,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -25,6 +25,8 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI
|
||||
select: {
|
||||
message: true,
|
||||
subject: true,
|
||||
dateFormat: true,
|
||||
timezone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user