diff --git a/src/.ripwire_quality_acks b/src/.ripwire_quality_acks new file mode 100644 index 0000000..8532283 --- /dev/null +++ b/src/.ripwire_quality_acks @@ -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 <16-hex-key> [cid=<16-hex-content-id>] [by=] — 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. diff --git a/src/.ripwire_quality_baseline b/src/.ripwire_quality_baseline new file mode 100644 index 0000000..87a33e9 --- /dev/null +++ b/src/.ripwire_quality_baseline @@ -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 diff --git a/src/app/(index)/shop/product/[id]/page.tsx b/src/app/(index)/shop/product/[id]/page.tsx index 4f85852..d575257 100644 --- a/src/app/(index)/shop/product/[id]/page.tsx +++ b/src/app/(index)/shop/product/[id]/page.tsx @@ -21,12 +21,12 @@ const Page = ({ params }: { params: { id: string } })=>{ const router = useRouter(); - const [products, setProducts] = useState<{category_name: string, category_img: string, + const [products, setProducts] = useState<{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}] }>() - const query = useQuery({ queryKey: ['productListByCat'], queryFn: async ()=>{ + const query = useQuery({ queryKey: ['productListByCat', params.id], queryFn: async ()=>{ return await axiosInstance.get(`shop/product/list/${params.id}/`) }, onSuccess(result:any) { setProducts(result.data) @@ -57,7 +57,7 @@ const Page = ({ params }: { params: { id: string } })=>{
- + {products?.category_img && {products.category_name}}
diff --git a/src/app/(login_register)/login/page.tsx b/src/app/(login_register)/login/page.tsx index a2972c0..bc9e33c 100644 --- a/src/app/(login_register)/login/page.tsx +++ b/src/app/(login_register)/login/page.tsx @@ -1,75 +1,46 @@ 'use client' -import { Fragment, useState } from "react" +import { FormEvent, useEffect, useState } from 'react' +import axios from 'axios' import { useRouter } from 'next/navigation' import { useDispatch } from 'react-redux' -import { addMobile } from "@/app/redux/features/mobileoptSlice" - - -const Page = ()=>{ - const [username, setUserName] = useState() - const [password, setPassword] = useState() +import { addMobile } from '@/app/redux/features/mobileoptSlice' +import { apiErrorMessage } from '@/lib/auth' +export default function Page() { + const [mobile, setMobile] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) const router = useRouter() const dispatch = useDispatch() + useEffect(() => { + const reason = new URLSearchParams(window.location.search).get('reason') + if (reason === 'logout-unconfirmed') setError('از این دستگاه خارج شدید؛ به علت قطع ارتباط، ابطال نشست در سرور تأیید نشد.') + if (reason === 'session') setError('نشست شما منقضی یا باطل شده است. دوباره وارد شوید.') + }, []) - 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'){ + async function login(event: FormEvent) { + event.preventDefault() + setError('') + setBusy(true) + try { + const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/login/mobile/', { mobile }, { timeout: 15000 }) + const number = response.data?.result?.mobile + if (!number) throw new Error('پاسخ ارسال کد معتبر نیست. دوباره تلاش کنید.') + dispatch(addMobile(number)) + window.sessionStorage.setItem('loginMobile', String(number)) router.push('/login/verify/') - } + } catch (failure) { setError(apiErrorMessage(failure)) } + finally { setBusy(false) } } - return ( - -
-
- -

ورود به سامانه مشتریان

- -
- - - { onChangeMobile(e) }} - className="mt-1 w-full h-[45px] p-5 rounded-md border-gray-200 shadow-sm sm:text-sm" - /> -
- - { login() }} type="submit" className="bg-slate-900 text-white rounded p-2" value={"ورود"} /> - -
-
-
- ) + return
+
+

ورود به سامانه مشتریان

+ + setMobile(e.target.value)} placeholder="شماره موبایل را وارد کنید" className="w-full h-[45px] p-5 rounded-md shadow-sm" /> + {error &&

{error}

} + +
+
} - -export default Page \ No newline at end of file diff --git a/src/app/(login_register)/login/verify/page.tsx b/src/app/(login_register)/login/verify/page.tsx index f46dc55..230db15 100644 --- a/src/app/(login_register)/login/verify/page.tsx +++ b/src/app/(login_register)/login/verify/page.tsx @@ -1,125 +1,62 @@ '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" - -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=/`; -} - -const Page = ()=>{ +import { FormEvent, useEffect, useState } from 'react' +import axios from 'axios' +import { useSelector } from 'react-redux' +import { RootState } from '@/app/redux/store' +import { useRouter } from 'next/navigation' +import { apiErrorMessage, LoginOption, obtainTokens } from '@/lib/auth' +export default function Page() { const router = useRouter() + const reduxMobile = useSelector((state: RootState) => state.mobileopt.mobile) + const [mobile, setMobile] = useState('') + const [code, setCode] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [options, setOptions] = useState([]) + useEffect(() => { + const value = reduxMobile || window.sessionStorage.getItem('loginMobile') + if (!value) router.replace('/login') + else setMobile(String(value)) + }, [reduxMobile, router]) - 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) + async function choose(option: LoginOption) { + setBusy(true) + setError('') + try { + await obtainTokens(option.uuid) + window.sessionStorage.removeItem('loginMobile') + window.location.replace('/shop') + } catch (failure) { setError(apiErrorMessage(failure)) } + finally { setBusy(false) } } - 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, - }) - })*/ - - } - + async function verify(event: FormEvent) { + event.preventDefault() + if (!mobile) return + const digits = code.replace(/[۰-۹]/g, digit => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(digit))).replace(/[٠-٩]/g, digit => String('٠١٢٣٤٥٦٧٨٩'.indexOf(digit))) + if (!/^\d{6}$/.test(digits)) { setError('کد شش‌رقمی پیامک را وارد کنید.'); return } + setBusy(true) + setError('') + try { + const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/login/mobile/verify/', { mobile, opt: digits }, { timeout: 10000 }) + const roles: LoginOption[] = response.data + if (!Array.isArray(roles) || !roles.length) throw new Error('دسترسی فعالی برای ورود وجود ندارد.') + setOptions(roles) + if (roles.length === 1) await choose(roles[0]) + } catch (failure) { setError(apiErrorMessage(failure)) } + finally { setBusy(false) } } - - return( - <> -
-
- -

ورود به سامانه مشتریان

-

- کد تایید پیامک شده را وارد کنید -

-
- - { inputOpt(event) }} - placeholder="کد تایید را وارد کنید" - className="mt-1 w-full h-[45px] p-5 rounded-md border-gray-200 shadow-sm sm:text-sm" - /> -
- - { veifyLogin(optCode) }} type="submit" className="bg-slate-900 text-white rounded p-2" value={"ورود به سامانه"} /> - -
-
- - ) + return
+
+

