Compare commits

..
4 Commits
Author SHA1 Message Date
javad 93873228ff fix order 2026-10-09 11:06:09 +03:30
javad c6d732ff10 fix bug sms 2026-10-09 10:32:47 +03:30
javad 880ac58042 fix login bug 2026-10-09 10:06:34 +03:30
javad 4d350c0278 fix some 2026-10-08 20:26:47 +03:30
16 changed files with 1740 additions and 370 deletions

No files matched your search

+6
View File
@@ -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.
+652
View File
@@ -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
+15 -22
View File
@@ -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 (
+8 -13
View File
@@ -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'>
+3 -71
View File
@@ -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
+3 -121
View File
@@ -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
+12 -16
View File
@@ -1,33 +1,29 @@
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>
)
}
+17 -28
View File
@@ -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
@@ -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">
+109 -74
View File
@@ -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)
}
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
+166
View File
@@ -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>
}
+51
View File
@@ -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
View File
@@ -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
}
}
+4 -23
View File
@@ -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).*)'],
}
+494
View File
@@ -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);
});
+57
View File
@@ -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);
});