Compare commits
4
Commits
b4fbb93ab5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93873228ff | ||
|
|
c6d732ff10 | ||
|
|
880ac58042 | ||
|
|
4d350c0278 |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# ripwire quality acks v1 — written by --quality-ack; a finding stays suppressed until it worsens past its acked magnitude
|
||||
# format: ack <kind> <16-hex-key> <ackNow> [cid=<16-hex-content-id>] [by=<scope that acked it>] <reason to end of line> — one per line, kept SORTED by (kind,key) on every write (merge-friendly)
|
||||
ack dead-code:new-symbol 063f1900d7e62017 0 cid=dc34ac1ff7665092 Next file-system default routes and JSX submit callbacks are runtime entrypoints; production build, served login HTML and component behavior tests verify reachability.
|
||||
ack dead-code:preexisting 25a63fa5465bc5f3 0 cid=a8934b17685be0c9 Next file-system default routes and JSX submit callbacks are runtime entrypoints; production build, served login HTML and component behavior tests verify reachability.
|
||||
ack dead-code:preexisting 43ae69eb99c246bd 0 cid=4149d5dfdb978abd Next file-system default routes and JSX submit callbacks are runtime entrypoints; production build, served login HTML and component behavior tests verify reachability.
|
||||
ack dead-code:preexisting a3b73a9575dc4b25 0 cid=91882ba72b296982 Next file-system default routes and JSX submit callbacks are runtime entrypoints; production build, served login HTML and component behavior tests verify reachability.
|
||||
@@ -0,0 +1,652 @@
|
||||
# ripwire quality baseline v6 — regenerate with --quality-baseline; do not hand-edit
|
||||
producer 6ba6bc71fb56e24b994359ee76e73cdc590f0c37779cbed0edef299c36756a40
|
||||
head b4fbb93ab50253188abbfd1db4ec317920001937
|
||||
ccx 1244c7c7cd1fd8b 0
|
||||
ccx 1db52daaaf57a64 0
|
||||
ccx 38182968886385e 0
|
||||
ccx 55ba6bdcfffe904 7
|
||||
ccx 7167d30674dbf63 3
|
||||
ccx 8e89a6d1fe25686 6
|
||||
ccx 152c7faf3423da88 0
|
||||
ccx 15df2860f80af8a3 0
|
||||
ccx 1ad8497b17a82f52 0
|
||||
ccx 2181f6ceb4c7477a 0
|
||||
ccx 2289e12e42c4e944 0
|
||||
ccx 24b76ca4826c5638 0
|
||||
ccx 25124adbff4938f5 0
|
||||
ccx 25a63fa5465bc5f3 1
|
||||
ccx 2ee71f6c7bc945fc 0
|
||||
ccx 31c8aa09bedd3a7b 1
|
||||
ccx 3a9c2e28f2f4c971 0
|
||||
ccx 3c3a9f754027034f 0
|
||||
ccx 3c750cd9774511e1 1
|
||||
ccx 3f321536f08a574b 0
|
||||
ccx 43ae69eb99c246bd 7
|
||||
ccx 451c71fe46a88a45 8
|
||||
ccx 477fb0a95df4de78 0
|
||||
ccx 481b17d410524bc9 0
|
||||
ccx 48392508fbeb2328 0
|
||||
ccx 4ab1da4efffdfe57 0
|
||||
ccx 508c5e817af8a999 0
|
||||
ccx 50a84d499bb42483 0
|
||||
ccx 511a3773cc69cdce 0
|
||||
ccx 51bd51b06f9d043a 0
|
||||
ccx 51c2230f60e10fa9 0
|
||||
ccx 5af4d7054c5559b6 0
|
||||
ccx 5ecab546ba33bd53 0
|
||||
ccx 5eed9706ed870021 1
|
||||
ccx 63e5a5404234d3d8 0
|
||||
ccx 64bb4422cbeb7ddf 0
|
||||
ccx 661e27d15af79ad1 0
|
||||
ccx 6c92d9507e31e28e 0
|
||||
ccx 6e4a7b562b4f2b06 0
|
||||
ccx 6f620455d93af4cc 1
|
||||
ccx 70b5147ec4eb035e 0
|
||||
ccx 713302e7d3a015bc 0
|
||||
ccx 714bbb5988c06069 0
|
||||
ccx 733de74921aafeaf 0
|
||||
ccx 74a43eee461c4b92 0
|
||||
ccx 7561ea47cdacbf1c 0
|
||||
ccx 78443083bf63ce64 0
|
||||
ccx 79f4173e0f2e51a7 11
|
||||
ccx 7adc71b514f49b47 0
|
||||
ccx 7f1b6e87b59ecd7b 4
|
||||
ccx 8062378a693ebea7 0
|
||||
ccx 82ad73bc67f7693d 0
|
||||
ccx 8370dc3dadb2bcfb 0
|
||||
ccx 8c58a47dcf04c64c 0
|
||||
ccx 9001dcbc7e1d3aea 0
|
||||
ccx 91c838f189efabf7 5
|
||||
ccx 91ff00c34ace10ba 1
|
||||
ccx 96a89f1e7280b8d7 0
|
||||
ccx 9741c003a8a15ed9 0
|
||||
ccx 9840a9660c6fdd94 0
|
||||
ccx 9a70cba9cf1ebdf7 0
|
||||
ccx 9be1d2f6ca24c2d7 0
|
||||
ccx 9d734221e5c3e063 0
|
||||
ccx a0df8b624fda7b0c 2
|
||||
ccx a1675e0277a72e1a 0
|
||||
ccx a3b73a9575dc4b25 1
|
||||
ccx a78d827224d8bb28 0
|
||||
ccx a7db39d64ea12c0a 10
|
||||
ccx a8cb0d648705da0c 1
|
||||
ccx a8cf87082209adfb 7
|
||||
ccx aa886b4cd027024e 0
|
||||
ccx ad3ee1fd5442beb3 0
|
||||
ccx b1ed81d137c24b7c 0
|
||||
ccx b6f11c20341391e5 0
|
||||
ccx b7a6f2b228337846 3
|
||||
ccx badcf9479458b20a 0
|
||||
ccx c355cd8704e407dc 0
|
||||
ccx c7a9af4537e2afe8 0
|
||||
ccx cad731c520ec519b 0
|
||||
ccx d0a38278d796bd04 0
|
||||
ccx d15e9e9934588e30 4
|
||||
ccx d33db8f2973d3fcb 0
|
||||
ccx d3694276ef344923 0
|
||||
ccx d3b2ed48be7d702c 0
|
||||
ccx d3d444d665f32ea0 1
|
||||
ccx d50883441c9933eb 0
|
||||
ccx da2ac98964590fc4 0
|
||||
ccx dac7c9f4b8bba74d 0
|
||||
ccx de4ee76947538762 0
|
||||
ccx e02c819d289fed29 0
|
||||
ccx e04f204b5c54120d 0
|
||||
ccx e110540af19ccc98 1
|
||||
ccx e2b93e155905eae3 1
|
||||
ccx e8e55e1817b8592c 0
|
||||
ccx eaaa91d2cc464568 0
|
||||
ccx eacb2d4d8d78b8db 6
|
||||
ccx eb3b6f0f11d0f4b0 0
|
||||
ccx ecd28a6977c13173 0
|
||||
ccx ee35548d34836b29 0
|
||||
ccx f216de2b471a4ff3 1
|
||||
ccx f43ac65d42899c26 0
|
||||
ccx f49dece5c6e17265 0
|
||||
ccx f52a8fffd7d8e216 0
|
||||
ccx f7d3bd419470ea93 0
|
||||
ccx f91c0d240bc00c1e 2
|
||||
ccx f95bf6daef6d17bf 0
|
||||
ccx f98584b54995a292 0
|
||||
ccx fef30218cd311413 0
|
||||
ccx ffbe62bbe9c06ac6 12
|
||||
loc 1244c7c7cd1fd8b 4
|
||||
loc 1db52daaaf57a64 12
|
||||
loc 38182968886385e 0
|
||||
loc 55ba6bdcfffe904 41
|
||||
loc 7167d30674dbf63 15
|
||||
loc 8e89a6d1fe25686 19
|
||||
loc 152c7faf3423da88 4
|
||||
loc 15df2860f80af8a3 4
|
||||
loc 1ad8497b17a82f52 0
|
||||
loc 2181f6ceb4c7477a 1
|
||||
loc 2289e12e42c4e944 4
|
||||
loc 24b76ca4826c5638 1
|
||||
loc 25124adbff4938f5 0
|
||||
loc 25a63fa5465bc5f3 50
|
||||
loc 2ee71f6c7bc945fc 0
|
||||
loc 31c8aa09bedd3a7b 17
|
||||
loc 3a9c2e28f2f4c971 6
|
||||
loc 3c3a9f754027034f 0
|
||||
loc 3c750cd9774511e1 14
|
||||
loc 3f321536f08a574b 2
|
||||
loc 43ae69eb99c246bd 72
|
||||
loc 451c71fe46a88a45 107
|
||||
loc 477fb0a95df4de78 3
|
||||
loc 481b17d410524bc9 0
|
||||
loc 48392508fbeb2328 3
|
||||
loc 4ab1da4efffdfe57 3
|
||||
loc 508c5e817af8a999 0
|
||||
loc 50a84d499bb42483 3
|
||||
loc 511a3773cc69cdce 1
|
||||
loc 51bd51b06f9d043a 3
|
||||
loc 51c2230f60e10fa9 0
|
||||
loc 5af4d7054c5559b6 6
|
||||
loc 5ecab546ba33bd53 6
|
||||
loc 5eed9706ed870021 18
|
||||
loc 63e5a5404234d3d8 17
|
||||
loc 64bb4422cbeb7ddf 11
|
||||
loc 661e27d15af79ad1 3
|
||||
loc 6c92d9507e31e28e 3
|
||||
loc 6e4a7b562b4f2b06 0
|
||||
loc 6f620455d93af4cc 9
|
||||
loc 70b5147ec4eb035e 0
|
||||
loc 713302e7d3a015bc 7
|
||||
loc 714bbb5988c06069 0
|
||||
loc 733de74921aafeaf 1
|
||||
loc 74a43eee461c4b92 4
|
||||
loc 7561ea47cdacbf1c 5
|
||||
loc 78443083bf63ce64 11
|
||||
loc 79f4173e0f2e51a7 48
|
||||
loc 7adc71b514f49b47 12
|
||||
loc 7f1b6e87b59ecd7b 38
|
||||
loc 8062378a693ebea7 4
|
||||
loc 82ad73bc67f7693d 0
|
||||
loc 8370dc3dadb2bcfb 0
|
||||
loc 8c58a47dcf04c64c 3
|
||||
loc 9001dcbc7e1d3aea 22
|
||||
loc 91c838f189efabf7 51
|
||||
loc 91ff00c34ace10ba 18
|
||||
loc 96a89f1e7280b8d7 0
|
||||
loc 9741c003a8a15ed9 4
|
||||
loc 9840a9660c6fdd94 1
|
||||
loc 9a70cba9cf1ebdf7 0
|
||||
loc 9be1d2f6ca24c2d7 17
|
||||
loc 9d734221e5c3e063 4
|
||||
loc a0df8b624fda7b0c 10
|
||||
loc a1675e0277a72e1a 0
|
||||
loc a3b73a9575dc4b25 19
|
||||
loc a78d827224d8bb28 0
|
||||
loc a7db39d64ea12c0a 73
|
||||
loc a8cb0d648705da0c 34
|
||||
loc a8cf87082209adfb 71
|
||||
loc aa886b4cd027024e 3
|
||||
loc ad3ee1fd5442beb3 14
|
||||
loc b1ed81d137c24b7c 11
|
||||
loc b6f11c20341391e5 17
|
||||
loc b7a6f2b228337846 51
|
||||
loc badcf9479458b20a 2
|
||||
loc c355cd8704e407dc 3
|
||||
loc c7a9af4537e2afe8 5
|
||||
loc cad731c520ec519b 1
|
||||
loc d0a38278d796bd04 3
|
||||
loc d15e9e9934588e30 36
|
||||
loc d33db8f2973d3fcb 33
|
||||
loc d3694276ef344923 3
|
||||
loc d3b2ed48be7d702c 63
|
||||
loc d3d444d665f32ea0 13
|
||||
loc d50883441c9933eb 3
|
||||
loc da2ac98964590fc4 4
|
||||
loc dac7c9f4b8bba74d 23
|
||||
loc de4ee76947538762 8
|
||||
loc e02c819d289fed29 1
|
||||
loc e04f204b5c54120d 0
|
||||
loc e110540af19ccc98 25
|
||||
loc e2b93e155905eae3 15
|
||||
loc e8e55e1817b8592c 4
|
||||
loc eaaa91d2cc464568 5
|
||||
loc eacb2d4d8d78b8db 72
|
||||
loc eb3b6f0f11d0f4b0 2
|
||||
loc ecd28a6977c13173 11
|
||||
loc ee35548d34836b29 7
|
||||
loc f216de2b471a4ff3 9
|
||||
loc f43ac65d42899c26 5
|
||||
loc f49dece5c6e17265 3
|
||||
loc f52a8fffd7d8e216 3
|
||||
loc f7d3bd419470ea93 1
|
||||
loc f91c0d240bc00c1e 22
|
||||
loc f95bf6daef6d17bf 1
|
||||
loc f98584b54995a292 0
|
||||
loc fef30218cd311413 1
|
||||
loc ffbe62bbe9c06ac6 72
|
||||
nest 1244c7c7cd1fd8b 0
|
||||
nest 1db52daaaf57a64 0
|
||||
nest 38182968886385e 0
|
||||
nest 55ba6bdcfffe904 3
|
||||
nest 7167d30674dbf63 1
|
||||
nest 8e89a6d1fe25686 1
|
||||
nest 152c7faf3423da88 0
|
||||
nest 15df2860f80af8a3 0
|
||||
nest 1ad8497b17a82f52 0
|
||||
nest 2181f6ceb4c7477a 0
|
||||
nest 2289e12e42c4e944 0
|
||||
nest 24b76ca4826c5638 0
|
||||
nest 25124adbff4938f5 0
|
||||
nest 25a63fa5465bc5f3 1
|
||||
nest 2ee71f6c7bc945fc 0
|
||||
nest 31c8aa09bedd3a7b 1
|
||||
nest 3a9c2e28f2f4c971 0
|
||||
nest 3c3a9f754027034f 0
|
||||
nest 3c750cd9774511e1 1
|
||||
nest 3f321536f08a574b 0
|
||||
nest 43ae69eb99c246bd 3
|
||||
nest 451c71fe46a88a45 1
|
||||
nest 477fb0a95df4de78 0
|
||||
nest 481b17d410524bc9 0
|
||||
nest 48392508fbeb2328 0
|
||||
nest 4ab1da4efffdfe57 0
|
||||
nest 508c5e817af8a999 0
|
||||
nest 50a84d499bb42483 0
|
||||
nest 511a3773cc69cdce 0
|
||||
nest 51bd51b06f9d043a 0
|
||||
nest 51c2230f60e10fa9 0
|
||||
nest 5af4d7054c5559b6 0
|
||||
nest 5ecab546ba33bd53 0
|
||||
nest 5eed9706ed870021 1
|
||||
nest 63e5a5404234d3d8 0
|
||||
nest 64bb4422cbeb7ddf 0
|
||||
nest 661e27d15af79ad1 0
|
||||
nest 6c92d9507e31e28e 0
|
||||
nest 6e4a7b562b4f2b06 0
|
||||
nest 6f620455d93af4cc 1
|
||||
nest 70b5147ec4eb035e 0
|
||||
nest 713302e7d3a015bc 0
|
||||
nest 714bbb5988c06069 0
|
||||
nest 733de74921aafeaf 0
|
||||
nest 74a43eee461c4b92 0
|
||||
nest 7561ea47cdacbf1c 0
|
||||
nest 78443083bf63ce64 0
|
||||
nest 79f4173e0f2e51a7 2
|
||||
nest 7adc71b514f49b47 0
|
||||
nest 7f1b6e87b59ecd7b 1
|
||||
nest 8062378a693ebea7 0
|
||||
nest 82ad73bc67f7693d 0
|
||||
nest 8370dc3dadb2bcfb 0
|
||||
nest 8c58a47dcf04c64c 0
|
||||
nest 9001dcbc7e1d3aea 0
|
||||
nest 91c838f189efabf7 1
|
||||
nest 91ff00c34ace10ba 1
|
||||
nest 96a89f1e7280b8d7 0
|
||||
nest 9741c003a8a15ed9 0
|
||||
nest 9840a9660c6fdd94 0
|
||||
nest 9a70cba9cf1ebdf7 0
|
||||
nest 9be1d2f6ca24c2d7 0
|
||||
nest 9d734221e5c3e063 0
|
||||
nest a0df8b624fda7b0c 1
|
||||
nest a1675e0277a72e1a 0
|
||||
nest a3b73a9575dc4b25 1
|
||||
nest a78d827224d8bb28 0
|
||||
nest a7db39d64ea12c0a 2
|
||||
nest a8cb0d648705da0c 1
|
||||
nest a8cf87082209adfb 2
|
||||
nest aa886b4cd027024e 0
|
||||
nest ad3ee1fd5442beb3 0
|
||||
nest b1ed81d137c24b7c 0
|
||||
nest b6f11c20341391e5 0
|
||||
nest b7a6f2b228337846 1
|
||||
nest badcf9479458b20a 0
|
||||
nest c355cd8704e407dc 0
|
||||
nest c7a9af4537e2afe8 0
|
||||
nest cad731c520ec519b 0
|
||||
nest d0a38278d796bd04 0
|
||||
nest d15e9e9934588e30 1
|
||||
nest d33db8f2973d3fcb 0
|
||||
nest d3694276ef344923 0
|
||||
nest d3b2ed48be7d702c 0
|
||||
nest d3d444d665f32ea0 1
|
||||
nest d50883441c9933eb 0
|
||||
nest da2ac98964590fc4 0
|
||||
nest dac7c9f4b8bba74d 0
|
||||
nest de4ee76947538762 0
|
||||
nest e02c819d289fed29 0
|
||||
nest e04f204b5c54120d 0
|
||||
nest e110540af19ccc98 1
|
||||
nest e2b93e155905eae3 1
|
||||
nest e8e55e1817b8592c 0
|
||||
nest eaaa91d2cc464568 0
|
||||
nest eacb2d4d8d78b8db 1
|
||||
nest eb3b6f0f11d0f4b0 0
|
||||
nest ecd28a6977c13173 0
|
||||
nest ee35548d34836b29 0
|
||||
nest f216de2b471a4ff3 1
|
||||
nest f43ac65d42899c26 0
|
||||
nest f49dece5c6e17265 0
|
||||
nest f52a8fffd7d8e216 0
|
||||
nest f7d3bd419470ea93 0
|
||||
nest f91c0d240bc00c1e 0
|
||||
nest f95bf6daef6d17bf 0
|
||||
nest f98584b54995a292 0
|
||||
nest fef30218cd311413 0
|
||||
nest ffbe62bbe9c06ac6 2
|
||||
params 1244c7c7cd1fd8b 1
|
||||
params 1db52daaaf57a64 0
|
||||
params 38182968886385e 0
|
||||
params 55ba6bdcfffe904 1
|
||||
params 7167d30674dbf63 0
|
||||
params 8e89a6d1fe25686 0
|
||||
params 152c7faf3423da88 0
|
||||
params 15df2860f80af8a3 2
|
||||
params 1ad8497b17a82f52 0
|
||||
params 2181f6ceb4c7477a 0
|
||||
params 2289e12e42c4e944 0
|
||||
params 24b76ca4826c5638 0
|
||||
params 25124adbff4938f5 0
|
||||
params 25a63fa5465bc5f3 0
|
||||
params 2ee71f6c7bc945fc 0
|
||||
params 31c8aa09bedd3a7b 0
|
||||
params 3a9c2e28f2f4c971 0
|
||||
params 3c3a9f754027034f 0
|
||||
params 3c750cd9774511e1 1
|
||||
params 3f321536f08a574b 1
|
||||
params 43ae69eb99c246bd 0
|
||||
params 451c71fe46a88a45 1
|
||||
params 477fb0a95df4de78 0
|
||||
params 481b17d410524bc9 0
|
||||
params 48392508fbeb2328 1
|
||||
params 4ab1da4efffdfe57 1
|
||||
params 508c5e817af8a999 0
|
||||
params 50a84d499bb42483 1
|
||||
params 511a3773cc69cdce 0
|
||||
params 51bd51b06f9d043a 0
|
||||
params 51c2230f60e10fa9 0
|
||||
params 5af4d7054c5559b6 0
|
||||
params 5ecab546ba33bd53 1
|
||||
params 5eed9706ed870021 0
|
||||
params 63e5a5404234d3d8 1
|
||||
params 64bb4422cbeb7ddf 0
|
||||
params 661e27d15af79ad1 0
|
||||
params 6c92d9507e31e28e 0
|
||||
params 6e4a7b562b4f2b06 0
|
||||
params 6f620455d93af4cc 1
|
||||
params 70b5147ec4eb035e 0
|
||||
params 713302e7d3a015bc 1
|
||||
params 714bbb5988c06069 0
|
||||
params 733de74921aafeaf 0
|
||||
params 74a43eee461c4b92 1
|
||||
params 7561ea47cdacbf1c 1
|
||||
params 78443083bf63ce64 0
|
||||
params 79f4173e0f2e51a7 2
|
||||
params 7adc71b514f49b47 0
|
||||
params 7f1b6e87b59ecd7b 0
|
||||
params 8062378a693ebea7 0
|
||||
params 82ad73bc67f7693d 0
|
||||
params 8370dc3dadb2bcfb 0
|
||||
params 8c58a47dcf04c64c 1
|
||||
params 9001dcbc7e1d3aea 1
|
||||
params 91c838f189efabf7 1
|
||||
params 91ff00c34ace10ba 1
|
||||
params 96a89f1e7280b8d7 0
|
||||
params 9741c003a8a15ed9 1
|
||||
params 9840a9660c6fdd94 0
|
||||
params 9a70cba9cf1ebdf7 0
|
||||
params 9be1d2f6ca24c2d7 0
|
||||
params 9d734221e5c3e063 0
|
||||
params a0df8b624fda7b0c 1
|
||||
params a1675e0277a72e1a 0
|
||||
params a3b73a9575dc4b25 0
|
||||
params a78d827224d8bb28 0
|
||||
params a7db39d64ea12c0a 0
|
||||
params a8cb0d648705da0c 0
|
||||
params a8cf87082209adfb 1
|
||||
params aa886b4cd027024e 0
|
||||
params ad3ee1fd5442beb3 1
|
||||
params b1ed81d137c24b7c 1
|
||||
params b6f11c20341391e5 1
|
||||
params b7a6f2b228337846 1
|
||||
params badcf9479458b20a 0
|
||||
params c355cd8704e407dc 1
|
||||
params c7a9af4537e2afe8 3
|
||||
params cad731c520ec519b 0
|
||||
params d0a38278d796bd04 1
|
||||
params d15e9e9934588e30 0
|
||||
params d33db8f2973d3fcb 1
|
||||
params d3694276ef344923 0
|
||||
params d3b2ed48be7d702c 1
|
||||
params d3d444d665f32ea0 1
|
||||
params d50883441c9933eb 1
|
||||
params da2ac98964590fc4 2
|
||||
params dac7c9f4b8bba74d 0
|
||||
params de4ee76947538762 0
|
||||
params e02c819d289fed29 0
|
||||
params e04f204b5c54120d 0
|
||||
params e110540af19ccc98 1
|
||||
params e2b93e155905eae3 1
|
||||
params e8e55e1817b8592c 0
|
||||
params eaaa91d2cc464568 3
|
||||
params eacb2d4d8d78b8db 0
|
||||
params eb3b6f0f11d0f4b0 0
|
||||
params ecd28a6977c13173 1
|
||||
params ee35548d34836b29 1
|
||||
params f216de2b471a4ff3 1
|
||||
params f43ac65d42899c26 0
|
||||
params f49dece5c6e17265 1
|
||||
params f52a8fffd7d8e216 1
|
||||
params f7d3bd419470ea93 0
|
||||
params f91c0d240bc00c1e 0
|
||||
params f95bf6daef6d17bf 0
|
||||
params f98584b54995a292 0
|
||||
params fef30218cd311413 0
|
||||
params ffbe62bbe9c06ac6 0
|
||||
defs 1244c7c7cd1fd8b 1
|
||||
defs 1db52daaaf57a64 1
|
||||
defs 38182968886385e 1
|
||||
defs 55ba6bdcfffe904 1
|
||||
defs 7167d30674dbf63 1
|
||||
defs 8e89a6d1fe25686 1
|
||||
defs 152c7faf3423da88 1
|
||||
defs 15df2860f80af8a3 1
|
||||
defs 1ad8497b17a82f52 1
|
||||
defs 2181f6ceb4c7477a 1
|
||||
defs 2289e12e42c4e944 1
|
||||
defs 24b76ca4826c5638 1
|
||||
defs 25124adbff4938f5 1
|
||||
defs 25a63fa5465bc5f3 1
|
||||
defs 2ee71f6c7bc945fc 1
|
||||
defs 31c8aa09bedd3a7b 1
|
||||
defs 3a9c2e28f2f4c971 1
|
||||
defs 3c3a9f754027034f 1
|
||||
defs 3c750cd9774511e1 1
|
||||
defs 3f321536f08a574b 1
|
||||
defs 43ae69eb99c246bd 1
|
||||
defs 451c71fe46a88a45 1
|
||||
defs 477fb0a95df4de78 1
|
||||
defs 481b17d410524bc9 1
|
||||
defs 48392508fbeb2328 1
|
||||
defs 4ab1da4efffdfe57 1
|
||||
defs 508c5e817af8a999 1
|
||||
defs 50a84d499bb42483 1
|
||||
defs 511a3773cc69cdce 1
|
||||
defs 51bd51b06f9d043a 1
|
||||
defs 51c2230f60e10fa9 1
|
||||
defs 5af4d7054c5559b6 1
|
||||
defs 5ecab546ba33bd53 1
|
||||
defs 5eed9706ed870021 1
|
||||
defs 63e5a5404234d3d8 1
|
||||
defs 64bb4422cbeb7ddf 1
|
||||
defs 661e27d15af79ad1 1
|
||||
defs 6c92d9507e31e28e 1
|
||||
defs 6e4a7b562b4f2b06 1
|
||||
defs 6f620455d93af4cc 1
|
||||
defs 70b5147ec4eb035e 1
|
||||
defs 713302e7d3a015bc 1
|
||||
defs 714bbb5988c06069 1
|
||||
defs 733de74921aafeaf 1
|
||||
defs 74a43eee461c4b92 1
|
||||
defs 7561ea47cdacbf1c 1
|
||||
defs 78443083bf63ce64 1
|
||||
defs 79f4173e0f2e51a7 1
|
||||
defs 7adc71b514f49b47 1
|
||||
defs 7f1b6e87b59ecd7b 1
|
||||
defs 8062378a693ebea7 1
|
||||
defs 82ad73bc67f7693d 1
|
||||
defs 8370dc3dadb2bcfb 1
|
||||
defs 8c58a47dcf04c64c 1
|
||||
defs 9001dcbc7e1d3aea 1
|
||||
defs 91c838f189efabf7 1
|
||||
defs 91ff00c34ace10ba 1
|
||||
defs 96a89f1e7280b8d7 1
|
||||
defs 9741c003a8a15ed9 1
|
||||
defs 9840a9660c6fdd94 1
|
||||
defs 9a70cba9cf1ebdf7 1
|
||||
defs 9be1d2f6ca24c2d7 1
|
||||
defs 9d734221e5c3e063 1
|
||||
defs a0df8b624fda7b0c 1
|
||||
defs a1675e0277a72e1a 1
|
||||
defs a3b73a9575dc4b25 1
|
||||
defs a78d827224d8bb28 1
|
||||
defs a7db39d64ea12c0a 1
|
||||
defs a8cb0d648705da0c 1
|
||||
defs a8cf87082209adfb 1
|
||||
defs aa886b4cd027024e 1
|
||||
defs ad3ee1fd5442beb3 1
|
||||
defs b1ed81d137c24b7c 1
|
||||
defs b6f11c20341391e5 1
|
||||
defs b7a6f2b228337846 1
|
||||
defs badcf9479458b20a 1
|
||||
defs c355cd8704e407dc 1
|
||||
defs c7a9af4537e2afe8 1
|
||||
defs cad731c520ec519b 1
|
||||
defs d0a38278d796bd04 1
|
||||
defs d15e9e9934588e30 1
|
||||
defs d33db8f2973d3fcb 1
|
||||
defs d3694276ef344923 1
|
||||
defs d3b2ed48be7d702c 1
|
||||
defs d3d444d665f32ea0 1
|
||||
defs d50883441c9933eb 1
|
||||
defs da2ac98964590fc4 1
|
||||
defs dac7c9f4b8bba74d 1
|
||||
defs de4ee76947538762 1
|
||||
defs e02c819d289fed29 1
|
||||
defs e04f204b5c54120d 1
|
||||
defs e110540af19ccc98 1
|
||||
defs e2b93e155905eae3 1
|
||||
defs e8e55e1817b8592c 1
|
||||
defs eaaa91d2cc464568 1
|
||||
defs eacb2d4d8d78b8db 1
|
||||
defs eb3b6f0f11d0f4b0 1
|
||||
defs ecd28a6977c13173 1
|
||||
defs ee35548d34836b29 1
|
||||
defs f216de2b471a4ff3 1
|
||||
defs f43ac65d42899c26 1
|
||||
defs f49dece5c6e17265 1
|
||||
defs f52a8fffd7d8e216 1
|
||||
defs f7d3bd419470ea93 1
|
||||
defs f91c0d240bc00c1e 1
|
||||
defs f95bf6daef6d17bf 1
|
||||
defs f98584b54995a292 1
|
||||
defs fef30218cd311413 1
|
||||
defs ffbe62bbe9c06ac6 1
|
||||
bodyq 1244c7c7cd1fd8b e26887357fa09102
|
||||
bodyq 1db52daaaf57a64 8ce7c1546756e1f0
|
||||
bodyq 55ba6bdcfffe904 7f0f2490aaf04e07
|
||||
bodyq 7167d30674dbf63 ff63be69db4f64ff
|
||||
bodyq 8e89a6d1fe25686 feb0ccbf27cbdbf4
|
||||
bodyq 152c7faf3423da88 b032d86fef7eb064
|
||||
bodyq 15df2860f80af8a3 34a852a1491e85d9
|
||||
bodyq 2181f6ceb4c7477a 46389a31982e7d15
|
||||
bodyq 2289e12e42c4e944 111649c04468a86b
|
||||
bodyq 24b76ca4826c5638 321c0ca65c9010fa
|
||||
bodyq 25a63fa5465bc5f3 39faccd5e17cf383
|
||||
bodyq 31c8aa09bedd3a7b 1a054a978abe936c
|
||||
bodyq 3a9c2e28f2f4c971 4a49b10c532c65a0
|
||||
bodyq 3c750cd9774511e1 5f1ad81db3e77281
|
||||
bodyq 3f321536f08a574b 2577d7c2d8063ad1
|
||||
bodyq 43ae69eb99c246bd f381548b471cb860
|
||||
bodyq 451c71fe46a88a45 1e91a6acc1fad0d7
|
||||
bodyq 477fb0a95df4de78 5d9659c99dc8cc5b
|
||||
bodyq 48392508fbeb2328 8f5798b33feef60e
|
||||
bodyq 4ab1da4efffdfe57 7382d0f758f6aa9f
|
||||
bodyq 50a84d499bb42483 fc238e94c39e11b1
|
||||
bodyq 511a3773cc69cdce 9415491cad0254c3
|
||||
bodyq 51bd51b06f9d043a 88a7064d051fdc8b
|
||||
bodyq 5af4d7054c5559b6 6f8d8c8075859cb8
|
||||
bodyq 5ecab546ba33bd53 14246694a833bad4
|
||||
bodyq 5eed9706ed870021 504db582c21ac412
|
||||
bodyq 63e5a5404234d3d8 7d45864bd768a086
|
||||
bodyq 64bb4422cbeb7ddf af9eeddc1a88bdc2
|
||||
bodyq 661e27d15af79ad1 aaa075efe60b8f5b
|
||||
bodyq 6c92d9507e31e28e c57242bfe26b9624
|
||||
bodyq 6f620455d93af4cc f4b52a849cceabe0
|
||||
bodyq 713302e7d3a015bc 94ac163290e698b4
|
||||
bodyq 733de74921aafeaf 767e8f1963349d4d
|
||||
bodyq 74a43eee461c4b92 e26887357fa09102
|
||||
bodyq 7561ea47cdacbf1c e60adbac84a25885
|
||||
bodyq 78443083bf63ce64 d138f0f6195c082c
|
||||
bodyq 79f4173e0f2e51a7 6ee82b7051fd6140
|
||||
bodyq 7adc71b514f49b47 4644245515fce582
|
||||
bodyq 7f1b6e87b59ecd7b 1e237db69e6530af
|
||||
bodyq 8062378a693ebea7 374aae6f839b1122
|
||||
bodyq 8c58a47dcf04c64c 2ed4694263351eea
|
||||
bodyq 9001dcbc7e1d3aea 8702f9a8349272e8
|
||||
bodyq 91c838f189efabf7 60cffe2fc8367864
|
||||
bodyq 91ff00c34ace10ba 400458fbd94fc337
|
||||
bodyq 9741c003a8a15ed9 5e76785cab558311
|
||||
bodyq 9840a9660c6fdd94 aa5fa526d758e274
|
||||
bodyq 9be1d2f6ca24c2d7 9e22fa659ba05980
|
||||
bodyq 9d734221e5c3e063 fc26552103f132e4
|
||||
bodyq a0df8b624fda7b0c d152b1c63048ca37
|
||||
bodyq a3b73a9575dc4b25 85c50c6e751b0773
|
||||
bodyq a7db39d64ea12c0a 76e57b785a3f95aa
|
||||
bodyq a8cb0d648705da0c e5e460a32fe5d819
|
||||
bodyq a8cf87082209adfb 11ae8f8fe1a6397b
|
||||
bodyq aa886b4cd027024e 5a7e375974a59b68
|
||||
bodyq ad3ee1fd5442beb3 58191bcd5905db2c
|
||||
bodyq b1ed81d137c24b7c 9322ff54162db108
|
||||
bodyq b6f11c20341391e5 d4fb03c3f5db0ad
|
||||
bodyq b7a6f2b228337846 9aacf2dc55bfb26f
|
||||
bodyq badcf9479458b20a fec68801d037553a
|
||||
bodyq c355cd8704e407dc a1c10b79b98bb44b
|
||||
bodyq c7a9af4537e2afe8 3939b37bd6ea6db1
|
||||
bodyq cad731c520ec519b 81d2b068737213d1
|
||||
bodyq d0a38278d796bd04 f2ceb626b3b60175
|
||||
bodyq d15e9e9934588e30 f51cc355368c8777
|
||||
bodyq d33db8f2973d3fcb 219134803ac5c1b
|
||||
bodyq d3694276ef344923 5d9659c99dc8cc5b
|
||||
bodyq d3b2ed48be7d702c 94f123c7525ea904
|
||||
bodyq d3d444d665f32ea0 723746b9230079fd
|
||||
bodyq d50883441c9933eb 4a49f90d19857418
|
||||
bodyq da2ac98964590fc4 d136f1f72d7da9b5
|
||||
bodyq dac7c9f4b8bba74d 68898e3ae1f27228
|
||||
bodyq de4ee76947538762 739c3779ad8edad1
|
||||
bodyq e02c819d289fed29 42a0f4bbf7a0b9ad
|
||||
bodyq e110540af19ccc98 1a624f2130189aae
|
||||
bodyq e2b93e155905eae3 b426a36358a21b79
|
||||
bodyq e8e55e1817b8592c 76936405e47611eb
|
||||
bodyq eaaa91d2cc464568 3939b37bd6ea6db1
|
||||
bodyq eacb2d4d8d78b8db 9ab81f1994f6c5c9
|
||||
bodyq eb3b6f0f11d0f4b0 dff15f4bb7e1e7cf
|
||||
bodyq ecd28a6977c13173 29f31e6466372f5b
|
||||
bodyq ee35548d34836b29 8ad0538b501ed4b4
|
||||
bodyq f216de2b471a4ff3 ed1ce9d21840a914
|
||||
bodyq f43ac65d42899c26 61e83238d840f1a4
|
||||
bodyq f49dece5c6e17265 bd8094c4a1fbb3aa
|
||||
bodyq f52a8fffd7d8e216 bd8094c4a1fbb3aa
|
||||
bodyq f7d3bd419470ea93 e302db95e6c70529
|
||||
bodyq f91c0d240bc00c1e 55a798c8d771b371
|
||||
bodyq f95bf6daef6d17bf c0e34a2d7f917454
|
||||
bodyq fef30218cd311413 68107fcdba1d4e69
|
||||
bodyq ffbe62bbe9c06ac6 78152c6b24bcba52
|
||||
clone 3c5626fcf2195ac5
|
||||
dead 3f321536f08a574b
|
||||
dead 4ab1da4efffdfe57
|
||||
dead 6f620455d93af4cc
|
||||
dead 8c58a47dcf04c64c
|
||||
dead a0df8b624fda7b0c
|
||||
dead ad3ee1fd5442beb3
|
||||
dead b1ed81d137c24b7c
|
||||
dead b6f11c20341391e5
|
||||
dead d3b2ed48be7d702c
|
||||
dead e110540af19ccc98
|
||||
dead ecd28a6977c13173
|
||||
dead f49dece5c6e17265
|
||||
dead f52a8fffd7d8e216
|
||||
@@ -1,37 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import ProductCategoryBox from '@/app/components/layout/shop/products-cat'
|
||||
import Image from 'next/image'
|
||||
import axiosInstance from '@/axios/axiosInstance'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery,useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
const Page = ()=>{
|
||||
|
||||
const [category, setCategory] = useState([])
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(()=>{
|
||||
|
||||
},[])
|
||||
|
||||
const query = useQuery({ queryKey: ['categoryProducts'], queryFn: async ()=>{
|
||||
|
||||
|
||||
return await axiosInstance.get("shop/v2/product-category/list/")
|
||||
},refetchOnWindowFocus:false, onSuccess(result:any) {
|
||||
},refetchOnWindowFocus:true, })
|
||||
|
||||
setCategory(result.data)
|
||||
|
||||
setTimeout(async ()=>{
|
||||
const scrollpos = localStorage.getItem('scrollY')
|
||||
if (scrollpos){
|
||||
await window.scrollTo(0, parseInt(scrollpos));
|
||||
const category = query.data?.data ?? []
|
||||
useEffect(() => {
|
||||
if (!query.isSuccess) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
try {
|
||||
const position = Number(localStorage.getItem('scrollY'))
|
||||
if (Number.isFinite(position) && position > 0) window.scrollTo(0, position)
|
||||
} catch {
|
||||
console.warn('Could not restore the saved shop scroll position.')
|
||||
}
|
||||
|
||||
},150)
|
||||
|
||||
}, })
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [query.isSuccess])
|
||||
|
||||
if(query.isLoading){
|
||||
return (
|
||||
@@ -53,10 +46,10 @@ const Page = ()=>{
|
||||
{query.isSuccess?category.map((iteam:any)=>{
|
||||
return <ProductCategoryBox key={iteam.id} iteam={iteam} />
|
||||
}):null}
|
||||
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Page
|
||||
export default Page
|
||||
@@ -1,11 +1,9 @@
|
||||
'use client'
|
||||
import Products from '@/app/components/layout/shop/products'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import axiosInstance from '@/axios/axiosInstance'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -21,16 +19,13 @@ const Page = ({ params }: { params: { id: string } })=>{
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [products, setProducts] = useState<{category_name: string, category_img: string,
|
||||
products:[{id:number, name:string, price: number, view_only: boolean}],
|
||||
view_only_products:[{id:number, name:string, price: number, view_only: boolean}]
|
||||
}>()
|
||||
|
||||
const query = useQuery({ queryKey: ['productListByCat'], queryFn: async ()=>{
|
||||
return await axiosInstance.get(`shop/product/list/${params.id}/`)
|
||||
}, onSuccess(result:any) {
|
||||
setProducts(result.data)
|
||||
const query = useQuery({ queryKey: ['productListByCat', params.id], queryFn: async ()=>{
|
||||
return await axiosInstance.get<{category_name: string, category_img: string | null,
|
||||
products: {id:number, name:string, price:number, view_only:boolean}[],
|
||||
view_only_products: {id:number, name:string, price:number, view_only:boolean}[] | null
|
||||
}>(`shop/product/list/${params.id}/`)
|
||||
}, });
|
||||
const products = query.data?.data
|
||||
|
||||
if(query.isLoading){
|
||||
return (
|
||||
@@ -57,7 +52,7 @@ const Page = ({ params }: { params: { id: string } })=>{
|
||||
|
||||
|
||||
<div className='disable-select w-full'>
|
||||
<Image className='disable-select mx-auto' alt="" src={`https://loolegostar-api.liara.run/${products?.category_img}`} height={150} width={150} />
|
||||
{products?.category_img && <Image className="disable-select mx-auto" alt={products.category_name} src={`${process.env.NEXT_PUBLIC_API_URL}/${products.category_img}`} height={150} width={150} />}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-2 mt-2'>
|
||||
@@ -115,4 +110,4 @@ const Page = ({ params }: { params: { id: string } })=>{
|
||||
|
||||
}
|
||||
|
||||
export default Page
|
||||
export default Page
|
||||
@@ -1,75 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment, useState } from "react"
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { addMobile } from "@/app/redux/features/mobileoptSlice"
|
||||
import CustomerLoginForm from '@/components/auth/customer-login-form'
|
||||
|
||||
|
||||
const Page = ()=>{
|
||||
const [username, setUserName] = useState()
|
||||
const [password, setPassword] = useState()
|
||||
|
||||
const router = useRouter()
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const onChangeMobile = (e:any)=>{
|
||||
setUserName(e.target.value)
|
||||
}
|
||||
|
||||
const login = async ()=>{
|
||||
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/login/mobile/`, {
|
||||
method: "POST",
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mobile: username
|
||||
})
|
||||
},)
|
||||
|
||||
console.log(res)
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
console.log(data)
|
||||
|
||||
dispatch( addMobile(data.result.mobile) )
|
||||
|
||||
if (data.result.message == 'opt code send'){
|
||||
router.push('/login/verify/')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="mx-auto w-full sm:w-[30rem] mt-[-10rem]">
|
||||
<div className="flex flex-col gap-y-2 pr-8 pl-8">
|
||||
|
||||
<h1 className="text-center border-b-2 border-black pb-4 text-2xl">ورود به سامانه مشتریان</h1>
|
||||
|
||||
<div className="mt-5">
|
||||
<label for="UserEmail" class="block text-md font-medium text-gray-700">
|
||||
موبایل
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="email"
|
||||
id="UserEmail"
|
||||
placeholder="شماره موبایل را وارد کنید"
|
||||
onChange={(e)=>{ onChangeMobile(e) }}
|
||||
className="mt-1 w-full h-[45px] p-5 rounded-md border-gray-200 shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input onClick={()=>{ login() }} type="submit" className="bg-slate-900 text-white rounded p-2" value={"ورود"} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
export default function Page() {
|
||||
return <CustomerLoginForm />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -1,125 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment, useState } from "react"
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { RootState } from "@/app/redux/store"
|
||||
import { addUuidOpt } from "@/app/redux/features/mobileoptSlice"
|
||||
import { useRouter } from "next/navigation"
|
||||
import CustomerLoginForm from '@/components/auth/customer-login-form'
|
||||
|
||||
function setCookieFn(name:string, value:string, daysToExpire:number) {
|
||||
const expires = new Date();
|
||||
expires.setTime(expires.getTime() + (daysToExpire * 24 * 60 * 60 * 1000));
|
||||
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
|
||||
export default function Page() {
|
||||
return <CustomerLoginForm initialStep="verify" />
|
||||
}
|
||||
|
||||
const Page = ()=>{
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const [optCode, setOptCode]: [any, any] = useState();
|
||||
|
||||
const mobileOpt = useSelector((state: RootState)=> state.mobileopt.mobile )
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const inputOpt = (event: any)=>{
|
||||
setOptCode(event.target.value)
|
||||
}
|
||||
|
||||
const veifyLogin = async (optcode: string)=>{
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/login/mobile/verify/`, {
|
||||
cache: 'no-store',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mobile: mobileOpt,
|
||||
opt: optCode
|
||||
})
|
||||
})
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
console.log(data)
|
||||
|
||||
if ('error' in data){
|
||||
alert("خطا در کد تایید")
|
||||
router.push('/login/')
|
||||
|
||||
} else {
|
||||
const roles: Array<{cuser_role:any, expire: any, uuid: string}> = await data
|
||||
|
||||
if (roles.length==1) {
|
||||
const uuidOpt = await roles[0].uuid
|
||||
|
||||
const getTokenRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/token/`, {
|
||||
cache: 'no-store',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
uuid_opt: uuidOpt,
|
||||
})
|
||||
})
|
||||
|
||||
const tokenData = await getTokenRes.json();
|
||||
|
||||
|
||||
if('refresh' in tokenData){
|
||||
setCookieFn('isAuth', 'true', 365)
|
||||
localStorage.setItem('access', tokenData.access)
|
||||
localStorage.setItem('refresh', tokenData.refresh)
|
||||
|
||||
router.push("/shop")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//await dispatch(addUuidOpt(uuid_opt))
|
||||
|
||||
/*const getTokenRes = await fetch('http://localhost:3000/api/auth/token/', {
|
||||
cache: 'no-store',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
uuid_opt: uuid_opt,
|
||||
})
|
||||
})*/
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
return(
|
||||
<>
|
||||
<div className="mx-auto w-full sm:w-[30rem] mt-[-10rem]">
|
||||
<div className="flex flex-col gap-y-2 pr-8 pl-8">
|
||||
|
||||
<h1 className="text-center border-b-2 border-black pb-4 text-2xl">ورود به سامانه مشتریان</h1>
|
||||
<p className="text-center text-red-600">
|
||||
کد تایید پیامک شده را وارد کنید
|
||||
</p>
|
||||
<div className="mt-5">
|
||||
|
||||
<input
|
||||
type="email"
|
||||
id="UserEmail"
|
||||
onChange={(event)=>{ inputOpt(event) }}
|
||||
placeholder="کد تایید را وارد کنید"
|
||||
className="mt-1 w-full h-[45px] p-5 rounded-md border-gray-200 shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input onClick={()=>{ veifyLogin(optCode) }} type="submit" className="bg-slate-900 text-white rounded p-2" value={"ورود به سامانه"} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -1,35 +1,31 @@
|
||||
import axiosInstance from "@/axios/axiosInstance"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRef, useState } from "react"
|
||||
import { logout } from '@/lib/auth'
|
||||
|
||||
const CustomerInfo = ()=>{
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [nameFamily, setNameFamily] = useState<string | null>(null)
|
||||
const [leaving, setLeaving] = useState(false)
|
||||
const logoutStarted = useRef(false)
|
||||
|
||||
const onLogout = ()=>{
|
||||
localStorage.removeItem('access');
|
||||
localStorage.removeItem('refresh');
|
||||
document.cookie = "isAuth=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
setTimeout(()=>{
|
||||
router.push('/login')
|
||||
}, 200)
|
||||
const onLogout = () => {
|
||||
if (logoutStarted.current) return
|
||||
logoutStarted.current = true
|
||||
setLeaving(true)
|
||||
void logout()
|
||||
}
|
||||
|
||||
const query = useQuery({ queryKey: ['cuser-info-x1'], queryFn: async ()=>{
|
||||
return await axiosInstance.get("cuser/info/")
|
||||
},refetchOnWindowFocus: false,onSuccess(result:any) {
|
||||
setNameFamily(result.data.name_family)
|
||||
}, })
|
||||
}, retry: false, staleTime: 30000, refetchOnWindowFocus: false })
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-x-2">
|
||||
<div className="disable-select">{nameFamily}</div>
|
||||
<div onClick={onLogout} className="disable-select bg-slate-950 rounded-md pr-1 pl-1 cursor-pointer">خروج</div>
|
||||
<div className="disable-select">{query.data?.data.name_family}</div>
|
||||
<button type="button" disabled={leaving} onClick={onLogout} className="disable-select bg-slate-950 rounded-md pr-1 pl-1 cursor-pointer disabled:opacity-50">{leaving ? 'در حال خروج…' : 'خروج'}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomerInfo
|
||||
export default CustomerInfo
|
||||
@@ -1,38 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import ReduxProvider from "@/app/redux/provider"
|
||||
import HamberMenu from "./hamber-menu"
|
||||
import ReactQueryProvider from "@/app/react_query/provider"
|
||||
import ReduxProvider from '@/app/redux/provider'
|
||||
import ReactQueryProvider from '@/app/react_query/provider'
|
||||
import SessionGuard from '@/components/auth/session-guard'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import CustomerInfo from "./customer-info"
|
||||
import HamberMenu from './hamber-menu'
|
||||
import CustomerInfo from './customer-info'
|
||||
|
||||
|
||||
const MainLayoutUtil = ({children,}: {children: React.ReactNode})=>{
|
||||
const MainLayoutUtil = ({ children }: { children: React.ReactNode }) => {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<div className={`basis-full md:mr-[8.9rem] ${pathname.startsWith('/shop') ? 'lg:ml-[17rem]': ''} `}>
|
||||
|
||||
return <div className={`basis-full md:mr-[8.9rem] ${pathname.startsWith('/shop') ? 'lg:ml-[17rem]' : ''}`}>
|
||||
<ReduxProvider>
|
||||
<ReactQueryProvider>
|
||||
<div className='w-full h-[2.5rem] flex flex-row bg-gray-800 p-2 text-white'>
|
||||
|
||||
<HamberMenu />
|
||||
|
||||
<div className='pr-4'>
|
||||
<CustomerInfo />
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
</ReactQueryProvider>
|
||||
<ReactQueryProvider>
|
||||
<SessionGuard>
|
||||
<div className="w-full h-[2.5rem] flex flex-row bg-gray-800 p-2 text-white">
|
||||
<HamberMenu />
|
||||
<div className="pr-4"><CustomerInfo /></div>
|
||||
</div>
|
||||
{children}
|
||||
</SessionGuard>
|
||||
</ReactQueryProvider>
|
||||
</ReduxProvider>
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
export default MainLayoutUtil
|
||||
export default MainLayoutUtil
|
||||
@@ -10,7 +10,7 @@ const ProductCategoryBox: any = (props:any)=>{
|
||||
|
||||
const scrollPy = window.scrollY
|
||||
setTimeout(()=>{
|
||||
localStorage.setItem("scrollY", scrollPy)
|
||||
localStorage.setItem("scrollY", String(scrollPy))
|
||||
}, 10)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const ProductCategoryBox: any = (props:any)=>{
|
||||
<div className="flex flex-col bg-white text-center rounded overflow-hidden text-white border-solid border-[1px] border-slate-700">
|
||||
<h2 className="bg-slate-600 mb-2 disable-select">{props.iteam.name}</h2>
|
||||
|
||||
<Image loader={imageLoader} className="disable-select p-1 mx-auto" alt="a" width={"200"} height={"200"} src={`${process.env.NEXT_PUBLIC_API_URL}/${props.iteam.image}`} />
|
||||
{props.iteam.image ? <Image loader={imageLoader} className="disable-select p-1 mx-auto" alt="a" width={"200"} height={"200"} src={`${process.env.NEXT_PUBLIC_API_URL}/${props.iteam.image}`} /> : <div className="h-[200px] flex items-center justify-center text-slate-500">بدون تصویر</div>}
|
||||
|
||||
<div className="flex justify-between text-black border-t-[1px] mt-2 p-1">
|
||||
|
||||
|
||||
+110
-75
@@ -1,88 +1,123 @@
|
||||
'use client'
|
||||
|
||||
import axios from 'axios'
|
||||
import { error } from 'console';
|
||||
import { cookies, headers } from 'next/headers'
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import { expireSession, getAccessToken, getRefreshToken, getSessionIdentity, saveTokens, validTokens } from '@/lib/auth'
|
||||
|
||||
//let accessToken = localStorage.getItem('access') ? localStorage.getItem('access') : null
|
||||
function setCookieFn(name:string, value:string, daysToExpire:number) {
|
||||
const expires = new Date();
|
||||
expires.setTime(expires.getTime() + (daysToExpire * 24 * 60 * 60 * 1000));
|
||||
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
|
||||
type RetryRequest = InternalAxiosRequestConfig & { _retry?: boolean; _sessionIdentity?: string }
|
||||
class InvalidSession extends Error {}
|
||||
let pendingRefresh: { identity: string; promise: Promise<string> } | null = null
|
||||
const leaseKey = 'customerRefreshLease'
|
||||
type RefreshLease = { owner: string; identity: string; expires: number }
|
||||
|
||||
function readLease(): RefreshLease | null {
|
||||
try {
|
||||
const value = JSON.parse(window.localStorage.getItem(leaseKey) || 'null')
|
||||
return value && typeof value.owner === 'string' && typeof value.identity === 'string' &&
|
||||
typeof value.expires === 'number' && Number.isFinite(value.expires) ? value : null
|
||||
} catch { return null }
|
||||
}
|
||||
async function withRefreshLease(identity: string, action: () => Promise<string>): Promise<string> {
|
||||
const owner = `${Date.now()}-${Math.random()}`
|
||||
const deadline = Date.now() + 20000
|
||||
while (Date.now() < deadline) {
|
||||
if (identity !== getSessionIdentity()) throw new Error('نشست تغییر کرده است.')
|
||||
const lease = readLease()
|
||||
if (!lease || lease.identity !== identity || lease.expires <= Date.now()) {
|
||||
try { window.localStorage.setItem(leaseKey, JSON.stringify({ owner, identity, expires: Date.now() + 15000 })) }
|
||||
catch { return action() }
|
||||
// Yield before checking ownership so simultaneous contenders see
|
||||
// the same winner rather than both sending a rotating JWT.
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
if (readLease()?.owner === owner) {
|
||||
try { return await action() }
|
||||
finally {
|
||||
try { if (readLease()?.owner === owner) window.localStorage.removeItem(leaseKey) }
|
||||
catch { /* An abandoned lease expires, including after a closed tab. */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
throw new Error('تمدید نشست موقتاً انجام نشد. دوباره تلاش کنید.')
|
||||
}
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: `${process.env.NEXT_PUBLIC_API_URL}/`,
|
||||
timeout: 2000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
//'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
axiosInstance.interceptors.request.use(async req => {
|
||||
const accessT = await localStorage.getItem('access')
|
||||
req.headers.Authorization = `Bearer ${accessT}`
|
||||
return req
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL + '/',
|
||||
timeout: 10000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
axiosInstance.interceptors.request.use((request: RetryRequest) => {
|
||||
const access = getAccessToken()
|
||||
if (access) request.headers.Authorization = 'Bearer ' + access
|
||||
else delete request.headers.Authorization
|
||||
request._sessionIdentity ??= getSessionIdentity()
|
||||
return request
|
||||
})
|
||||
|
||||
axiosInstance.interceptors.response.use((response)=> {
|
||||
return response
|
||||
}, async (error) =>{
|
||||
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response.status === 401 && !originalRequest._retry) {
|
||||
|
||||
originalRequest._retry = true; // Prevent infinite loops
|
||||
|
||||
async function refreshAccess(identity: string): Promise<string> {
|
||||
const originalRefresh = getRefreshToken()
|
||||
async function rotate(): Promise<string> {
|
||||
if (identity !== getSessionIdentity()) throw new Error('نشست تغییر کرده است.')
|
||||
const refresh = getRefreshToken()
|
||||
if (!refresh) throw new InvalidSession()
|
||||
// A different tab may have rotated while this tab waited for the lock.
|
||||
if (refresh !== originalRefresh && getAccessToken()) return getAccessToken()!
|
||||
try {
|
||||
|
||||
if (localStorage.getItem('refresh') != 'undefined'){
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/token/refresh/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=UTF-8',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
'refresh': localStorage.getItem('refresh')
|
||||
})
|
||||
});
|
||||
|
||||
if(res.status==200){
|
||||
setCookieFn('isAuth', 'true', 365)
|
||||
const tokenData = await res.json()
|
||||
localStorage.setItem('access', tokenData.access)
|
||||
localStorage.setItem('refresh',tokenData.refresh)
|
||||
originalRequest.headers.Authorization = `Bearer ${tokenData.access}`
|
||||
return axiosInstance(originalRequest)
|
||||
}else{
|
||||
|
||||
}
|
||||
} else {
|
||||
|
||||
localStorage.removeItem('access');
|
||||
localStorage.removeItem('refresh');
|
||||
document.cookie = "isAuth=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
setTimeout(()=>{
|
||||
window.location.replace('/login')
|
||||
}, 200)
|
||||
|
||||
const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/token/refresh/', { refresh }, { timeout: 10000 })
|
||||
if (identity !== getSessionIdentity()) throw new Error('نشست تغییر کرده است.')
|
||||
if (getRefreshToken() !== refresh) {
|
||||
if (getAccessToken()) return getAccessToken()!
|
||||
throw new Error('نشست تغییر کرده است.')
|
||||
}
|
||||
|
||||
} catch(refreshError) {
|
||||
|
||||
localStorage.removeItem('access');
|
||||
localStorage.removeItem('refresh');
|
||||
document.cookie = "isAuth=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
setTimeout(()=>{
|
||||
window.location.replace('/login')
|
||||
}, 200)
|
||||
|
||||
throw refreshError;
|
||||
if (!validTokens(response.data)) throw new InvalidSession()
|
||||
saveTokens(response.data, refresh)
|
||||
return response.data.access
|
||||
} catch (error) {
|
||||
if (identity !== getSessionIdentity()) throw new Error('نشست تغییر کرده است.')
|
||||
if (getRefreshToken() !== refresh && getAccessToken()) return getAccessToken()!
|
||||
if (axios.isAxiosError(error) && [400, 401, 403].includes(error.response?.status || 0)) throw new InvalidSession()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
});
|
||||
// Web Locks serialize refresh rotation across tabs in supporting browsers.
|
||||
if (typeof navigator !== 'undefined' && navigator.locks) {
|
||||
return navigator.locks.request('crm-customer-jwt-refresh', rotate)
|
||||
}
|
||||
return withRefreshLease(identity, rotate)
|
||||
}
|
||||
|
||||
export default axiosInstance
|
||||
axiosInstance.interceptors.response.use(response => {
|
||||
const identity = (response.config as RetryRequest)._sessionIdentity
|
||||
if (identity && identity !== getSessionIdentity()) return Promise.reject(new Error('نشست تغییر کرده است.'))
|
||||
return response
|
||||
}, async (error: AxiosError) => {
|
||||
const request = error.config as RetryRequest | undefined
|
||||
if (error.response?.status !== 401 || !request || /\/auth\//.test(request.url || '')) return Promise.reject(error)
|
||||
const identity = request._sessionIdentity!
|
||||
// Never replay a previous person's request under a newly logged-in account.
|
||||
if (identity !== getSessionIdentity()) return Promise.reject(error)
|
||||
if (request._retry) {
|
||||
const latest = getAccessToken()
|
||||
if (!latest || request.headers.Authorization === 'Bearer ' + latest) expireSession()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
request._retry = true
|
||||
const currentAccess = getAccessToken()
|
||||
if (currentAccess && request.headers.Authorization !== 'Bearer ' + currentAccess) return axiosInstance(request)
|
||||
const refresh = getRefreshToken()
|
||||
try {
|
||||
if (!pendingRefresh || pendingRefresh.identity !== identity) {
|
||||
const pending = { identity, promise: refreshAccess(identity) }
|
||||
pendingRefresh = pending
|
||||
void pending.promise.finally(() => { if (pendingRefresh === pending) pendingRefresh = null }).catch(() => {})
|
||||
}
|
||||
const access = await pendingRefresh.promise
|
||||
if (identity !== getSessionIdentity()) return Promise.reject(error)
|
||||
request.headers.Authorization = 'Bearer ' + access
|
||||
return axiosInstance(request)
|
||||
} catch (refreshError) {
|
||||
if (refreshError instanceof InvalidSession && identity === getSessionIdentity() && getRefreshToken() === refresh) expireSession()
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
})
|
||||
export default axiosInstance
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { apiErrorMessage, forgetLoginMobile, LoginOption, obtainTokens, readLoginMobile, rememberLoginMobile } from '@/lib/auth'
|
||||
|
||||
type Step = 'mobile' | 'verify'
|
||||
function normalizePhone(value: string): string {
|
||||
let digits = value.trim().replace(/[۰-۹]/g, n => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(n)))
|
||||
.replace(/[٠-٩]/g, n => String('٠١٢٣٤٥٦٧٨٩'.indexOf(n))).replace(/[\s-]/g, '')
|
||||
for (const prefix of ['+98', '0098', '98', '0']) {
|
||||
if (digits.startsWith(prefix) && digits.length === 10 + prefix.length) { digits = digits.slice(prefix.length); break }
|
||||
}
|
||||
return /^9\d{9}$/.test(digits) ? digits : ''
|
||||
}
|
||||
function loginOptions(data: unknown): LoginOption[] {
|
||||
if (!Array.isArray(data)) return []
|
||||
return data.filter(option => typeof option?.uuid === 'string' && option.uuid &&
|
||||
option?.cuser_role?.role_type?.role_name_en === 'workspace_customer' &&
|
||||
typeof option?.cuser_role?.sys_ws?.name === 'string')
|
||||
}
|
||||
|
||||
export default function CustomerLoginForm({ initialStep = 'mobile' }: { initialStep?: Step }) {
|
||||
const [mobile, setMobile] = useState(() => initialStep === 'verify' ? normalizePhone(readLoginMobile()) : '')
|
||||
const [step, setStep] = useState<Step>(() => initialStep === 'verify' && normalizePhone(readLoginMobile()) ? 'verify' : 'mobile')
|
||||
const [code, setCode] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [options, setOptions] = useState<LoginOption[]>([])
|
||||
const [recoveryMobile, setRecoveryMobile] = useState('')
|
||||
const [resendAt, setResendAt] = useState(0)
|
||||
const [now, setNow] = useState(Date.now())
|
||||
const locked = useRef(false)
|
||||
const mounted = useRef(true)
|
||||
const redirecting = useRef(false)
|
||||
const remaining = Math.max(0, Math.ceil((resendAt - now) / 1000))
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
const reason = new URLSearchParams(window.location.search).get('reason')
|
||||
if (reason === 'logout-unconfirmed') setError('از این دستگاه خارج شدید؛ به علت قطع ارتباط، ابطال نشست در سرور تأیید نشد.')
|
||||
if (reason === 'session') setError('نشست شما منقضی یا باطل شده است. دوباره وارد شوید.')
|
||||
return () => { mounted.current = false }
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!resendAt) return
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [resendAt])
|
||||
|
||||
async function run(action: () => Promise<void>) {
|
||||
if (locked.current) return
|
||||
locked.current = true
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try { await action() }
|
||||
catch (failure) {
|
||||
if (!mounted.current) return
|
||||
setError(apiErrorMessage(failure))
|
||||
if (axios.isAxiosError(failure) && failure.response?.status === 429) {
|
||||
const wait = Number(failure.response.data?.retry_after || failure.response.headers?.['retry-after'] || 60)
|
||||
if (Number.isFinite(wait) && wait > 0) setResendAt(Date.now() + Math.min(wait, 3600) * 1000)
|
||||
}
|
||||
} finally {
|
||||
if (!redirecting.current) {
|
||||
if (mounted.current) setBusy(false)
|
||||
locked.current = false
|
||||
}
|
||||
}
|
||||
}
|
||||
async function sendCode(event?: FormEvent) {
|
||||
event?.preventDefault()
|
||||
if (locked.current || Date.now() < resendAt) return
|
||||
const normalized = normalizePhone(mobile)
|
||||
if (!normalized) { setError('شماره موبایل معتبر وارد کنید.'); return }
|
||||
await run(async () => {
|
||||
setRecoveryMobile('')
|
||||
setNotice('')
|
||||
let response
|
||||
try {
|
||||
response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/login/mobile/', { mobile: normalized }, { timeout: 30000 })
|
||||
} catch (failure) {
|
||||
// The server may have sent the SMS even if its HTTP response was
|
||||
// lost. Let the person use the code they actually received.
|
||||
if (mounted.current && axios.isAxiosError(failure) && (!failure.response || failure.response.status === 429)) setRecoveryMobile(normalized)
|
||||
throw failure
|
||||
}
|
||||
const confirmed = normalizePhone(String(response.data?.result?.mobile || ''))
|
||||
if (!confirmed) throw new Error('پاسخ ارسال کد معتبر نیست. دوباره تلاش کنید.')
|
||||
if (!mounted.current) return
|
||||
rememberLoginMobile(confirmed)
|
||||
setMobile(confirmed)
|
||||
setCode('')
|
||||
setOptions([])
|
||||
setStep('verify') // Never depend on storage or route navigation after SMS.
|
||||
if (response.data?.result?.delivery_status === 'unconfirmed') {
|
||||
setNotice('وضعیت ارسال پیامک تأیید نشد. اگر کد را دریافت کردهاید، آن را وارد کنید؛ در غیر این صورت پس از پایان شمارش دوباره درخواست دهید.')
|
||||
}
|
||||
const wait = Number(response.data?.result?.retry_after || 60)
|
||||
setNow(Date.now())
|
||||
setResendAt(Date.now() + (Number.isFinite(wait) ? Math.min(Math.max(wait, 1), 3600) : 60) * 1000)
|
||||
})
|
||||
}
|
||||
async function finishLogin(option: LoginOption) {
|
||||
await obtainTokens(option.uuid)
|
||||
if (!mounted.current) return
|
||||
forgetLoginMobile()
|
||||
redirecting.current = true
|
||||
window.location.replace('/shop')
|
||||
}
|
||||
async function verify(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
if (locked.current || options.length) return
|
||||
const digits = code.replace(/[۰-۹]/g, n => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(n)))
|
||||
.replace(/[٠-٩]/g, n => String('٠١٢٣٤٥٦٧٨٩'.indexOf(n)))
|
||||
if (!/^[1-9]\d{5}$/.test(digits)) { setError('کد ششرقمی پیامک را وارد کنید.'); return }
|
||||
await run(async () => {
|
||||
const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/login/mobile/verify/', { mobile, opt: digits }, { timeout: 15000 })
|
||||
const roles = loginOptions(response.data)
|
||||
if (!roles.length) throw new Error('دسترسی فعالی به پنل مشتریان برای ورود وجود ندارد.')
|
||||
if (!mounted.current) return
|
||||
setOptions(roles)
|
||||
if (roles.length === 1) await finishLogin(roles[0])
|
||||
})
|
||||
}
|
||||
function changeMobile() {
|
||||
if (locked.current) return
|
||||
forgetLoginMobile()
|
||||
setStep('mobile')
|
||||
setCode('')
|
||||
setOptions([])
|
||||
setError('')
|
||||
setNotice('')
|
||||
}
|
||||
return <div className="mx-auto w-full sm:w-[30rem]" dir="rtl">
|
||||
<form onSubmit={step === 'mobile' ? sendCode : verify} className="flex flex-col gap-y-3 px-8" aria-busy={busy}>
|
||||
<h1 className="text-center border-b-2 border-black pb-4 text-2xl">ورود به سامانه مشتریان</h1>
|
||||
{step === 'mobile' ? <>
|
||||
<label htmlFor="mobile">موبایل</label>
|
||||
<input id="mobile" type="tel" inputMode="tel" autoComplete="tel" required disabled={busy} value={mobile} onChange={e => setMobile(e.target.value)} placeholder="شماره موبایل را وارد کنید" className="w-full h-[45px] p-5 rounded-md shadow-sm" />
|
||||
<button type="submit" disabled={busy || remaining > 0} className="bg-slate-900 text-white rounded p-2 disabled:opacity-50">{busy ? 'در حال ارسال کد…' : 'دریافت کد ورود'}</button>
|
||||
{recoveryMobile && <button type="button" disabled={busy} className="underline" onClick={() => {
|
||||
if (locked.current) return
|
||||
rememberLoginMobile(recoveryMobile)
|
||||
setMobile(recoveryMobile)
|
||||
setStep('verify')
|
||||
setCode('')
|
||||
setOptions([])
|
||||
setError('')
|
||||
}}>کد پیامک را دریافت کردهام</button>}
|
||||
</> : <>
|
||||
<p>کد ششرقمی پیامکشده به شمارهٔ <b dir="ltr">0{mobile}</b> را وارد کنید.</p>
|
||||
{!options.length ? <>
|
||||
<label htmlFor="otp">کد تأیید</label>
|
||||
<input id="otp" aria-label="کد تایید" autoComplete="one-time-code" inputMode="numeric" disabled={busy} value={code} onChange={e => setCode(e.target.value)} maxLength={6} required className="w-full h-[45px] p-5 rounded-md shadow-sm" />
|
||||
<button type="submit" disabled={busy} className="bg-slate-900 text-white rounded p-2 disabled:opacity-50">{busy ? 'در حال بررسی…' : 'ورود به سامانه'}</button>
|
||||
</> : <div className="flex flex-col gap-2"><p>پنل موردنظر را انتخاب کنید:</p>{options.map(option => <button type="button" key={option.uuid} disabled={busy} onClick={() => run(() => finishLogin(option))} className="border rounded p-2 disabled:opacity-50">{option.cuser_role.sys_ws.name}</button>)}</div>}
|
||||
<button type="button" disabled={busy || remaining > 0} onClick={() => sendCode()} className="underline disabled:opacity-50">دریافت دوباره کد</button>
|
||||
<button type="button" disabled={busy} onClick={changeMobile} className="underline disabled:opacity-50">تغییر شماره</button>
|
||||
</>}
|
||||
{remaining > 0 && <p role="status">ارسال دوبارهٔ کد پس از {remaining} ثانیه امکانپذیر است.</p>}
|
||||
{notice && <p role="status" className="bg-amber-50 rounded p-2">{notice}</p>}
|
||||
{error && <p role="alert" className="text-red-700 bg-red-100 rounded p-2">{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import axiosInstance from '@/axios/axiosInstance'
|
||||
import { apiErrorMessage, expireSession, getAccessToken, getRefreshToken, getSessionIdentity } from '@/lib/auth'
|
||||
|
||||
export default function SessionGuard({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false)
|
||||
const client = useQueryClient()
|
||||
useEffect(() => {
|
||||
if (!getAccessToken() && !getRefreshToken()) { expireSession(); return }
|
||||
setReady(true)
|
||||
const identity = getSessionIdentity()
|
||||
let navigating = false
|
||||
const changed = (event: StorageEvent) => {
|
||||
if (navigating) return
|
||||
if (event.key !== 'authSession' && event.key !== null && !(identity.startsWith('legacy:') && event.key === 'refresh' && event.newValue === null)) return
|
||||
if (getSessionIdentity() === identity && getRefreshToken()) return
|
||||
navigating = true
|
||||
setReady(false)
|
||||
client.clear()
|
||||
if (!getRefreshToken()) expireSession()
|
||||
else window.location.replace('/shop')
|
||||
}
|
||||
window.addEventListener('storage', changed)
|
||||
return () => window.removeEventListener('storage', changed)
|
||||
}, [client])
|
||||
const query = useQuery({
|
||||
queryKey: ['cuser-info-x1'],
|
||||
enabled: ready,
|
||||
queryFn: async () => {
|
||||
const result = await axiosInstance.get('cuser/info/')
|
||||
if (result.data?.role && result.data.role !== 'workspace_customer') {
|
||||
expireSession()
|
||||
throw new Error('این حساب به پنل مشتریان دسترسی ندارد.')
|
||||
}
|
||||
return result
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 30000,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: 60000,
|
||||
})
|
||||
if (!ready || query.isLoading) return <p role="status" className="p-6">در حال بررسی نشست…</p>
|
||||
if (query.isError) return <div className="p-6" dir="rtl">
|
||||
<p role="alert">{apiErrorMessage(query.error)}</p>
|
||||
<button type="button" onClick={() => query.refetch()} className="underline">تلاش دوباره</button>
|
||||
</div>
|
||||
return <>{children}</>
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
export type Tokens = { access: string; refresh: string }
|
||||
export type LoginOption = { uuid: string; cuser_role: { name_family: string; title: string; sys_ws: { name: string }; role_type: { role_name: string; role_name_en: string } } }
|
||||
let sessionVersion = 0
|
||||
let sessionCleared = false
|
||||
let clearedIdentity: string | null = null
|
||||
let loginMobile = ''
|
||||
let pendingLogout: Promise<void> | null = null
|
||||
|
||||
function readStorage(key: string): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try { return window.localStorage.getItem(key) } catch { return null }
|
||||
}
|
||||
function readToken(key: 'access' | 'refresh'): string | null {
|
||||
if (sessionCleared) {
|
||||
const current = readStorage('authSession')
|
||||
if (!current || current === clearedIdentity) return null
|
||||
// A new login in another tab must not be erased by this tab's old logout.
|
||||
sessionCleared = false
|
||||
}
|
||||
const value = readStorage(key)
|
||||
return value && !['undefined', 'null'].includes(value) ? value : null
|
||||
}
|
||||
export const getAccessToken = () => readToken('access')
|
||||
export const getRefreshToken = () => readToken('refresh')
|
||||
export const getSessionIdentity = () => readStorage('authSession') || `legacy:${sessionVersion}`
|
||||
|
||||
export function validTokens(data: unknown): data is Tokens {
|
||||
const tokens = data as Tokens | null
|
||||
return !!tokens && ['access', 'refresh'].every(key => {
|
||||
const value = tokens[key as keyof Tokens]
|
||||
return typeof value === 'string' && !!value.trim() && !['null', 'undefined'].includes(value)
|
||||
})
|
||||
}
|
||||
|
||||
export function saveTokens(data: Tokens, expectedRefresh?: string): void {
|
||||
if (!validTokens(data)) throw new Error('پاسخ ورود معتبر نیست. دوباره تلاش کنید.')
|
||||
if (expectedRefresh && getRefreshToken() !== expectedRefresh) throw new Error('نشست تغییر کرده است.')
|
||||
const keys = ['access', 'refresh', 'authSession']
|
||||
const previous = keys.map(readStorage)
|
||||
try {
|
||||
window.localStorage.setItem('access', data.access)
|
||||
window.localStorage.setItem('refresh', data.refresh)
|
||||
if (!expectedRefresh) window.localStorage.setItem('authSession', `${Date.now()}-${Math.random()}`)
|
||||
} catch {
|
||||
// A partially stored pair cannot authenticate reliably. Restore the old
|
||||
// session when possible and leave the current operation unsuccessful.
|
||||
keys.forEach((key, index) => {
|
||||
try {
|
||||
if (previous[index] === null) window.localStorage.removeItem(key)
|
||||
else window.localStorage.setItem(key, previous[index]!)
|
||||
} catch { /* Storage can be disabled entirely by the browser. */ }
|
||||
})
|
||||
throw new Error('ذخیرهٔ نشست در مرورگر ممکن نیست. اجازهٔ ذخیرهسازی سایت را فعال کنید و دوباره تلاش کنید.')
|
||||
}
|
||||
if (!expectedRefresh) sessionVersion++
|
||||
sessionCleared = false
|
||||
for (const key of ['0a', '0r']) { try { window.localStorage.removeItem(key) } catch { /* Legacy keys only. */ } }
|
||||
document.cookie = 'isAuth=true;max-age=2592000;path=/;SameSite=Lax'
|
||||
}
|
||||
|
||||
export function rememberLoginMobile(mobile: string): void {
|
||||
loginMobile = mobile
|
||||
try { window.sessionStorage.setItem('loginMobile', mobile) } catch { /* The same-page form keeps its own mobile. */ }
|
||||
}
|
||||
export function readLoginMobile(): string {
|
||||
if (loginMobile) return loginMobile
|
||||
try { return window.sessionStorage.getItem('loginMobile') || '' } catch { return '' }
|
||||
}
|
||||
export function forgetLoginMobile(): void {
|
||||
loginMobile = ''
|
||||
try { window.sessionStorage.removeItem('loginMobile') } catch { /* Optional OTP navigation state. */ }
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
clearedIdentity = readStorage('authSession')
|
||||
sessionVersion++
|
||||
sessionCleared = true
|
||||
if (typeof window === 'undefined') return
|
||||
for (const key of ['access', 'refresh', 'authSession', '0a', '0r', 'nameFamily']) {
|
||||
try { window.localStorage.removeItem(key) } catch { /* Still invalidate this tab and the routing cookie. */ }
|
||||
}
|
||||
forgetLoginMobile()
|
||||
document.cookie = 'isAuth=;max-age=0;path=/;SameSite=Lax'
|
||||
}
|
||||
|
||||
export function expireSession(): void {
|
||||
clearSession()
|
||||
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
|
||||
window.location.replace('/login?reason=session')
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeRefresh(refresh: string): Promise<void> {
|
||||
await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/logout/', { refresh }, { timeout: 5000 })
|
||||
}
|
||||
|
||||
export function logout(): Promise<void> {
|
||||
if (pendingLogout) return pendingLogout
|
||||
const refresh = getRefreshToken()
|
||||
clearSession()
|
||||
pendingLogout = (async () => {
|
||||
let destination = '/login'
|
||||
try { if (refresh) await revokeRefresh(refresh) }
|
||||
catch { destination = '/login?reason=logout-unconfirmed' }
|
||||
finally { window.location.replace(destination) }
|
||||
})().finally(() => { pendingLogout = null })
|
||||
return pendingLogout
|
||||
}
|
||||
|
||||
function collectMessages(value: unknown): string[] {
|
||||
if (typeof value === 'string') return [value]
|
||||
if (Array.isArray(value)) return value.flatMap(collectMessages)
|
||||
if (value && typeof value === 'object') return Object.entries(value)
|
||||
.filter(([key]) => !['code', 'retry_after', 'status', 'status_code'].includes(key))
|
||||
.flatMap(([, item]) => collectMessages(item))
|
||||
return []
|
||||
}
|
||||
export function apiErrorMessage(error: unknown): string {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const messages = collectMessages(error.response?.data)
|
||||
return messages.length ? messages.join(' ـ ') : 'ارتباط با سرور برقرار نشد. دوباره تلاش کنید.'
|
||||
}
|
||||
return error instanceof Error ? error.message : 'خطایی رخ داد. دوباره تلاش کنید.'
|
||||
}
|
||||
|
||||
export async function obtainTokens(uuid: string): Promise<void> {
|
||||
const version = sessionVersion
|
||||
const identity = getSessionIdentity()
|
||||
const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/token/', { uuid_opt: uuid }, { timeout: 15000 })
|
||||
try {
|
||||
if (version !== sessionVersion || identity !== getSessionIdentity() || pendingLogout) throw new Error('درخواست ورود لغو شده است. دوباره وارد شوید.')
|
||||
saveTokens(response.data)
|
||||
} catch (error) {
|
||||
if (validTokens(response.data)) { try { await revokeRefresh(response.data.refresh) } catch { /* Keep the original, actionable error. */ } }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
+5
-24
@@ -1,32 +1,13 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
// This function can be marked `async` if using `await` inside
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
|
||||
let isAuth:any = request.cookies.get('isAuth')
|
||||
|
||||
try {
|
||||
if(isAuth.value != "true"){
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
} catch {
|
||||
if (request.cookies.get('isAuth')?.value !== 'true') {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
|
||||
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api (API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|login|login/verify).*)',
|
||||
],
|
||||
}
|
||||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|login|register).*)'],
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const realAxios = require('axios');
|
||||
|
||||
// Execute the actual TypeScript modules against Axios adapters and a browser stub.
|
||||
// No additional runtime/test dependencies, no network, no production API.
|
||||
function harness(shared = {}) {
|
||||
const local = shared.local || new Map(); const session = new Map(); const redirects = [];
|
||||
const listeners = new Map(); const timers = new Map(); let clock = Date.now();
|
||||
const storage = map => ({ getItem: key => map.get(key) ?? null, setItem: (key, value) => map.set(key, String(value)), removeItem: key => map.delete(key) });
|
||||
const window = { localStorage: storage(local), sessionStorage: storage(session), location: { pathname: '/shop', search: '', replace: value => redirects.push(value) },
|
||||
addEventListener: (name, callback) => listeners.set(name, callback), removeEventListener: name => listeners.delete(name) };
|
||||
const document = { cookie: '' };
|
||||
const axios = realAxios.create(); axios.create = realAxios.create;
|
||||
axios.isAxiosError = realAxios.isAxiosError;
|
||||
const cache = {};
|
||||
const hooks = []; let cursor = 0;
|
||||
const react = { ...require('react'), useState: initial => {
|
||||
const index = cursor++;
|
||||
if (!(index in hooks)) hooks[index] = typeof initial === 'function' ? initial() : initial;
|
||||
return [hooks[index], value => { hooks[index] = typeof value === 'function' ? value(hooks[index]) : value; }];
|
||||
}, useRef: initial => { const index = cursor++; return hooks[index] ||= { current: initial }; },
|
||||
useEffect: (effect, deps) => {
|
||||
const index = cursor++; const previous = hooks[index];
|
||||
if (!previous || !deps || deps.some((value, i) => value !== previous.deps[i])) {
|
||||
previous?.cleanup?.(); hooks[index] = { deps, cleanup: effect() };
|
||||
}
|
||||
} };
|
||||
const queryState = { isLoading: false, isError: false, error: null, refetch: async () => {} };
|
||||
const queryClient = { clears: 0, clear() { this.clears++; } };
|
||||
let queryOptions;
|
||||
const navigator = { locks: Object.hasOwn(shared, 'locks') ? shared.locks : { request: (_name, callback) => callback() } };
|
||||
const dispatches = [];
|
||||
function render(component, props = {}) { cursor = 0; return component(props); }
|
||||
function load(filename) {
|
||||
const full = path.resolve(filename);
|
||||
if (cache[full]) return cache[full].exports;
|
||||
const module = { exports: {} }; cache[full] = module;
|
||||
const source = ts.transpileModule(fs.readFileSync(full, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true, jsx: ts.JsxEmit.ReactJSX } }).outputText;
|
||||
const requireStub = name => {
|
||||
if (name === 'axios') return axios;
|
||||
if (name === 'react') return react;
|
||||
if (name === 'react/jsx-runtime') return require(name);
|
||||
if (name === '@tanstack/react-query') return { useQuery: options => { queryOptions = options; return queryState; }, useQueryClient: () => queryClient };
|
||||
if (name === 'next/navigation') return { useRouter: () => ({ push: value => redirects.push(value), replace: value => redirects.push(value) }) };
|
||||
if (name === 'react-redux') return { useDispatch: () => action => dispatches.push(action), useSelector: select => select({ mobileopt: { mobile: 9123456789 } }) };
|
||||
if (name.includes('mobileoptSlice')) return { addMobile: payload => ({ payload }) };
|
||||
const base = path.join('src', name.slice(2));
|
||||
return load(base + (fs.existsSync(base + '.ts') ? '.ts' : '.tsx'));
|
||||
};
|
||||
vm.runInNewContext('(function(require, module, exports){' + source + '\n})', { window, document, navigator, process, Error, Promise, URLSearchParams, console,
|
||||
Date: class extends Date { static now() { return clock; } },
|
||||
setInterval: callback => { const id = timers.size + 1; timers.set(id, callback); return id; }, clearInterval: id => timers.delete(id),
|
||||
setTimeout,
|
||||
})(requireStub, module, module.exports);
|
||||
return module.exports;
|
||||
}
|
||||
const auth = load('src/lib/auth.ts');
|
||||
const instance = load('src/axios/axiosInstance.ts').default;
|
||||
return { auth, instance, axios, local, session, redirects, document, window, load, render, dispatches, listeners, queryState, queryClient,
|
||||
get queryOptions() { return queryOptions; }, advance: ms => { clock += ms; timers.forEach(callback => callback()); } };
|
||||
}
|
||||
function failure(config, status, data = { detail: 'Error' }) {
|
||||
return new realAxios.AxiosError('Request failed', 'ERR_BAD_RESPONSE', config, null, { data, status, statusText: 'Failed', headers: {}, config });
|
||||
}
|
||||
function response(config, data, status = 200) { return { config, data, status, statusText: 'OK', headers: {} }; }
|
||||
|
||||
test('stores valid tokens and rejects malformed success responses', () => {
|
||||
const h = harness();
|
||||
h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
assert.equal(h.auth.getAccessToken(), 'access.old');
|
||||
assert.equal(h.auth.getRefreshToken(), 'refresh.old');
|
||||
assert.match(h.document.cookie, /isAuth=true/);
|
||||
assert.throws(() => h.auth.saveTokens({ access: undefined, refresh: 'bad' }));
|
||||
});
|
||||
test('request with no token never sends Bearer null or undefined', async () => {
|
||||
const h = harness();
|
||||
h.instance.defaults.adapter = config => { assert.equal(config.headers.Authorization, undefined); return Promise.resolve(response(config, {})); };
|
||||
await h.instance.get('/public/');
|
||||
});
|
||||
test('400, 403, 500 and network failures always reject and preserve session', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
for (const status of [400, 403, 500, null]) {
|
||||
h.instance.defaults.adapter = config => Promise.reject(status ? failure(config, status) : new realAxios.AxiosError('Offline', 'ERR_NETWORK', config));
|
||||
await assert.rejects(h.instance.get('/customer/v2/create/'));
|
||||
assert.equal(h.auth.getAccessToken(), 'access.old');
|
||||
assert.equal(h.redirects.length, 0);
|
||||
}
|
||||
});
|
||||
test('simultaneous 401 requests perform one refresh and both retry', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
let calls = 0;
|
||||
h.axios.defaults.adapter = async config => { calls++; await new Promise(resolve => setImmediate(resolve)); return response(config, { access: 'access.new', refresh: 'refresh.new' }); };
|
||||
h.instance.defaults.adapter = config => config.headers.Authorization === 'Bearer access.old' ? Promise.reject(failure(config, 401)) : Promise.resolve(response(config, { ok: true }));
|
||||
const result = await Promise.all([h.instance.get('/first/'), h.instance.get('/second/')]);
|
||||
assert.equal(calls, 1); assert.equal(result.length, 2); assert.equal(h.auth.getAccessToken(), 'access.new');
|
||||
});
|
||||
test('revoked refresh clears tokens, cookie and redirects to login', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
h.instance.defaults.adapter = config => Promise.reject(failure(config, 401));
|
||||
h.axios.defaults.adapter = config => Promise.reject(failure(config, 401));
|
||||
await assert.rejects(h.instance.get('/cuser/info/'));
|
||||
assert.equal(h.auth.getAccessToken(), null); assert.equal(h.auth.getRefreshToken(), null);
|
||||
assert.equal(h.redirects[0], '/login?reason=session'); assert.match(h.document.cookie, /max-age=0/);
|
||||
});
|
||||
test('401 without refresh logs out and rejects', async () => {
|
||||
const h = harness();
|
||||
h.instance.defaults.adapter = config => Promise.reject(failure(config, 401));
|
||||
await assert.rejects(h.instance.get('/cuser/info/'));
|
||||
assert.equal(h.redirects[0], '/login?reason=session');
|
||||
});
|
||||
test('network failure while refreshing preserves recoverable session', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
h.instance.defaults.adapter = config => Promise.reject(failure(config, 401));
|
||||
h.axios.defaults.adapter = config => Promise.reject(new realAxios.AxiosError('Offline', 'ERR_NETWORK', config));
|
||||
await assert.rejects(h.instance.get('/cuser/info/'));
|
||||
assert.equal(h.auth.getRefreshToken(), 'refresh.old'); assert.equal(h.redirects.length, 0);
|
||||
});
|
||||
test('malformed refresh success logs out instead of storing undefined', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
h.instance.defaults.adapter = config => Promise.reject(failure(config, 401));
|
||||
h.axios.defaults.adapter = config => Promise.resolve(response(config, { error: 'Invalid' }));
|
||||
await assert.rejects(h.instance.get('/cuser/info/'));
|
||||
assert.equal(h.auth.getAccessToken(), null);
|
||||
});
|
||||
test('retry 401 does not refresh forever', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let calls = 0;
|
||||
h.instance.defaults.adapter = config => Promise.reject(failure(config, 401));
|
||||
h.axios.defaults.adapter = config => { calls++; return Promise.resolve(response(config, { access: 'access.new', refresh: 'refresh.new' })); };
|
||||
await assert.rejects(h.instance.get('/cuser/info/')); assert.equal(calls, 1); assert.equal(h.auth.getAccessToken(), null);
|
||||
});
|
||||
test('public auth 401 displays error without triggering token refresh', async () => {
|
||||
const h = harness(); let calls = 0;
|
||||
h.instance.defaults.adapter = config => Promise.reject(failure(config, 401));
|
||||
h.axios.defaults.adapter = config => { calls++; return Promise.resolve(response(config, {})); };
|
||||
await assert.rejects(h.instance.post('/auth/token/', {}));
|
||||
assert.equal(calls, 0); assert.equal(h.redirects.length, 0);
|
||||
});
|
||||
test('logout revokes original refresh token and clears all local identity', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); h.local.set('nameFamily', 'Previous');
|
||||
h.axios.defaults.adapter = config => { assert.match(config.url, /auth\/logout/); assert.equal(JSON.parse(config.data).refresh, 'refresh.old'); return Promise.resolve(response(config, null, 204)); };
|
||||
await h.auth.logout(); assert.equal(h.local.size, 0); assert.equal(h.redirects[0], '/login');
|
||||
});
|
||||
test('late refresh response cannot restore a logged-out session', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let resolveRefresh;
|
||||
h.instance.defaults.adapter = config => Promise.reject(failure(config, 401));
|
||||
h.axios.defaults.adapter = config => new Promise(resolve => { resolveRefresh = () => resolve(response(config, { access: 'access.new', refresh: 'refresh.new' })); });
|
||||
const pending = h.instance.get('/cuser/info/');
|
||||
await new Promise(resolve => setImmediate(resolve)); h.auth.clearSession(); resolveRefresh();
|
||||
await assert.rejects(pending); assert.equal(h.auth.getAccessToken(), null);
|
||||
});
|
||||
test('API messages including duplicate mobile and SMS failure are preserved', () => {
|
||||
const h = harness();
|
||||
const config = {};
|
||||
assert.equal(h.auth.apiErrorMessage(failure(config, 400, { mobile: ['شماره تکراری است'] })), 'شماره تکراری است');
|
||||
assert.equal(h.auth.apiErrorMessage(failure(config, 503, { detail: 'ارسال پیامک انجام نشد' })), 'ارسال پیامک انجام نشد');
|
||||
});
|
||||
|
||||
function elements(tree, predicate) {
|
||||
if (!tree || typeof tree !== 'object') return [];
|
||||
if (Array.isArray(tree)) return tree.flatMap(item => elements(item, predicate));
|
||||
return [...(predicate(tree) ? [tree] : []), ...elements(tree.props?.children, predicate)];
|
||||
}
|
||||
function textContent(tree) {
|
||||
if (typeof tree === 'string' || typeof tree === 'number') return String(tree);
|
||||
if (Array.isArray(tree)) return tree.map(textContent).join('');
|
||||
return tree?.props ? textContent(tree.props.children) : '';
|
||||
}
|
||||
test('login page renders API errors and re-enables submit', async () => {
|
||||
const h = harness(); const component = h.load('src/components/auth/customer-login-form.tsx').default;
|
||||
elements(h.render(component), item => item.type === 'input')[0].props.onChange({ target: { value: '09123456789' } });
|
||||
h.axios.defaults.adapter = config => Promise.reject(failure(config, 503, { detail: 'ارسال پیامک انجام نشد' }));
|
||||
const form = elements(h.render(component), item => item.type === 'form')[0];
|
||||
await form.props.onSubmit({ preventDefault() {} });
|
||||
const tree = h.render(component);
|
||||
const alerts = elements(tree, item => item.props?.role === 'alert');
|
||||
assert.ok(alerts.some(item => textContent(item).includes('ارسال پیامک انجام نشد')));
|
||||
assert.ok(elements(tree, item => item.type === 'button').some(item => !item.props.disabled));
|
||||
assert.equal(h.redirects.length, 0);
|
||||
});
|
||||
|
||||
test('customer login does not navigate when SMS is rejected or account is absent', async () => {
|
||||
const h = harness(); const component = h.load('src/components/auth/customer-login-form.tsx').default;
|
||||
elements(h.render(component), item => item.type === 'input')[0].props.onChange({ target: { value: '09123456789' } });
|
||||
h.axios.defaults.adapter = config => Promise.reject(failure(config, 400, { mobile: ['کاربر ثبت نشده است'] }));
|
||||
await elements(h.render(component), item => item.type === 'form')[0].props.onSubmit({ preventDefault() {} });
|
||||
assert.ok(textContent(h.render(component)).includes('کاربر ثبت نشده است'));
|
||||
assert.equal(h.dispatches.length, 0); assert.equal(h.redirects.length, 0);
|
||||
});
|
||||
test('OTP page retains code input after wrong code', async () => {
|
||||
const h = harness(); const component = h.load('src/components/auth/customer-login-form.tsx').default;
|
||||
h.auth.rememberLoginMobile('9123456789');
|
||||
const render = () => h.render(component, { initialStep: 'verify' });
|
||||
let tree = render();
|
||||
elements(tree, item => item.type === 'input')[0].props.onChange({ target: { value: '۱۲۳۴۵۶' } });
|
||||
h.axios.defaults.adapter = config => Promise.reject(failure(config, 400, { opt: ['کد تأیید اشتباه است'] }));
|
||||
await elements(render(), item => item.type === 'form')[0].props.onSubmit({ preventDefault() {} });
|
||||
tree = render();
|
||||
assert.ok(textContent(tree).includes('کد تأیید اشتباه است'));
|
||||
assert.equal(elements(tree, item => item.type === 'input')[0].props.value, '۱۲۳۴۵۶');
|
||||
assert.equal(h.redirects.length, 0);
|
||||
});
|
||||
|
||||
test('a stale retry failure does not clear a newer session from another tab', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
h.instance.defaults.adapter = config => {
|
||||
if (config.headers.Authorization === 'Bearer access.new') h.auth.saveTokens({ access: 'access.other-tab', refresh: 'refresh.other-tab' });
|
||||
return Promise.reject(failure(config, 401));
|
||||
};
|
||||
h.axios.defaults.adapter = config => Promise.resolve(response(config, { access: 'access.new', refresh: 'refresh.new' }));
|
||||
await assert.rejects(h.instance.get('/cuser/info/'));
|
||||
assert.equal(h.auth.getAccessToken(), 'access.other-tab'); assert.equal(h.redirects.length, 0);
|
||||
});
|
||||
|
||||
function customerForm(h, props = {}) {
|
||||
const component = h.load('src/components/auth/customer-login-form.tsx').default;
|
||||
return { render: () => h.render(component, props), submit: () => elements(h.render(component, props), item => item.type === 'form')[0].props.onSubmit({ preventDefault() {} }),
|
||||
input: value => elements(h.render(component, props), item => item.type === 'input')[0].props.onChange({ target: { value } }) };
|
||||
}
|
||||
const customerOption = name => ({ uuid: name, cuser_role: { sys_ws: { name }, role_type: { role_name_en: 'workspace_customer' } } });
|
||||
const settle = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
test('unconfirmed SMS reply opens OTP and the received code completes login', async () => {
|
||||
const h = harness(); const form = customerForm(h); let sends = 0;
|
||||
h.axios.defaults.adapter = config => {
|
||||
if (config.url.endsWith('/auth/login/mobile/')) {
|
||||
sends++;
|
||||
return Promise.resolve(response(config, { result: { mobile: 9123456789, retry_after: 60, delivery_status: 'unconfirmed' } }, 202));
|
||||
}
|
||||
if (config.url.endsWith('/auth/login/mobile/verify/')) {
|
||||
assert.equal(JSON.parse(config.data).opt, '123456');
|
||||
return Promise.resolve(response(config, [customerOption('customer-panel')]));
|
||||
}
|
||||
return Promise.resolve(response(config, { access: 'access.customer', refresh: 'refresh.customer' }));
|
||||
};
|
||||
form.input('09123456789'); await form.submit();
|
||||
assert.equal(elements(form.render(), item => item.props?.autoComplete === 'one-time-code').length, 1);
|
||||
assert.ok(textContent(form.render()).includes('وضعیت ارسال پیامک تأیید نشد'));
|
||||
assert.equal(elements(form.render(), item => item.props?.role === 'alert').length, 0);
|
||||
form.input('123456'); await form.submit();
|
||||
assert.equal(sends, 1); assert.equal(h.auth.getAccessToken(), 'access.customer');
|
||||
assert.deepEqual(h.redirects, ['/shop']);
|
||||
});
|
||||
test('explicit SMS rejection retains the real error and does not open OTP', async () => {
|
||||
const h = harness(); const form = customerForm(h);
|
||||
h.axios.defaults.adapter = config => Promise.reject(failure(config, 503, { detail: 'ارسال پیامک انجام نشد. لطفاً کمی بعد دوباره تلاش کنید.' }));
|
||||
form.input('09123456789'); await form.submit();
|
||||
assert.ok(textContent(form.render()).includes('ارسال پیامک انجام نشد'));
|
||||
assert.equal(elements(form.render(), item => item.props?.autoComplete === 'one-time-code').length, 0);
|
||||
assert.equal(h.auth.getAccessToken(), null);
|
||||
});
|
||||
|
||||
test('SMS success displays OTP in the same page even when sessionStorage is blocked', async () => {
|
||||
const h = harness(); const form = customerForm(h);
|
||||
h.window.sessionStorage.setItem = () => { throw new Error('Storage denied'); };
|
||||
h.axios.defaults.adapter = config => Promise.resolve(response(config, { result: { mobile: 9123456789, retry_after: 60 } }));
|
||||
form.input('۰۹۱۲۳۴۵۶۷۸۹');
|
||||
await form.submit();
|
||||
assert.ok(elements(form.render(), item => item.props?.autoComplete === 'one-time-code').length);
|
||||
assert.ok(textContent(form.render()).includes('09123456789'));
|
||||
assert.equal(h.redirects.length, 0);
|
||||
assert.equal(h.auth.readLoginMobile(), '9123456789');
|
||||
});
|
||||
test('same-render double login submits only one SMS request', async () => {
|
||||
const h = harness(); const form = customerForm(h); let resolve; let requests = 0;
|
||||
form.input('09123456789');
|
||||
h.axios.defaults.adapter = config => { requests++; return new Promise(done => { resolve = () => done(response(config, { result: { mobile: 9123456789 } })); }); };
|
||||
const handler = elements(form.render(), item => item.type === 'form')[0].props.onSubmit;
|
||||
const first = handler({ preventDefault() {} }); const second = handler({ preventDefault() {} });
|
||||
await settle(); assert.equal(requests, 1);
|
||||
assert.ok(elements(form.render(), item => item.type === 'button').every(item => item.props.disabled));
|
||||
resolve(); await Promise.all([first, second]);
|
||||
});
|
||||
test('absent account and disabled panel messages render without navigating or opening OTP', async () => {
|
||||
for (const [status, message] of [[400, 'کاربری با این شماره ثبت نشده است'], [403, 'دسترسی شما به پنل مشتریان فعال نیست']]) {
|
||||
const h = harness(); const form = customerForm(h); form.input('09123456789');
|
||||
h.axios.defaults.adapter = config => Promise.reject(failure(config, status, { detail: message }));
|
||||
await form.submit();
|
||||
assert.ok(textContent(form.render()).includes(message));
|
||||
assert.equal(elements(form.render(), item => item.props?.autoComplete === 'one-time-code').length, 0);
|
||||
assert.equal(h.redirects.length, 0);
|
||||
}
|
||||
});
|
||||
test('lost SMS response permits entering the code that was actually received', async () => {
|
||||
const h = harness(); const form = customerForm(h); form.input('09123456789');
|
||||
h.axios.defaults.adapter = config => Promise.reject(new realAxios.AxiosError('Timeout', 'ECONNABORTED', config));
|
||||
await form.submit();
|
||||
const recovery = elements(form.render(), item => item.type === 'button' && textContent(item).includes('دریافت کرده'))[0];
|
||||
assert.ok(recovery); recovery.props.onClick();
|
||||
assert.ok(elements(form.render(), item => item.props?.autoComplete === 'one-time-code').length);
|
||||
});
|
||||
test('429 displays a countdown and can recover the previously sent code', async () => {
|
||||
const h = harness(); const form = customerForm(h); form.input('09123456789');
|
||||
h.axios.defaults.adapter = config => Promise.reject(failure(config, 429, { detail: 'کد قبلی را وارد کنید', retry_after: 30 }));
|
||||
await form.submit();
|
||||
const tree = form.render();
|
||||
assert.ok(textContent(tree).includes('30 ثانیه'));
|
||||
assert.equal(elements(tree, item => item.type === 'button' && item.props.type === 'submit')[0].props.disabled, true);
|
||||
h.advance(31000);
|
||||
assert.equal(elements(form.render(), item => item.type === 'button' && item.props.type === 'submit')[0].props.disabled, false);
|
||||
});
|
||||
test('resend remains disabled during its cooldown and sends once after it ends', async () => {
|
||||
const h = harness(); const form = customerForm(h); form.input('09123456789'); let requests = 0;
|
||||
h.axios.defaults.adapter = config => { requests++; return Promise.resolve(response(config, { result: { mobile: 9123456789, retry_after: 60 } })); };
|
||||
await form.submit();
|
||||
let resend = elements(form.render(), item => item.type === 'button' && textContent(item) === 'دریافت دوباره کد')[0];
|
||||
assert.equal(resend.props.disabled, true); await resend.props.onClick(); assert.equal(requests, 1);
|
||||
h.advance(61000);
|
||||
resend = elements(form.render(), item => item.type === 'button' && textContent(item) === 'دریافت دوباره کد')[0];
|
||||
assert.equal(resend.props.disabled, false); await resend.props.onClick(); assert.equal(requests, 2);
|
||||
});
|
||||
test('same-render double OTP submit verifies and exchanges tokens only once', async () => {
|
||||
const h = harness(); h.auth.rememberLoginMobile('9123456789'); const form = customerForm(h, { initialStep: 'verify' }); form.input('۱۲۳۴۵۶');
|
||||
let verifyRequests = 0; let tokenRequests = 0; let release;
|
||||
h.axios.defaults.adapter = config => {
|
||||
if (config.url.includes('/verify/')) { verifyRequests++; return new Promise(done => { release = () => done(response(config, [customerOption('Workspace')])); }); }
|
||||
tokenRequests++; return Promise.resolve(response(config, { access: 'access.logged-in', refresh: 'refresh.logged-in' }));
|
||||
};
|
||||
const handler = elements(form.render(), item => item.type === 'form')[0].props.onSubmit;
|
||||
const first = handler({ preventDefault() {} }); const second = handler({ preventDefault() {} });
|
||||
await settle(); assert.equal(verifyRequests, 1); release(); await Promise.all([first, second]);
|
||||
assert.equal(tokenRequests, 1); assert.equal(h.auth.getAccessToken(), 'access.logged-in');
|
||||
assert.deepEqual(h.redirects, ['/shop']);
|
||||
assert.ok(elements(form.render(), item => item.type === 'button').every(item => item.props.disabled));
|
||||
});
|
||||
test('token exchange failure preserves verified choices for retry without consuming OTP twice', async () => {
|
||||
const h = harness(); h.auth.rememberLoginMobile('9123456789'); const form = customerForm(h, { initialStep: 'verify' }); form.input('123456');
|
||||
let verifications = 0; let exchanges = 0;
|
||||
h.axios.defaults.adapter = config => {
|
||||
if (config.url.includes('/verify/')) { verifications++; return Promise.resolve(response(config, [customerOption('Workspace')])); }
|
||||
exchanges++;
|
||||
return exchanges === 1 ? Promise.reject(failure(config, 503, { detail: 'ارتباط قطع شد' })) : Promise.resolve(response(config, { access: 'access.ok', refresh: 'refresh.ok' }));
|
||||
};
|
||||
await form.submit();
|
||||
assert.ok(textContent(form.render()).includes('ارتباط قطع شد'));
|
||||
const choice = elements(form.render(), item => item.type === 'button' && textContent(item) === 'Workspace')[0];
|
||||
await choice.props.onClick();
|
||||
assert.equal(verifications, 1); assert.equal(exchanges, 2); assert.deepEqual(h.redirects, ['/shop']);
|
||||
});
|
||||
test('multiple workspace choice uses a synchronous lock and ignores operator roles', async () => {
|
||||
const h = harness(); h.auth.rememberLoginMobile('9123456789'); const form = customerForm(h, { initialStep: 'verify' }); form.input('123456'); let exchanges = 0; let release;
|
||||
const operator = customerOption('Operator'); operator.cuser_role.role_type.role_name_en = 'workspace_operator';
|
||||
h.axios.defaults.adapter = config => config.url.includes('/verify/') ? Promise.resolve(response(config, [customerOption('One'), customerOption('Two'), operator])) : new Promise(done => { exchanges++; release = () => done(response(config, { access: 'access.ok', refresh: 'refresh.ok' })); });
|
||||
await form.submit(); assert.equal(exchanges, 0); assert.ok(!textContent(form.render()).includes('Operator'));
|
||||
const choice = elements(form.render(), item => item.type === 'button' && textContent(item) === 'One')[0];
|
||||
const first = choice.props.onClick(); const second = choice.props.onClick(); await settle(); assert.equal(exchanges, 1);
|
||||
release(); await Promise.all([first, second]);
|
||||
});
|
||||
test('direct verification URL without navigation state presents a usable mobile form', () => {
|
||||
const h = harness(); const form = customerForm(h, { initialStep: 'verify' });
|
||||
assert.ok(elements(form.render(), item => item.props?.id === 'mobile').length);
|
||||
assert.equal(h.redirects.length, 0);
|
||||
});
|
||||
test('OTP verified route uses remembered state without Redux or working sessionStorage', () => {
|
||||
const h = harness(); h.window.sessionStorage.setItem = () => { throw new Error('denied'); }; h.auth.rememberLoginMobile('9123456789');
|
||||
const form = customerForm(h, { initialStep: 'verify' });
|
||||
assert.ok(elements(form.render(), item => item.props?.autoComplete === 'one-time-code').length);
|
||||
});
|
||||
test('logout double invocation revokes once and redirects once', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let calls = 0; let release;
|
||||
h.axios.defaults.adapter = config => { calls++; return new Promise(done => { release = () => done(response(config, null, 204)); }); };
|
||||
const first = h.auth.logout(); const second = h.auth.logout(); assert.equal(first, second);
|
||||
await settle(); assert.equal(calls, 1); assert.equal(h.auth.getAccessToken(), null);
|
||||
release(); await Promise.all([first, second]); assert.deepEqual(h.redirects, ['/login']);
|
||||
});
|
||||
test('late login token response after logout is revoked and cannot restore the session', async () => {
|
||||
const h = harness(); let release; let revoked = 0;
|
||||
h.axios.defaults.adapter = config => config.url.includes('/auth/token/') ? new Promise(done => { release = () => done(response(config, { access: 'access.late', refresh: 'refresh.late' })); }) : (revoked++, Promise.resolve(response(config, null, 204)));
|
||||
const pending = h.auth.obtainTokens('login-option'); await settle(); await h.auth.logout(); release();
|
||||
await assert.rejects(pending); assert.equal(h.auth.getAccessToken(), null); assert.equal(revoked, 1);
|
||||
});
|
||||
test('storage failure has a useful message and partially written tokens are rolled back', () => {
|
||||
const h = harness(); const original = h.window.localStorage.setItem;
|
||||
h.window.localStorage.setItem = (key, value) => { if (key === 'refresh') throw new Error('quota'); original(key, value); };
|
||||
assert.throws(() => h.auth.saveTokens({ access: 'access.new', refresh: 'refresh.new' }), /ذخیره/);
|
||||
assert.equal(h.auth.getAccessToken(), null); assert.equal(h.auth.getRefreshToken(), null);
|
||||
});
|
||||
test('clearing session still invalidates in-memory reads when storage removal fails', () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
h.window.localStorage.removeItem = () => { throw new Error('denied'); };
|
||||
h.auth.clearSession(); assert.equal(h.auth.getAccessToken(), null); assert.equal(h.auth.getRefreshToken(), null);
|
||||
assert.match(h.document.cookie, /max-age=0/);
|
||||
});
|
||||
test('old account request is never retried under a newly logged-in identity', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let calls = 0;
|
||||
h.instance.defaults.adapter = config => { calls++; h.auth.saveTokens({ access: 'access.other-person', refresh: 'refresh.other-person' }); return Promise.reject(failure(config, 401)); };
|
||||
await assert.rejects(h.instance.post('/shop/cart/', { product: 10 }));
|
||||
assert.equal(calls, 1); assert.equal(h.auth.getAccessToken(), 'access.other-person'); assert.equal(h.redirects.length, 0);
|
||||
});
|
||||
test('simultaneous refreshes in separate tabs serialize through Web Locks', async () => {
|
||||
const local = new Map(); let queue = Promise.resolve(); let lockCalls = 0; let refreshCalls = 0;
|
||||
const locks = { request(name, callback) { assert.equal(name, 'crm-customer-jwt-refresh'); lockCalls++; const result = queue.then(callback); queue = result.catch(() => {}); return result; } };
|
||||
const first = harness({ local, locks }); const second = harness({ local, locks }); first.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
for (const h of [first, second]) {
|
||||
h.instance.defaults.adapter = config => config.headers.Authorization === 'Bearer access.old' ? Promise.reject(failure(config, 401)) : Promise.resolve(response(config, {}));
|
||||
h.axios.defaults.adapter = async config => { refreshCalls++; await settle(); return response(config, { access: 'access.rotated', refresh: 'refresh.rotated' }); };
|
||||
}
|
||||
await Promise.all([first.instance.get('/first/'), second.instance.get('/second/')]);
|
||||
assert.equal(lockCalls, 2); assert.equal(refreshCalls, 1); assert.equal(second.auth.getAccessToken(), 'access.rotated');
|
||||
});
|
||||
test('a new identity can refresh while a previous identity refresh is still pending', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let releaseOld;
|
||||
h.instance.defaults.adapter = config => config.headers.Authorization === 'Bearer access.current' ? Promise.resolve(response(config, {})) : Promise.reject(failure(config, 401));
|
||||
h.axios.defaults.adapter = config => JSON.parse(config.data).refresh === 'refresh.old' ? new Promise(done => { releaseOld = () => done(response(config, { access: 'access.stale', refresh: 'refresh.stale' })); }) : Promise.resolve(response(config, { access: 'access.current', refresh: 'refresh.current' }));
|
||||
const old = h.instance.get('/previous-person/'); await settle(); h.auth.saveTokens({ access: 'access.new-person', refresh: 'refresh.new-person' });
|
||||
await h.instance.get('/current-person/'); releaseOld(); await assert.rejects(old);
|
||||
assert.equal(h.auth.getAccessToken(), 'access.current'); assert.equal(h.redirects.length, 0);
|
||||
});
|
||||
test('protected UI with a stale routing cookie and no real session never displays children', () => {
|
||||
const h = harness(); h.document.cookie = 'isAuth=true'; const guard = h.load('src/components/auth/session-guard.tsx').default;
|
||||
const tree = h.render(guard, { children: 'Private customer data' });
|
||||
assert.ok(!textContent(tree).includes('Private customer data')); assert.equal(h.queryOptions.enabled, false);
|
||||
assert.deepEqual(h.redirects, ['/login?reason=session']);
|
||||
});
|
||||
test('protected session checks revalidate on focus and periodically, preserving network failure for retry', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); const guard = h.load('src/components/auth/session-guard.tsx').default;
|
||||
h.render(guard, { children: 'Private data' }); h.render(guard, { children: 'Private data' });
|
||||
assert.equal(h.queryOptions.enabled, true); assert.equal(h.queryOptions.retry, false); assert.equal(h.queryOptions.refetchOnWindowFocus, true); assert.equal(h.queryOptions.refetchInterval, 60000);
|
||||
h.queryState.isError = true; h.queryState.error = failure({}, 503, { detail: 'ارتباط موقتاً قطع است' });
|
||||
const tree = h.render(guard, { children: 'Private data' });
|
||||
assert.ok(textContent(tree).includes('ارتباط موقتاً قطع است')); assert.ok(!textContent(tree).includes('Private data'));
|
||||
assert.equal(h.auth.getRefreshToken(), 'refresh.old'); assert.equal(h.redirects.length, 0);
|
||||
});
|
||||
test('logout or identity change in another tab clears protected cached data', () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); const guard = h.load('src/components/auth/session-guard.tsx').default;
|
||||
h.render(guard, { children: 'Private data' }); h.render(guard, { children: 'Private data' });
|
||||
h.local.delete('access'); h.local.delete('refresh'); h.local.delete('authSession');
|
||||
h.listeners.get('storage')({ key: 'authSession', newValue: null });
|
||||
assert.equal(h.queryClient.clears, 1); assert.deepEqual(h.redirects, ['/login?reason=session']);
|
||||
assert.ok(!textContent(h.render(guard, { children: 'Private data' })).includes('Private data'));
|
||||
});
|
||||
|
||||
test('invalid stored OTP mobile falls back to the mobile form', () => {
|
||||
const h = harness(); h.session.set('loginMobile', 'undefined'); const form = customerForm(h, { initialStep: 'verify' });
|
||||
assert.ok(elements(form.render(), item => item.props?.id === 'mobile').length);
|
||||
});
|
||||
test('new login in another tab survives this tab previously clearing its session', () => {
|
||||
const local = new Map(); const first = harness({ local }); const second = harness({ local });
|
||||
first.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); first.auth.clearSession();
|
||||
second.auth.saveTokens({ access: 'access.new-person', refresh: 'refresh.new-person' });
|
||||
assert.equal(first.auth.getAccessToken(), 'access.new-person'); assert.equal(first.auth.getRefreshToken(), 'refresh.new-person');
|
||||
});
|
||||
test('late successful response from a previous identity is rejected instead of displaying old data', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
h.instance.defaults.adapter = config => { h.auth.saveTokens({ access: 'access.other-person', refresh: 'refresh.other-person' }); return Promise.resolve(response(config, { private: 'previous person' })); };
|
||||
await assert.rejects(h.instance.get('/orders/'), /نشست/);
|
||||
assert.equal(h.auth.getAccessToken(), 'access.other-person');
|
||||
});
|
||||
test('old request sees another-tab rotation and retries without issuing another refresh', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let first = true; let refreshes = 0;
|
||||
h.axios.defaults.adapter = config => { refreshes++; return Promise.resolve(response(config, {})); };
|
||||
h.instance.defaults.adapter = config => {
|
||||
if (first) { first = false; h.auth.saveTokens({ access: 'access.rotated', refresh: 'refresh.rotated' }, 'refresh.old'); return Promise.reject(failure(config, 401)); }
|
||||
assert.equal(config.headers.Authorization, 'Bearer access.rotated'); return Promise.resolve(response(config, {}));
|
||||
};
|
||||
await h.instance.get('/orders/'); assert.equal(refreshes, 0);
|
||||
});
|
||||
test('navigation wrappers both use the shared form and preserve the verification route', () => {
|
||||
const h = harness(); const login = h.load('src/app/(login_register)/login/page.tsx').default;
|
||||
const verify = h.load('src/app/(login_register)/login/verify/page.tsx').default;
|
||||
const form = h.load('src/components/auth/customer-login-form.tsx').default;
|
||||
assert.equal(h.render(login).type, form); assert.equal(h.render(verify).type, form);
|
||||
assert.equal(h.render(verify).props.initialStep, 'verify');
|
||||
});
|
||||
test('logout button handles same-render repeated clicks and becomes disabled', async () => {
|
||||
const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let calls = 0;
|
||||
h.axios.defaults.adapter = config => { calls++; return Promise.resolve(response(config, null, 204)); };
|
||||
const component = h.load('src/app/components/layout/customer-info.tsx').default;
|
||||
const button = elements(h.render(component), item => item.type === 'button')[0];
|
||||
button.props.onClick(); button.props.onClick(); await settle();
|
||||
assert.equal(calls, 1); assert.equal(elements(h.render(component), item => item.type === 'button')[0].props.disabled, true);
|
||||
});
|
||||
|
||||
test('tabs without Web Locks serialize rotating refresh through an expiring storage lease', async () => {
|
||||
const local = new Map(); const first = harness({ local, locks: null }); const second = harness({ local, locks: null });
|
||||
first.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let requests = 0;
|
||||
for (const h of [first, second]) {
|
||||
h.instance.defaults.adapter = config => config.headers.Authorization === 'Bearer access.old' ? Promise.reject(failure(config, 401)) : Promise.resolve(response(config, {}));
|
||||
h.axios.defaults.adapter = async config => { requests++; await settle(); return response(config, { access: 'access.rotated', refresh: 'refresh.rotated' }); };
|
||||
}
|
||||
await Promise.all([first.instance.get('/first/'), second.instance.get('/second/')]);
|
||||
assert.equal(requests, 1); assert.equal(local.has('customerRefreshLease'), false);
|
||||
});
|
||||
test('an abandoned expired refresh lease cannot block a later refresh', async () => {
|
||||
const h = harness({ locks: null }); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' });
|
||||
h.local.set('customerRefreshLease', JSON.stringify({ owner: 'closed-tab', identity: h.auth.getSessionIdentity(), expires: 0 }));
|
||||
h.instance.defaults.adapter = config => config.headers.Authorization === 'Bearer access.old' ? Promise.reject(failure(config, 401)) : Promise.resolve(response(config, {}));
|
||||
h.axios.defaults.adapter = config => Promise.resolve(response(config, { access: 'access.new', refresh: 'refresh.new' }));
|
||||
await h.instance.get('/orders/'); assert.equal(h.auth.getAccessToken(), 'access.new'); assert.equal(h.local.has('customerRefreshLease'), false);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
|
||||
// Render the actual pages with cached React Query responses; no onSuccess call.
|
||||
function pageHarness(filename, data) {
|
||||
const state = { data: { data }, isSuccess: true, isLoading: false, isError: false, dataUpdatedAt: 1 };
|
||||
const module = { exports: {} }; let options;
|
||||
const source = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true, jsx: ts.JsxEmit.ReactJSX,
|
||||
} }).outputText;
|
||||
const requireStub = name => {
|
||||
if (name === 'react/jsx-runtime') return require(name);
|
||||
if (name === 'react') return { useEffect() {}, useState: () => [undefined, () => {}] };
|
||||
if (name === '@tanstack/react-query') return { useQuery: value => { options = value; return state; } };
|
||||
if (name === 'next/navigation') return { useRouter: () => ({ push() {} }) };
|
||||
if (name === '@/axios/axiosInstance') return { __esModule: true, default: { get() {} } };
|
||||
if (name === '@/components/ui/table') return Object.fromEntries(['Table', 'TableBody', 'TableCaption', 'TableCell', 'TableFooter', 'TableHead', 'TableHeader', 'TableRow'].map(key => [key, key]));
|
||||
return { __esModule: true, default: name.endsWith('products-cat') ? 'CategoryCard' : name.endsWith('products') ? 'ProductCard' : 'Image' };
|
||||
};
|
||||
vm.runInNewContext('(function(require,module,exports){' + source + '\n})', {})(requireStub, module, module.exports);
|
||||
return { state, render: () => module.exports.default({ params: { id: '9' } }), get options() { return options; } };
|
||||
}
|
||||
function elements(tree, type) {
|
||||
if (tree == null || typeof tree !== 'object') return [];
|
||||
if (Array.isArray(tree)) return tree.flatMap(item => elements(item, type));
|
||||
return [...(tree.type === type ? [tree] : []), ...elements(tree.props?.children, type)];
|
||||
}
|
||||
const categoryPage = 'src/app/(index)/shop/page.tsx';
|
||||
const productPage = 'src/app/(index)/shop/product/[id]/page.tsx';
|
||||
|
||||
test('shop displays cached categories in the API order, including after updated data', () => {
|
||||
const h = pageHarness(categoryPage, [{ id: 8, name: 'Second alphabetically' }, { id: 1, name: 'First alphabetically' }]);
|
||||
assert.deepEqual(elements(h.render(), 'CategoryCard').map(card => card.props.iteam.id), [8, 1]);
|
||||
h.state.data = { data: [{ id: 1 }, { id: 8 }] }; h.state.dataUpdatedAt++;
|
||||
assert.deepEqual(elements(h.render(), 'CategoryCard').map(card => card.props.iteam.id), [1, 8]);
|
||||
assert.equal(h.options.refetchOnWindowFocus, true);
|
||||
});
|
||||
test('category page preserves cached and refreshed product order for both product groups', () => {
|
||||
const data = { category_name: 'Category', category_img: null,
|
||||
products: [{ id: 20, name: 'Z', view_only: false }, { id: 2, name: 'A', view_only: false }],
|
||||
view_only_products: [{ id: 19, name: 'Custom Z' }, { id: 1, name: 'Custom A' }] };
|
||||
const h = pageHarness(productPage, data);
|
||||
assert.deepEqual(elements(h.render(), 'ProductCard').map(card => card.props.iteam.id), [20, 2]);
|
||||
assert.deepEqual(elements(h.render(), 'TableCell').filter(cell => cell.props.children !== '-----').map(cell => cell.props.children), ['Custom Z', 'Custom A']);
|
||||
h.state.data = { data: { ...data, products: [...data.products].reverse(), view_only_products: [...data.view_only_products].reverse() } };
|
||||
assert.deepEqual(elements(h.render(), 'ProductCard').map(card => card.props.iteam.id), [2, 20]);
|
||||
assert.deepEqual(elements(h.render(), 'TableCell').filter(cell => cell.props.children !== '-----').map(cell => cell.props.children), ['Custom A', 'Custom Z']);
|
||||
});
|
||||
test('a category without custom products renders its ordered ordinary products', () => {
|
||||
const h = pageHarness(productPage, { category_name: 'Category', category_img: null, products: [{ id: 3, view_only: false }], view_only_products: null });
|
||||
assert.deepEqual(elements(h.render(), 'ProductCard').map(card => card.props.iteam.id), [3]);
|
||||
assert.equal(elements(h.render(), 'TableRow').length, 0);
|
||||
});
|
||||
Reference in new issue
Block a user