ورود به سامانه مشتریان

+

کد تایید پیامک‌شده را وارد کنید

+ {!options.length && <> setCode(e.target.value)} maxLength={6} required className="w-full h-[45px] p-5 rounded-md shadow-sm" />} + {options.length > 0 &&

پنل موردنظر را انتخاب کنید:

{options.map(option => )}
} + {error &&

{error}

} + +
+
} - -export default Page \ No newline at end of file diff --git a/src/app/components/layout/customer-info.tsx b/src/app/components/layout/customer-info.tsx index b9d77a7..ddbab02 100644 --- a/src/app/components/layout/customer-info.tsx +++ b/src/app/components/layout/customer-info.tsx @@ -1,22 +1,14 @@ import axiosInstance from "@/axios/axiosInstance" import { useQuery } from "@tanstack/react-query" import { useState } from "react" -import { useRouter } from 'next/navigation' +import { logout } from '@/lib/auth' const CustomerInfo = ()=>{ - const router = useRouter(); const [nameFamily, setNameFamily] = useState(null) - 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 = () => { void logout() } const query = useQuery({ queryKey: ['cuser-info-x1'], queryFn: async ()=>{ return await axiosInstance.get("cuser/info/") diff --git a/src/app/components/layout/shop/products-cat.tsx b/src/app/components/layout/shop/products-cat.tsx index 4f63055..57556b6 100644 --- a/src/app/components/layout/shop/products-cat.tsx +++ b/src/app/components/layout/shop/products-cat.tsx @@ -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)=>{

{props.iteam.name}

- a + {props.iteam.image ? a :
بدون تصویر
}
diff --git a/src/axios/axiosInstance.ts b/src/axios/axiosInstance.ts index 169ef40..8074448 100644 --- a/src/axios/axiosInstance.ts +++ b/src/axios/axiosInstance.ts @@ -1,88 +1,64 @@ '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, saveTokens } 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 } +class InvalidSession extends Error {} +let pendingRefresh: Promise | null = null 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.response.use((response)=> { - return response -}, async (error) =>{ +axiosInstance.interceptors.request.use(request => { + const access = getAccessToken() + if (access) request.headers.Authorization = 'Bearer ' + access + else delete request.headers.Authorization + return request +}) - const originalRequest = error.config; - - if (error.response.status === 401 && !originalRequest._retry) { - - originalRequest._retry = true; // Prevent infinite loops - - 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) - - } - - } 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; - } +async function refreshAccess(): Promise { + const refresh = getRefreshToken() + if (!refresh) throw new InvalidSession() + try { + const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/token/refresh/', { refresh }, { timeout: 10000 }) + if (getRefreshToken() !== refresh) throw new Error('نشست تغییر کرده است.') + if (!response.data?.access || !response.data?.refresh) throw new InvalidSession() + saveTokens(response.data) + return response.data.access + } catch (error) { + if (axios.isAxiosError(error) && [400, 401, 403].includes(error.response?.status || 0)) throw new InvalidSession() + throw error } -}); +} -export default axiosInstance \ No newline at end of file +axiosInstance.interceptors.response.use(response => 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) + if (request._retry) { + const latest = getAccessToken() + if (!latest || request.headers.Authorization === 'Bearer ' + latest) expireSession() + return Promise.reject(error) + } + request._retry = true + // Another request may already have refreshed the token that this request used. + const currentAccess = getAccessToken() + if (currentAccess && request.headers.Authorization !== 'Bearer ' + currentAccess) { + request.headers.Authorization = 'Bearer ' + currentAccess + return axiosInstance(request) + } + const refresh = getRefreshToken() + try { + if (!pendingRefresh) pendingRefresh = refreshAccess().finally(() => { pendingRefresh = null }) + request.headers.Authorization = 'Bearer ' + await pendingRefresh + return axiosInstance(request) + } catch (refreshError) { + if (refreshError instanceof InvalidSession && getRefreshToken() === refresh) expireSession() + return Promise.reject(refreshError) + } +}) + +export default axiosInstance diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..27bf579 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,76 @@ +'use client' + +import axios from 'axios' + +const splitTokens = false +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 } } } + +function readToken(key: 'access' | 'refresh'): string | null { + if (typeof window === 'undefined') return null + const value = window.localStorage.getItem(key) + if (!value || value === 'undefined' || value === 'null') return null + return value + (splitTokens ? window.localStorage.getItem(key === 'access' ? '0a' : '0r') || '' : '') +} +export const getAccessToken = () => readToken('access') +export const getRefreshToken = () => readToken('refresh') + +export function saveTokens(data: Tokens): void { + if (!data || typeof data.access !== 'string' || !data.access || typeof data.refresh !== 'string' || !data.refresh) { + throw new Error('پاسخ ورود معتبر نیست. دوباره تلاش کنید.') + } + for (const key of ['access', 'refresh'] as const) { + window.localStorage.setItem(key, splitTokens ? data[key].slice(0, -2) : data[key]) + const suffix = key === 'access' ? '0a' : '0r' + if (splitTokens) window.localStorage.setItem(suffix, data[key].slice(-2)) + else window.localStorage.removeItem(suffix) + } + document.cookie = 'isAuth=true;max-age=2592000;path=/;SameSite=Lax' +} + +export function clearSession(): void { + if (typeof window === 'undefined') return + for (const key of ['access', 'refresh', '0a', '0r', 'nameFamily']) window.localStorage.removeItem(key) + window.sessionStorage.removeItem('loginMobile') + 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') + } +} + +export async function logout(): Promise { + const refresh = getRefreshToken() + clearSession() // Pending refresh responses cannot restore this session. + let destination = '/login' + try { + if (refresh) await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/logout/', { refresh }, { timeout: 5000 }) + } catch { + destination = '/login?reason=logout-unconfirmed' + } finally { + window.location.replace(destination) + } +} + +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.values(value).flatMap(collectMessages) + 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 { + const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/token/', { uuid_opt: uuid }, { timeout: 10000 }) + saveTokens(response.data) +} diff --git a/src/middleware.ts b/src/middleware.ts index 8996460..94fa530 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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).*)', - ], - } \ No newline at end of file + matcher: ['/((?!api|_next/static|_next/image|favicon.ico|login|register).*)'], +} diff --git a/tests/auth-session.test.cjs b/tests/auth-session.test.cjs new file mode 100644 index 0000000..9434ab8 --- /dev/null +++ b/tests/auth-session.test.cjs @@ -0,0 +1,196 @@ +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() { + const local = new Map(); const session = new Map(); const redirects = []; + 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', replace: value => redirects.push(value) } }; + 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] = initial; + return [hooks[index], value => { hooks[index] = typeof value === 'function' ? value(hooks[index]) : value; }]; + }, useEffect: effect => effect() }; + const dispatches = []; + function render(component) { cursor = 0; return component(); } + 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 === '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, process, Error, Promise, URLSearchParams, console })(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, load, render, dispatches }; +} +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/app/(login_register)/login/page.tsx').default; + 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/app/(login_register)/login/page.tsx').default; + 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/app/(login_register)/login/verify/page.tsx').default; + h.render(component); + let tree = h.render(component); + elements(tree, item => item.type === 'input')[0].props.onChange({ target: { value: '۱۲۳۴۵۶' } }); + h.axios.defaults.adapter = config => Promise.reject(failure(config, 400, { opt: ['کد تأیید اشتباه است'] })); + await elements(h.render(component), item => item.type === 'form')[0].props.onSubmit({ preventDefault() {} }); + tree = h.render(component); + 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); + });