diff --git a/.env.example b/.env.example index 408e04d..ce51476 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,6 @@ VITE_UPLOAD_TIMEOUT_MS=60000 # Maximum SSE reconnect attempts VITE_SSE_RECONNECT_MAX=3 + +# Optional V3 install URL override +VITE_V3_INSTALL_URL= diff --git a/package.json b/package.json index 71dbdc8..bd6af5b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "pnpm": { "overrides": { "brace-expansion@^1.1.7": "^1.1.13", - "brace-expansion@^5.0.2": "^5.0.5", + "brace-expansion@^5.0.2": "^5.0.6", "fast-uri": "^3.1.2", "flatted": "^3.4.2", "lodash": "^4.18.1", @@ -64,13 +64,13 @@ "secretlint": "^11.3.0", "typescript": "^5.9.3", "vite": "^7.3.2", - "vitest": "^3.2.4" + "vitest": "^4.1.8" }, "dependencies": { "@tanstack/react-router": "^1.166.3", "@vanilla-extract/css": "^1.18.0", "antd": "^6.3.2", - "axios": "1.15.2", + "axios": "1.16.1", "react": "^19.1.1", "react-dom": "^19.1.1", "zustand": "5.0.12" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d86e180..9a248d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: overrides: brace-expansion@^1.1.7: ^1.1.13 - brace-expansion@^5.0.2: ^5.0.5 + brace-expansion@^5.0.2: ^5.0.6 fast-uri: ^3.1.2 flatted: ^3.4.2 lodash: ^4.18.1 @@ -28,8 +28,8 @@ importers: specifier: ^6.3.2 version: 6.3.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) axios: - specifier: 1.15.2 - version: 1.15.2 + specifier: 1.16.1 + version: 1.16.1 react: specifier: ^19.1.1 version: 19.2.4 @@ -92,8 +92,8 @@ importers: specifier: ^7.3.2 version: 7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + specifier: ^4.1.8 + version: 4.1.8(@types/node@24.12.0)(vite@7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) packages: '@ant-design/colors@8.0.1': @@ -1422,6 +1422,12 @@ packages: } engines: { node: '>=18' } + '@standard-schema/spec@1.1.0': + resolution: + { + integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, + } + '@tanstack/history@1.161.4': resolution: { @@ -1676,54 +1682,54 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitest/expect@3.2.4': + '@vitest/expect@4.1.8': resolution: { - integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==, + integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==, } - '@vitest/mocker@3.2.4': + '@vitest/mocker@4.1.8': resolution: { - integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==, + integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==, } peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@3.2.4': + '@vitest/pretty-format@4.1.8': resolution: { - integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==, + integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==, } - '@vitest/runner@3.2.4': + '@vitest/runner@4.1.8': resolution: { - integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==, + integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==, } - '@vitest/snapshot@3.2.4': + '@vitest/snapshot@4.1.8': resolution: { - integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==, + integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==, } - '@vitest/spy@3.2.4': + '@vitest/spy@4.1.8': resolution: { - integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==, + integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==, } - '@vitest/utils@3.2.4': + '@vitest/utils@4.1.8': resolution: { - integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==, + integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==, } acorn-jsx@5.3.2: @@ -1742,6 +1748,13 @@ packages: engines: { node: '>=0.4.0' } hasBin: true + agent-base@6.0.2: + resolution: + { + integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==, + } + engines: { node: '>= 6.0.0' } + ajv@6.14.0: resolution: { @@ -1880,10 +1893,10 @@ packages: } engines: { node: '>= 0.4' } - axios@1.15.2: + axios@1.16.1: resolution: { - integrity: sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==, + integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==, } balanced-match@1.0.2: @@ -1926,10 +1939,10 @@ packages: integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==, } - brace-expansion@5.0.5: + brace-expansion@5.0.6: resolution: { - integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==, + integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==, } engines: { node: 18 || 20 || >=22 } @@ -2002,10 +2015,10 @@ packages: integrity: sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==, } - chai@5.3.3: + chai@6.2.2: resolution: { - integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==, + integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==, } engines: { node: '>=18' } @@ -2023,13 +2036,6 @@ packages: } engines: { node: ^12.17.0 || ^14.13 || >=16.0.0 } - check-error@2.1.3: - resolution: - { - integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==, - } - engines: { node: '>= 16' } - cli-cursor@5.0.0: resolution: { @@ -2222,13 +2228,6 @@ packages: babel-plugin-macros: optional: true - deep-eql@5.0.2: - resolution: - { - integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==, - } - engines: { node: '>=6' } - deep-is@0.1.4: resolution: { @@ -2348,6 +2347,12 @@ packages: integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==, } + es-module-lexer@2.1.0: + resolution: + { + integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==, + } + es-object-atoms@1.1.1: resolution: { @@ -2847,6 +2852,13 @@ packages: } engines: { node: ^16.14.0 || >=18.0.0 } + https-proxy-agent@5.0.1: + resolution: + { + integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==, + } + engines: { node: '>= 6' } + ignore@5.3.2: resolution: { @@ -3122,12 +3134,6 @@ packages: integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, } - js-tokens@9.0.1: - resolution: - { - integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==, - } - js-yaml@4.1.1: resolution: { @@ -3342,12 +3348,6 @@ packages: } engines: { node: '>=18' } - loupe@3.2.1: - resolution: - { - integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==, - } - lru-cache@10.4.3: resolution: { @@ -3520,6 +3520,12 @@ packages: } engines: { node: '>= 0.4' } + obug@2.1.1: + resolution: + { + integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==, + } + onetime@7.0.0: resolution: { @@ -3630,13 +3636,6 @@ packages: integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, } - pathval@2.0.1: - resolution: - { - integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==, - } - engines: { node: '>= 14.16' } - picocolors@1.1.1: resolution: { @@ -4108,10 +4107,10 @@ packages: integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==, } - std-env@3.10.0: + std-env@4.1.0: resolution: { - integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==, + integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==, } stop-iteration-iterator@1.1.0: @@ -4204,12 +4203,6 @@ packages: } engines: { node: '>=8' } - strip-literal@3.1.0: - resolution: - { - integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==, - } - structured-source@4.0.0: resolution: { @@ -4303,12 +4296,6 @@ packages: integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==, } - tinyexec@0.3.2: - resolution: - { - integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==, - } - tinyexec@1.0.2: resolution: { @@ -4323,24 +4310,10 @@ packages: } engines: { node: '>=12.0.0' } - tinypool@1.1.1: - resolution: - { - integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==, - } - engines: { node: ^18.0.0 || >=20.0.0 } - - tinyrainbow@2.0.0: + tinyrainbow@3.1.0: resolution: { - integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==, - } - engines: { node: '>=14.0.0' } - - tinyspy@4.0.4: - resolution: - { - integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==, + integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==, } engines: { node: '>=14.0.0' } @@ -4544,29 +4517,42 @@ packages: yaml: optional: true - vitest@3.2.4: + vitest@4.1.8: resolution: { - integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==, + integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==, } - engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 } + engines: { node: ^20.0.0 || ^22.0.0 || >=24.0.0 } hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/debug': ^4.1.12 - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.4 - '@vitest/ui': 3.2.4 + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true - '@types/debug': + '@opentelemetry/api': optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': optional: true '@vitest/ui': optional: true @@ -5551,6 +5537,8 @@ snapshots: '@sindresorhus/merge-streams@2.3.0': {} + '@standard-schema/spec@1.1.0': {} + '@tanstack/history@1.161.4': {} '@tanstack/react-router@1.166.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -5806,47 +5794,46 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/expect@3.2.4': + '@vitest/expect@4.1.8': dependencies: + '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - tinyrainbow: 2.0.0 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.8(vite@7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@vitest/spy': 3.2.4 + '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/pretty-format@3.2.4': + '@vitest/pretty-format@4.1.8': dependencies: - tinyrainbow: 2.0.0 + tinyrainbow: 3.1.0 - '@vitest/runner@3.2.4': + '@vitest/runner@4.1.8': dependencies: - '@vitest/utils': 3.2.4 + '@vitest/utils': 4.1.8 pathe: 2.0.3 - strip-literal: 3.1.0 - '@vitest/snapshot@3.2.4': + '@vitest/snapshot@4.1.8': dependencies: - '@vitest/pretty-format': 3.2.4 + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@3.2.4': - dependencies: - tinyspy: 4.0.4 + '@vitest/spy@4.1.8': {} - '@vitest/utils@3.2.4': + '@vitest/utils@4.1.8': dependencies: - '@vitest/pretty-format': 3.2.4 - loupe: 3.2.1 - tinyrainbow: 2.0.0 + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 acorn-jsx@5.3.2(acorn@8.16.0): dependencies: @@ -5854,6 +5841,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -6003,13 +5996,15 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axios@1.15.2: + axios@1.16.1: dependencies: follow-redirects: 1.16.0 form-data: 4.0.5 + https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color balanced-match@1.0.2: {} @@ -6028,7 +6023,7 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -6072,13 +6067,7 @@ snapshots: caniuse-lite@1.0.30001777: {} - chai@5.3.3: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.3 - deep-eql: 5.0.2 - loupe: 3.2.1 - pathval: 2.0.1 + chai@6.2.2: {} chalk@4.1.2: dependencies: @@ -6087,8 +6076,6 @@ snapshots: chalk@5.6.2: {} - check-error@2.1.3: {} - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -6177,8 +6164,6 @@ snapshots: dedent@1.7.2: {} - deep-eql@5.0.2: {} - deep-is@0.1.4: {} deep-object-diff@1.1.9: {} @@ -6286,6 +6271,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -6640,6 +6627,13 @@ snapshots: dependencies: lru-cache: 10.4.3 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ignore@5.3.2: {} ignore@7.0.5: {} @@ -6791,8 +6785,6 @@ snapshots: js-tokens@4.0.0: {} - js-tokens@9.0.1: {} - js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -6909,8 +6901,6 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 - loupe@3.2.1: {} - lru-cache@10.4.3: {} lru-cache@5.1.1: @@ -6944,7 +6934,7 @@ snapshots: minimatch@10.2.4: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 minimatch@3.1.5: dependencies: @@ -7008,6 +6998,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.1: {} + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -7067,8 +7059,6 @@ snapshots: pathe@2.0.3: {} - pathval@2.0.1: {} - picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -7377,7 +7367,7 @@ snapshots: stackback@0.0.2: {} - std-env@3.10.0: {} + std-env@4.1.0: {} stop-iteration-iterator@1.1.0: dependencies: @@ -7440,10 +7430,6 @@ snapshots: strip-json-comments@3.1.1: {} - strip-literal@3.1.0: - dependencies: - js-tokens: 9.0.1 - structured-source@4.0.0: dependencies: boundary: 2.0.0 @@ -7496,8 +7482,6 @@ snapshots: tinybench@2.9.0: {} - tinyexec@0.3.2: {} - tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -7505,11 +7489,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinypool@1.1.1: {} - - tinyrainbow@2.0.0: {} - - tinyspy@4.0.4: {} + tinyrainbow@3.1.0: {} to-regex-range@5.0.1: dependencies: @@ -7647,46 +7627,32 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitest@3.2.4(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): + vitest@4.1.8(@types/node@24.12.0)(vite@7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3 + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 + obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.4 - std-env: 3.10.0 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.0.2 tinyglobby: 0.2.15 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 + tinyrainbow: 3.1.0 vite: 7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) - vite-node: 3.2.4(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.0 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml which-boxed-primitive@1.1.1: dependencies: diff --git a/src/features/scan-history/api/fetchScanHistoryData.test.ts b/src/features/scan-history/api/fetchScanHistoryData.test.ts new file mode 100644 index 0000000..c4a0bd7 --- /dev/null +++ b/src/features/scan-history/api/fetchScanHistoryData.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { fetchScanDetail } from '@/shared/api/fetchScanDetail'; + +import { fetchScanHistory } from './fetchScanHistory'; +import { fetchRecentScanHistoryData, fetchScanHistoryData } from './fetchScanHistoryData'; + +vi.mock('./fetchScanHistory', () => ({ + fetchScanHistory: vi.fn(), +})); + +vi.mock('@/shared/api/fetchScanDetail', () => ({ + fetchScanDetail: vi.fn(), +})); + +vi.mock('@/shared/store/guestStore', () => ({ + useGuestStore: { + getState: () => ({ + ensureGuestUuid: () => 'guest-1', + guestUuid: 'guest-1', + }), + }, +})); + +const mockedFetchScanHistory = vi.mocked(fetchScanHistory); +const mockedFetchScanDetail = vi.mocked(fetchScanDetail); + +function waitForBackgroundTasks(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +describe('fetchScanHistoryData', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns recent history immediately and enriches risk labels in the background', async () => { + mockedFetchScanHistory.mockResolvedValue([ + { + isUrl: true, + riskLevel: 'SUSPICIOUS', + scannedAt: '2026-06-03T09:00:00', + schemeType: 'URL', + typeInfo: 'https://testsafebrowsing.appspot.com/s/phishing.html', + }, + ]); + mockedFetchScanDetail.mockResolvedValue({ + riskLevel: 'danger', + score: 88, + }); + const onItemsEnriched = vi.fn(); + + const data = await fetchRecentScanHistoryData({ + onItemsEnriched, + }); + + expect(data.items[0]?.status).toBe('warning'); + + await waitForBackgroundTasks(); + + expect(onItemsEnriched).toHaveBeenCalledWith([ + expect.objectContaining({ + status: 'critical', + }), + ]); + }); + + it('keeps the history risk label when detail enrichment fails', async () => { + mockedFetchScanHistory.mockResolvedValue([ + { + isUrl: true, + riskLevel: 'SUSPICIOUS', + scannedAt: '2026-06-03T09:00:00', + schemeType: 'URL', + typeInfo: 'https://example.com', + }, + ]); + mockedFetchScanDetail.mockRejectedValue(new Error('detail unavailable')); + const onItemsEnriched = vi.fn(); + + const data = await fetchRecentScanHistoryData({ + onItemsEnriched, + }); + + expect(data.items[0]?.status).toBe('warning'); + + await waitForBackgroundTasks(); + + expect(onItemsEnriched).not.toHaveBeenCalled(); + }); + + it('does not call detail enrichment for the full history list by default', async () => { + mockedFetchScanHistory.mockResolvedValue([ + { + isUrl: true, + riskLevel: 'SUSPICIOUS', + scannedAt: '2026-06-03T09:00:00', + schemeType: 'URL', + typeInfo: 'https://example.com', + }, + ]); + + const data = await fetchScanHistoryData(); + + expect(data.items[0]?.status).toBe('warning'); + expect(mockedFetchScanDetail).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/scan-history/api/fetchScanHistoryData.ts b/src/features/scan-history/api/fetchScanHistoryData.ts index 5b56eaa..e311a38 100644 --- a/src/features/scan-history/api/fetchScanHistoryData.ts +++ b/src/features/scan-history/api/fetchScanHistoryData.ts @@ -1,3 +1,6 @@ +import { fetchScanDetail } from '@/shared/api/fetchScanDetail'; +import { resolveResultToneFromSource } from '@/shared/api/risk/resolveResultTone'; +import { isWebScanTarget } from '@/shared/lib/scan-session/scanClassification'; import { useGuestStore } from '@/shared/store/guestStore'; import { fetchScanHistory } from './fetchScanHistory'; @@ -12,12 +15,14 @@ import { resolveHistoryTimestamp, } from '../lib/historyItemAccess'; -import type { ScanHistoryData } from '../types/scanHistory.types'; +import type { ScanHistoryData, ScanHistoryItem } from '../types/scanHistory.types'; const maxRecentScanHistoryItemCount = 5; type FetchScanHistoryDataOptions = { + enrichRiskFromDetail?: boolean; limit?: number; + onItemsEnriched?: (items: ScanHistoryItem[]) => void; }; type HistoryItemWithTargetValue = { @@ -72,6 +77,62 @@ function formatScannedAt(rawScannedAt: string | null): string { return `${year}.${month}.${day}`; } +async function resolveDetailRiskLevel(item: ScanHistoryItem): Promise { + if (!isWebScanTarget(item)) { + return item.status; + } + + try { + const detail = await fetchScanDetail(item.url); + return resolveResultToneFromSource(detail) ?? item.status; + } catch { + return item.status; + } +} + +async function enrichHistoryItemRiskLevel(item: ScanHistoryItem): Promise { + const status = await resolveDetailRiskLevel(item); + + if (status === item.status) { + return item; + } + + return { + ...item, + status, + }; +} + +function startHistoryItemRiskEnrichment( + historyItems: ScanHistoryItem[], + onItemsEnriched: (items: ScanHistoryItem[]) => void, +): void { + setTimeout(() => { + void Promise.allSettled(historyItems.map((item) => enrichHistoryItemRiskLevel(item))).then( + (results) => { + const enrichedItemsById = new Map(); + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + enrichedItemsById.set(historyItems[index].id, result.value); + } + }); + + const enrichedHistoryItems = historyItems.map( + (item) => enrichedItemsById.get(item.id) ?? item, + ); + const hasStatusUpdate = enrichedHistoryItems.some( + (item, index) => item !== historyItems[index], + ); + + if (hasStatusUpdate) { + onItemsEnriched(enrichedHistoryItems); + } + }, + ); + }, 0); +} + export async function fetchScanHistoryData( options: FetchScanHistoryDataOptions = {}, ): Promise { @@ -82,24 +143,33 @@ export async function fetchScanHistoryData( options.limit, ); + const historyItems: ScanHistoryItem[] = visibleItems.map(({ item, targetValue }, index) => { + return { + id: buildHistoryItemId(item, index), + isUrl: pickHistoryIsUrl(item), + scannedAt: formatScannedAt(pickHistoryScannedAt(item)), + schemeType: pickHistorySchemeType(item), + status: pickHistoryRiskLevel(item) ?? 'warning', + url: targetValue, + }; + }); + if (options.enrichRiskFromDetail && options.onItemsEnriched) { + startHistoryItemRiskEnrichment(historyItems, options.onItemsEnriched); + } + return { - items: visibleItems.map(({ item, targetValue }, index) => { - return { - id: buildHistoryItemId(item, index), - isUrl: pickHistoryIsUrl(item), - scannedAt: formatScannedAt(pickHistoryScannedAt(item)), - schemeType: pickHistorySchemeType(item), - status: pickHistoryRiskLevel(item) ?? 'warning', - url: targetValue, - }; - }), + items: historyItems, uuid, }; } -export async function fetchRecentScanHistoryData(): Promise { +export async function fetchRecentScanHistoryData( + options: Pick = {}, +): Promise { return fetchScanHistoryData({ + enrichRiskFromDetail: true, limit: maxRecentScanHistoryItemCount, + onItemsEnriched: options.onItemsEnriched, }); } diff --git a/src/features/scan-url/api/submitScanUrl.ts b/src/features/scan-url/api/submitScanUrl.ts new file mode 100644 index 0000000..c21cbef --- /dev/null +++ b/src/features/scan-url/api/submitScanUrl.ts @@ -0,0 +1,19 @@ +import { getUploadTimeoutMs } from '@/shared/api/apiConfig'; +import { axiosBe3 } from '@/shared/api/axios'; +import { apiEndpoints } from '@/shared/api/endpoints'; +import type { BackendScanResponse } from '@/shared/api/types'; + +type SubmitScanUrlPayload = { + url: string; +}; + +export async function submitScanUrl({ url }: SubmitScanUrlPayload): Promise { + const response = await axiosBe3.get(apiEndpoints.scanText, { + params: { + url, + }, + timeout: getUploadTimeoutMs(), + }); + + return response.data; +} diff --git a/src/pages/Loading/hooks/useLoadingPage.ts b/src/pages/Loading/hooks/useLoadingPage.ts index 7deed90..38c8dc7 100644 --- a/src/pages/Loading/hooks/useLoadingPage.ts +++ b/src/pages/Loading/hooks/useLoadingPage.ts @@ -3,6 +3,7 @@ import { App } from 'antd'; import { useCallback, useEffect, useRef } from 'react'; import { resolveResultToneFromSources } from '@/shared/api/risk/resolveResultTone'; +import { showApiError } from '@/shared/lib/feedback/showApiError'; import { ensureScanDetail } from '@/shared/lib/scan-session/ensureScanDetail'; import { isSameScanSource } from '@/shared/lib/scan-session/scanIdentity'; import { resolveScanResultRoute } from '@/shared/lib/scan-session/scanResultRoute'; @@ -11,6 +12,9 @@ import { useGuestStore } from '@/shared/store/guestStore'; import { useScanProgressStore } from '@/shared/store/scanProgressStore'; import { getScanSessionSnapshot, useScanSessionStore } from '@/shared/store/scanSessionStore'; +import { submitScanUrl } from '@/features/scan-url/api/submitScanUrl'; +import { isCaptchaRequiredUploadError } from '@/features/scan-url/api/uploadErrors'; + import { startLoadingDetailPolling } from '../lib/loadingDetailPolling'; import { DETAIL_RESOLUTION_TIMEOUT_MESSAGE, @@ -39,8 +43,11 @@ export function useLoadingPage(): UseLoadingPageReturn { const navigate = useNavigate(); const guestUuid = useGuestStore((state) => state.guestUuid); const finalResult = useScanSessionStore((state) => state.finalResult); + const pendingTextScanUrl = useScanSessionStore((state) => state.pendingTextScanUrl); const scanResponse = useScanSessionStore((state) => state.scanResponse); + const clearPendingTextScanUrl = useScanSessionStore((state) => state.clearPendingTextScanUrl); const setFinalResult = useScanSessionStore((state) => state.setFinalResult); + const setScanResponse = useScanSessionStore((state) => state.setScanResponse); const resetProgress = useScanProgressStore((state) => state.reset); const setCompleted = useScanProgressStore((state) => state.setCompleted); const setConnecting = useScanProgressStore((state) => state.setConnecting); @@ -49,6 +56,7 @@ export function useLoadingPage(): UseLoadingPageReturn { const detailResolutionFailedRef = useRef(false); const detailResolutionStartedRef = useRef(false); const isMountedRef = useRef(true); + const submittedTextScanUrlRef = useRef(null); useEffect(() => { isMountedRef.current = true; @@ -59,7 +67,7 @@ export function useLoadingPage(): UseLoadingPageReturn { }, []); useEffect(() => { - if (!scanResponse && !finalResult) { + if (!pendingTextScanUrl && !scanResponse && !finalResult) { void navigate({ to: '/' }); return; } @@ -67,12 +75,18 @@ export function useLoadingPage(): UseLoadingPageReturn { if (finalResult && scanResponse && isSameScanSource(finalResult, scanResponse)) { return; } + }, [finalResult, navigate, pendingTextScanUrl, scanResponse]); + + useEffect(() => { + if (!pendingTextScanUrl || finalResult) { + return; + } resetProgress(); detailResolutionFailedRef.current = false; detailResolutionStartedRef.current = false; setConnecting(); - }, [finalResult, navigate, resetProgress, scanResponse, setConnecting]); + }, [finalResult, pendingTextScanUrl, resetProgress, setConnecting]); const failDetailResolution = useCallback( (errorMessage: string) => { @@ -127,6 +141,37 @@ export function useLoadingPage(): UseLoadingPageReturn { [resolveDetailAndOpenResult], ); + const submitPendingTextScan = useCallback(() => { + if (!pendingTextScanUrl || submittedTextScanUrlRef.current === pendingTextScanUrl) { + return; + } + + submittedTextScanUrlRef.current = pendingTextScanUrl; + + void submitScanUrl({ + url: pendingTextScanUrl, + }) + .then((nextScanResponse) => { + setScanResponse(nextScanResponse); + clearPendingTextScanUrl(); + message.success('URL 분석을 시작했습니다.'); + }) + .catch((error) => { + clearPendingTextScanUrl(); + submittedTextScanUrlRef.current = null; + + if (isCaptchaRequiredUploadError(error)) { + message.warning('요청 횟수를 초과했습니다. 캡차 인증 후 다시 검사해 주세요.'); + void navigate({ to: '/captcha' }); + return; + } + + console.error('Failed to submit URL scan.', error); + setError('URL 분석 요청에 실패했습니다.'); + showApiError(message, error, 'URL 분석 요청에 실패했습니다.'); + }); + }, [clearPendingTextScanUrl, message, navigate, pendingTextScanUrl, setError, setScanResponse]); + useEffect(() => { if (finalResult || !scanResponse) { return; @@ -146,7 +191,7 @@ export function useLoadingPage(): UseLoadingPageReturn { }, [failDetailResolution, finalResult, resolveDetailAndOpenResult, scanResponse]); useScanSubscription({ - enabled: Boolean(guestUuid) && Boolean(scanResponse || finalResult), + enabled: Boolean(guestUuid) && Boolean(pendingTextScanUrl || scanResponse || finalResult), guestUuid, onError: (errorMessage) => { setError(errorMessage); @@ -171,6 +216,7 @@ export function useLoadingPage(): UseLoadingPageReturn { setCompleted(); openResultRouteForCurrentSession(); }, + onOpen: submitPendingTextScan, onProgress: (payload) => { updateFromProgressEvent(payload); void tryResolveDetailAfterTerminalProgress(payload); diff --git a/src/pages/QRScan/QRScanPage.tsx b/src/pages/QRScan/QRScanPage.tsx index 61404c4..2500066 100644 --- a/src/pages/QRScan/QRScanPage.tsx +++ b/src/pages/QRScan/QRScanPage.tsx @@ -34,6 +34,20 @@ function UploadGlyphIcon() { ); } +function SearchGlyphIcon() { + return ( + + ); +} + function ViewGlyphIcon() { return ( 검사할 URL + + + + + + { + it('prepends https to host-like inputs without a scheme', () => { + expect(normalizeUrlInput('example.com/path')).toBe('https://example.com/path'); + }); + + it('keeps http and https URLs', () => { + expect(normalizeUrlInput('http://example.com')).toBe('http://example.com/'); + expect(normalizeUrlInput('https://example.com')).toBe('https://example.com/'); + }); + + it('rejects non-http schemes instead of rewriting them', () => { + expect(normalizeUrlInput('ftp://example.com/file')).toBeNull(); + }); +}); diff --git a/src/pages/QRScan/hooks/useQRScanPage.ts b/src/pages/QRScan/hooks/useQRScanPage.ts index 83097cf..3b702fc 100644 --- a/src/pages/QRScan/hooks/useQRScanPage.ts +++ b/src/pages/QRScan/hooks/useQRScanPage.ts @@ -1,4 +1,4 @@ -import type { ChangeEvent, RefObject } from 'react'; +import type { ChangeEvent, FormEvent, RefObject } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { App } from 'antd'; @@ -12,6 +12,7 @@ import { nonUrlResultRoute, resolveScanResultRouteByRiskLevel, } from '@/shared/lib/scan-session/scanResultRoute'; +import { useGuestStore } from '@/shared/store/guestStore'; import { useScanProgressStore } from '@/shared/store/scanProgressStore'; import { useScanSessionStore } from '@/shared/store/scanSessionStore'; @@ -45,9 +46,12 @@ type UseQRScanPageReturn = { handleOpenRecentScanResult: () => void; handleShowNextHistoryItem: () => void; handleShowPreviousHistoryItem: () => void; + handleSubmitUrlScan: (event: FormEvent) => void; + handleUrlInputChange: (event: ChangeEvent) => void; isCapturing: boolean; isFlashVisible: boolean; recentScanItem: ScanHistoryItem | null; + urlInputValue: string; videoRef: RefObject; }; @@ -136,12 +140,40 @@ function isNonWebScanResponse(scanResponse: Record): boolean { return !isWebScanTarget(normalizedResult); } +const urlSchemePattern = /^[a-zA-Z][\w+.-]*:\/\//u; + +export function normalizeUrlInput(rawValue: string): string | null { + const trimmedValue = rawValue.trim(); + + if (!trimmedValue) { + return null; + } + + const candidateUrl = urlSchemePattern.test(trimmedValue) + ? trimmedValue + : `https://${trimmedValue}`; + + try { + const parsedUrl = new URL(candidateUrl); + + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + return null; + } + + return parsedUrl.toString(); + } catch { + return null; + } +} + export function useQRScanPage(): UseQRScanPageReturn { const { message } = App.useApp(); const navigate = useNavigate(); + const ensureGuestUuid = useGuestStore((state) => state.ensureGuestUuid); const resetScanProgress = useScanProgressStore((state) => state.reset); const resetForNewScan = useScanSessionStore((state) => state.resetForNewScan); const setHistorySelection = useScanSessionStore((state) => state.setHistorySelection); + const setPendingTextScanUrl = useScanSessionStore((state) => state.setPendingTextScanUrl); const setScanResponse = useScanSessionStore((state) => state.setScanResponse); const videoRef = useRef(null); const fileInputRef = useRef(null); @@ -155,6 +187,7 @@ export function useQRScanPage(): UseQRScanPageReturn { const [captureRecord, setCaptureRecord] = useState(null); const [isCapturing, setIsCapturing] = useState(false); const [isFlashVisible, setIsFlashVisible] = useState(false); + const [urlInputValue, setUrlInputValue] = useState(''); const [scanHistoryItems, setScanHistoryItems] = useState(() => { return getInitialScanHistoryData().items; }); @@ -325,35 +358,32 @@ export function useQRScanPage(): UseQRScanPageReturn { useEffect(() => { let isMounted = true; + const applyRecentScanItems = (items: ScanHistoryItem[]) => { + if (!isMounted) { + return; + } + + setScanHistoryItems(items); + setCurrentHistoryIndex((previousIndex) => { + if (items.length === 0) { + return 0; + } + + return Math.min(previousIndex, items.length - 1); + }); + }; + const loadRecentScanItems = async () => { try { - const response = await fetchRecentScanHistoryData(); - - if (isMounted) { - setScanHistoryItems(response.items); - setCurrentHistoryIndex((previousIndex) => { - if (response.items.length === 0) { - return 0; - } + const response = await fetchRecentScanHistoryData({ + onItemsEnriched: applyRecentScanItems, + }); - return Math.min(previousIndex, response.items.length - 1); - }); - } + applyRecentScanItems(response.items); } catch (error) { console.error('Failed to load latest scan list items.', error); - if (isMounted) { - const fallbackItems = getInitialScanHistoryData().items; - - setScanHistoryItems(fallbackItems); - setCurrentHistoryIndex((previousIndex) => { - if (fallbackItems.length === 0) { - return 0; - } - - return Math.min(previousIndex, fallbackItems.length - 1); - }); - } + applyRecentScanItems(getInitialScanHistoryData().items); } }; @@ -535,6 +565,48 @@ export function useQRScanPage(): UseQRScanPageReturn { [beginScanSubmission, finishScanSubmission, message, submitScanFile, updateCaptureRecord], ); + const handleUrlInputChange = useCallback((event: ChangeEvent) => { + setUrlInputValue(event.target.value); + }, []); + + const handleSubmitUrlScan = useCallback( + (event: FormEvent) => { + event.preventDefault(); + + const normalizedUrl = normalizeUrlInput(urlInputValue); + + if (!normalizedUrl) { + message.warning('검사할 URL을 올바르게 입력해 주세요.'); + return; + } + + if (!beginScanSubmission()) { + return; + } + + resetForNewScan(); + resetScanProgress(); + ensureGuestUuid(); + setPendingTextScanUrl(normalizedUrl); + setUrlInputValue(normalizedUrl); + + void navigate({ to: '/loading' }).finally(() => { + finishScanSubmission(); + }); + }, + [ + beginScanSubmission, + ensureGuestUuid, + finishScanSubmission, + message, + navigate, + resetForNewScan, + resetScanProgress, + setPendingTextScanUrl, + urlInputValue, + ], + ); + return { cameraStatus, cameraStatusText, @@ -547,9 +619,12 @@ export function useQRScanPage(): UseQRScanPageReturn { handleOpenRecentScanResult, handleShowNextHistoryItem, handleShowPreviousHistoryItem, + handleSubmitUrlScan, + handleUrlInputChange, isCapturing, isFlashVisible, recentScanItem, + urlInputValue, videoRef, }; } diff --git a/src/pages/QRScan/styles/qrScanPage.css.ts b/src/pages/QRScan/styles/qrScanPage.css.ts index 0000763..3b94b68 100644 --- a/src/pages/QRScan/styles/qrScanPage.css.ts +++ b/src/pages/QRScan/styles/qrScanPage.css.ts @@ -359,6 +359,100 @@ export const secondaryButton = style([ }, ]); +export const urlSearchForm = style({ + width: '100%', + minHeight: '56px', + boxSizing: 'border-box', + borderRadius: '999px', + border: '1px solid rgba(11, 11, 11, 0.08)', + backgroundColor: vars.colors.white, + boxShadow: '0 10px 20px rgba(15, 23, 42, 0.04)', + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto', + alignItems: 'center', + gap: vars.spacing.xs, + padding: '6px 6px 6px 18px', + transition: 'border-color 160ms ease, box-shadow 160ms ease', + selectors: { + '&:focus-within': { + borderColor: 'rgba(17, 212, 131, 0.36)', + boxShadow: '0 0 0 3px rgba(17, 212, 131, 0.12)', + }, + }, +}); + +export const urlInputLabel = style({ + minWidth: 0, + display: 'block', +}); + +export const urlInput = style({ + width: '100%', + minWidth: 0, + border: 'none', + outline: 'none', + background: 'transparent', + color: vars.colors.black, + fontSize: vars.font.size.md, + fontWeight: vars.font.weight.medium, + lineHeight: 1.4, + selectors: { + '&::placeholder': { + color: '#9CA3AF', + }, + '&:disabled': { + cursor: 'wait', + opacity: 0.72, + }, + }, +}); + +export const urlSearchButton = style({ + minWidth: '112px', + minHeight: '44px', + border: 'none', + borderRadius: '999px', + backgroundColor: vars.colors.success, + color: vars.colors.white, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: '6px', + padding: '0 14px', + fontSize: vars.font.size.sm, + fontWeight: vars.font.weight.semibold, + lineHeight: 1, + whiteSpace: 'nowrap', + cursor: 'pointer', + transition: 'background-color 160ms ease, transform 120ms ease', + selectors: { + '&:hover:not(:disabled)': { + backgroundColor: '#0fbe75', + transform: 'translateY(-1px)', + }, + '&:focus-visible': { + outline: `3px solid ${vars.colors.mainLightHover}`, + outlineOffset: '2px', + }, + '&:disabled': { + opacity: 0.7, + cursor: 'wait', + }, + }, +}); + +export const visuallyHidden = style({ + position: 'absolute', + width: '1px', + height: '1px', + padding: 0, + margin: '-1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0, +}); + export const buttonIcon = style({ width: '18px', height: '18px', diff --git a/src/pages/Report/ReportPage.tsx b/src/pages/Report/ReportPage.tsx index 7948943..9c21897 100644 --- a/src/pages/Report/ReportPage.tsx +++ b/src/pages/Report/ReportPage.tsx @@ -1,10 +1,13 @@ import { RiskLevelCard, TrustScoreCard } from '@/shared/component'; import googleSafeBrowsingIcon from '@/shared/icon/Google Safe Browsing.svg'; import { qrIconByTone, statusMarkIconByTone } from '@/shared/icon/resultIcons'; +import { openExternalLink } from '@/shared/lib/browser/openExternalLink'; +import { isSafeExternalUrl } from '@/shared/lib/security/isSafeExternalUrl'; import AppHeader from '@/shared/ui/app-header'; import { resolveRiskDetectionContent } from './constants/riskDetectionCatalog'; import { useReportPage } from './hooks/useReportPage'; +import { resolveAnalysisFailureNotice } from './lib/analysisFailureNotice'; import * as styles from './styles/reportPage.css'; import type { ReportStatusTone } from './types/reportPage.types'; @@ -51,6 +54,14 @@ const certificateStatusClassNameByTone: Record = { warning: styles.certificateWarning, }; +const DEFAULT_V3_INSTALL_URL = 'https://www.ahnlab.com/product/v3-lite'; +const configuredV3InstallUrl = import.meta.env.VITE_V3_INSTALL_URL?.trim(); +const V3_INSTALL_URL = + configuredV3InstallUrl && isSafeExternalUrl(configuredV3InstallUrl) + ? configuredV3InstallUrl + : DEFAULT_V3_INSTALL_URL; +const isV3InstallUrlSafe = isSafeExternalUrl(V3_INSTALL_URL); + export default function ReportPage() { const { handleRescan, reportPageData } = useReportPage(); @@ -62,6 +73,14 @@ export default function ReportPage() { window.print(); }; + const handleOpenV3Install = () => { + if (!isV3InstallUrlSafe) { + return; + } + + openExternalLink(V3_INSTALL_URL); + }; + const reputationReportCount = reportPageData.reputation.summary.reportCount; const originalProtocolTone = resolveProtocolTone(reportPageData.urlAnalysis.originalUrl); @@ -77,6 +96,7 @@ export default function ReportPage() { ...resolveRiskDetectionContent(riskType, reportPageData.riskLevel), key: `${riskType}-${index}`, })); + const analysisFailureNotice = resolveAnalysisFailureNotice(reportPageData.detectedRiskTypes); const toneKey = reportPageData.riskLevel; @@ -137,6 +157,36 @@ export default function ReportPage() { /> + {analysisFailureNotice.hasFailureAlert ? ( +
+
+ + ! + +
+

+ {analysisFailureNotice.isOnlyAnalysisFailures + ? '분석 결과가 제한되었습니다' + : '일부 분석이 제한되었습니다'} +

+

+ {analysisFailureNotice.isOnlyAnalysisFailures + ? '여러 검사 모듈이 정상적으로 완료되지 않아, 이 결과를 안전하다는 의미로 해석하면 안 됩니다.' + : '일부 검사 모듈이 정상적으로 완료되지 않아, 아래 결과에는 확인하지 못한 영역이 남아 있습니다.'} +

+
+
+ +

+ 실패한 검사: {analysisFailureNotice.labels.join(', ')} +

+

+ 공식 사이트인지 직접 확인하기 전에는 로그인, 결제, 개인정보 입력, 파일 다운로드를 + 진행하지 마세요. +

+
+ ) : null} +
@@ -404,24 +454,41 @@ export default function ReportPage() {
-
- - - +
+
+ + + +
+ +
+ +

+ 설치하면 접속 전 보호 기능이 추가되어 조금 더 안전하게 이용할 수 있습니다. +

+
diff --git a/src/pages/Report/constants/riskDetectionCatalog.test.ts b/src/pages/Report/constants/riskDetectionCatalog.test.ts index 8e8504f..9aaab84 100644 --- a/src/pages/Report/constants/riskDetectionCatalog.test.ts +++ b/src/pages/Report/constants/riskDetectionCatalog.test.ts @@ -21,10 +21,34 @@ const backendThreatCodes = [ 'SPAM', 'C2', 'SUSPICIOUS', - 'hostname_missing', - 'certificate_request_timeout', - 'invalid certificate response', - 'peer certificate not available', + 'CERT_SELF_SIGNED', + 'CERT_UNTRUSTED', + 'CERT_EXPIRED', + 'CERT_HOSTNAME_MISMATCH', + 'CERT_NOT_YET_VALID', + 'CERT_REVOKED', + 'CERT_SSL_ERROR', + 'CERT_INVALID_HOST', + 'CERT_CONNECTION_FAILED', + 'CERT_LOOKUP_FAILED', + 'CERT_TIMEOUT', + 'CERT_NO_CERTIFICATE', + 'CERT_UNKNOWN_ERROR', + 'GSB_FAILED', + 'OTX_FAILED', + 'WHOIS_FAILED', + 'REDIRECT_FAILED', + 'REDIRECT_REQUEST_FAILED', + 'REDIRECT_CLIENT_ERROR', + 'REDIRECT_LOOP_DETECTED', + 'REDIRECT_TOO_MANY_REDIRECTS', + 'REDIRECT_INVALID_LOCATION', + 'SERVER_INFO_FAILED', + 'CERTIFICATE_FAILED', + 'CHARCNN_FAILED', + 'XGB_FAILED', + 'ML_FAILED', + 'SCORING_FAILED', ] as const; describe('resolveRiskDetectionContent', () => { @@ -51,4 +75,52 @@ describe('resolveRiskDetectionContent', () => { expect(content.englishLabel).toBe('PHISHING'); expect(content.title).toBe('피싱 위협 감지'); }); + + it('maps provider-prefixed threat codes from analysis threats', () => { + expect(resolveRiskDetectionContent('GSB:SOCIAL_ENGINEERING', 'critical')).toMatchObject({ + englishLabel: 'SOCIAL ENGINEERING', + title: '사회공학 위협 감지', + }); + expect(resolveRiskDetectionContent('OTX:PHISHING', 'critical')).toMatchObject({ + englishLabel: 'PHISHING', + title: '피싱 위협 감지', + }); + }); + + it('maps module-prefixed threat codes to the underlying backend threat description', () => { + expect(resolveRiskDetectionContent('CERT:CERT_EXPIRED', 'critical')).toMatchObject({ + englishLabel: 'CERT EXPIRED', + title: '만료된 인증서 감지', + }); + expect( + resolveRiskDetectionContent('REDIRECT:REDIRECT_LOOP_DETECTED', 'critical'), + ).toMatchObject({ + englishLabel: 'REDIRECT LOOP DETECTED', + title: '리다이렉트 루프 감지', + }); + expect(resolveRiskDetectionContent('MODEL:ML:CHARCNN_FAILED', 'critical')).toMatchObject({ + englishLabel: 'CHARCNN FAILED', + title: 'CharCNN 분석 실패 감지', + }); + }); + + it('keeps certificate timeout aliases mapped to their canonical timeout entry', () => { + expect(resolveRiskDetectionContent('certificate_timeout', 'warning')).toMatchObject({ + englishLabel: 'CERT TIMEOUT', + title: '인증서 검증 시간 초과 감지', + }); + expect(resolveRiskDetectionContent('certificate_request_timeout', 'warning')).toMatchObject({ + englishLabel: 'CERTIFICATE REQUEST TIMEOUT', + title: '인증서 요청 시간 초과 감지', + }); + }); + + it('uses user-facing copy for unknown threat fallbacks', () => { + const content = resolveRiskDetectionContent('NEW_BACKEND_SIGNAL', 'critical'); + + expect(content.description).not.toContain('백엔드'); + expect(content.description).not.toContain('정책 테이블'); + expect(content.risk).not.toContain('운영 정책'); + expect(content.englishLabel).toBe('UNKNOWN CRITICAL RISK SIGNAL'); + }); }); diff --git a/src/pages/Report/constants/riskDetectionCatalog.ts b/src/pages/Report/constants/riskDetectionCatalog.ts index f39c510..3634c26 100644 --- a/src/pages/Report/constants/riskDetectionCatalog.ts +++ b/src/pages/Report/constants/riskDetectionCatalog.ts @@ -13,6 +13,23 @@ function normalizeRiskType(value: string): string { .replace(/[\s\-_/.,:%@()\[\]'"]/g, ''); } +export function resolveRiskTypeLookupKeys(value: string): string[] { + const trimmedValue = value.trim(); + const candidates = [trimmedValue]; + const colonSeparatedParts = trimmedValue + .split(':') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + if (colonSeparatedParts.length > 1) { + for (let partIndex = 1; partIndex < colonSeparatedParts.length; partIndex += 1) { + candidates.push(colonSeparatedParts.slice(partIndex).join(':')); + } + } + + return Array.from(new Set(candidates.map((candidate) => normalizeRiskType(candidate)))); +} + const riskDetectionLookup = threatTextCatalog.reduce>( (accumulator, catalogItem) => { catalogItem.names.forEach((name) => { @@ -33,7 +50,9 @@ export function resolveRiskDetectionContent( riskType: string, riskLevel: ResultTone, ): RiskDetectionContent { - const resolved = riskDetectionLookup.get(normalizeRiskType(riskType)); + const resolved = resolveRiskTypeLookupKeys(riskType) + .map((lookupKey) => riskDetectionLookup.get(lookupKey)) + .find((content): content is RiskDetectionContent => content !== undefined); if (resolved) { return resolved; diff --git a/src/pages/Report/constants/threatText.ts b/src/pages/Report/constants/threatText.ts index de474cf..db2e100 100644 --- a/src/pages/Report/constants/threatText.ts +++ b/src/pages/Report/constants/threatText.ts @@ -327,6 +327,203 @@ export const threatTextCatalog: RiskDetectionCatalogItem[] = [ risk: '단일 신호로 단정할 수는 없지만 다른 탐지 항목과 함께 나타나면 접속 위험도가 크게 올라갑니다.', title: '의심 URL 감지', }, + { + description: '서버가 공인 인증기관이 아닌 자체 서명 인증서를 사용하고 있습니다.', + englishLabel: 'CERT SELF SIGNED', + names: ['CERT_SELF_SIGNED', 'cert_self_signed', 'self_signed_certificate'], + risk: '서버 신원을 제3자가 검증하지 않았기 때문에 위장 서버나 중간자 공격 가능성을 추가로 확인해야 합니다.', + title: '자체 서명 인증서 감지', + }, + { + description: '브라우저나 보안 시스템이 신뢰하지 않는 인증서 체인이 확인되었습니다.', + englishLabel: 'CERT UNTRUSTED', + names: ['CERT_UNTRUSTED', 'cert_untrusted', 'untrusted_certificate'], + risk: '정상적인 HTTPS처럼 보여도 인증서 발급 주체를 신뢰하기 어려워 민감 정보 입력 시 위험할 수 있습니다.', + title: '신뢰되지 않은 인증서 감지', + }, + { + description: 'HTTPS 인증서의 유효 기간이 이미 만료된 것으로 확인되었습니다.', + englishLabel: 'CERT EXPIRED', + names: ['CERT_EXPIRED', 'cert_expired', 'expired_certificate'], + risk: '관리되지 않는 서버이거나 위장 사이트일 가능성이 있으며, 암호화 연결의 신뢰성을 보장하기 어렵습니다.', + title: '만료된 인증서 감지', + }, + { + description: '인증서에 기록된 도메인과 실제 접속하려는 호스트명이 서로 일치하지 않습니다.', + englishLabel: 'CERT HOSTNAME MISMATCH', + names: ['CERT_HOSTNAME_MISMATCH', 'cert_hostname_mismatch', 'hostname_mismatch'], + risk: '다른 사이트용 인증서를 사용한 위장 서버일 수 있어 로그인, 결제, 개인정보 입력을 피해야 합니다.', + title: '인증서 호스트명 불일치 감지', + }, + { + description: '인증서의 유효 시작일이 아직 도래하지 않아 현재 시점에서는 유효하지 않습니다.', + englishLabel: 'CERT NOT YET VALID', + names: ['CERT_NOT_YET_VALID', 'cert_not_yet_valid', 'certificate_not_yet_valid'], + risk: '서버 시간이 잘못되었거나 비정상 인증서를 사용했을 수 있어 신뢰성을 판단하기 어렵습니다.', + title: '아직 유효하지 않은 인증서 감지', + }, + { + description: '인증서가 발급기관에 의해 폐기 또는 취소된 상태로 확인되었습니다.', + englishLabel: 'CERT REVOKED', + names: ['CERT_REVOKED', 'cert_revoked', 'revoked_certificate'], + risk: '인증서 탈취, 잘못된 발급, 서버 침해 같은 문제로 폐기되었을 수 있어 접속 위험도가 높습니다.', + title: '폐기된 인증서 감지', + }, + { + description: 'SSL/TLS 연결 또는 인증서 검증 과정에서 일반적인 보안 오류가 발생했습니다.', + englishLabel: 'CERT SSL ERROR', + names: ['CERT_SSL_ERROR', 'cert_ssl_error', 'ssl_error', 'tls_error'], + risk: '암호화 연결이 정상적으로 검증되지 않아 중간자 공격이나 잘못 구성된 서버 가능성을 배제하기 어렵습니다.', + title: 'SSL/TLS 오류 감지', + }, + { + description: '인증서 검증에 사용할 호스트 정보가 잘못되었거나 유효하지 않습니다.', + englishLabel: 'CERT INVALID HOST', + names: ['CERT_INVALID_HOST', 'cert_invalid_host', 'invalid_certificate_host'], + risk: '정상 도메인이 아닌 값으로 인증서 검증이 시도되어 실제 서버 신원을 확인하기 어렵습니다.', + title: '인증서 호스트 오류 감지', + }, + { + description: '인증서 확인을 위한 HTTPS 연결을 정상적으로 수립하지 못했습니다.', + englishLabel: 'CERT CONNECTION FAILED', + names: ['CERT_CONNECTION_FAILED', 'cert_connection_failed', 'certificate_connection_failed'], + risk: '서버가 분석을 차단하거나 불안정한 악성 인프라일 수 있어 다른 신호와 함께 주의가 필요합니다.', + title: '인증서 연결 실패 감지', + }, + { + description: '인증서 조회 대상의 DNS 또는 호스트 정보를 확인하지 못했습니다.', + englishLabel: 'CERT LOOKUP FAILED', + names: ['CERT_LOOKUP_FAILED', 'cert_lookup_failed', 'certificate_lookup_failed'], + risk: '일시적으로 사라지는 도메인, 잘못된 URL, 분석 회피용 인프라일 가능성이 있어 신뢰 판단이 제한됩니다.', + title: '인증서 조회 실패 감지', + }, + { + description: '인증서 검증 요청이 제한 시간 안에 완료되지 않았습니다.', + englishLabel: 'CERT TIMEOUT', + names: ['CERT_TIMEOUT', 'cert_timeout', 'certificate_timeout', 'tls_certificate_timeout'], + risk: 'HTTPS 신뢰 정보를 충분히 확인하지 못해 서버 신뢰성을 판단하기 어렵고, 은폐된 서버일 수 있습니다.', + title: '인증서 검증 시간 초과 감지', + }, + { + description: '서버가 HTTPS 연결에서 확인 가능한 인증서를 제공하지 않았습니다.', + englishLabel: 'CERT NO CERTIFICATE', + names: ['CERT_NO_CERTIFICATE', 'cert_no_certificate', 'no_certificate'], + risk: '서버 신원을 확인할 수 없어 민감 정보 입력 시 도청, 위장 서버, 피싱 위험을 배제하기 어렵습니다.', + title: '인증서 미제공 감지', + }, + { + description: '인증서 검증 중 분류되지 않은 오류가 발생했습니다.', + englishLabel: 'CERT UNKNOWN ERROR', + names: ['CERT_UNKNOWN_ERROR', 'cert_unknown_error', 'unknown_certificate_error'], + risk: '정확한 원인은 확인되지 않았지만 HTTPS 신뢰 검증이 완료되지 않아 보수적인 접근이 필요합니다.', + title: '인증서 알 수 없는 오류 감지', + }, + { + description: 'Google Safe Browsing 평판 조회가 정상적으로 완료되지 않았습니다.', + englishLabel: 'GSB FAILED', + names: ['GSB_FAILED', 'gsb_failed', 'google_safe_browsing_failed'], + risk: '외부 평판 결과가 비어 있어 안전 여부를 단정하기 어렵고, 다른 탐지 신호를 함께 확인해야 합니다.', + title: 'Google Safe Browsing 조회 실패 감지', + }, + { + description: 'OTX 위협 인텔리전스 조회가 정상적으로 완료되지 않았습니다.', + englishLabel: 'OTX FAILED', + names: ['OTX_FAILED', 'otx_failed', 'alienvault_otx_failed'], + risk: '공개 위협 정보 기반 검증이 제한되어 최근 악성 이력이나 캠페인 연관성을 충분히 확인하지 못했습니다.', + title: 'OTX 조회 실패 감지', + }, + { + description: 'WHOIS 또는 도메인 등록 정보 조회가 정상적으로 완료되지 않았습니다.', + englishLabel: 'WHOIS FAILED', + names: ['WHOIS_FAILED', 'whois_failed', 'domain_whois_failed'], + risk: '도메인 생성일, 등록자, 만료일 같은 신뢰 판단 자료가 부족해 신생 악성 도메인 여부를 확인하기 어렵습니다.', + title: 'WHOIS 조회 실패 감지', + }, + { + description: '리다이렉트 경로 분석이 정상적으로 완료되지 않았습니다.', + englishLabel: 'REDIRECT FAILED', + names: ['REDIRECT_FAILED', 'redirect_failed', 'redirect_analysis_failed'], + risk: '최종 도착지를 확인하지 못해 정상 URL처럼 보이는 중간 경유지 뒤의 위험 사이트를 놓칠 수 있습니다.', + title: '리다이렉트 분석 실패 감지', + }, + { + description: '리다이렉트 대상에 요청을 보내는 과정에서 오류가 발생했습니다.', + englishLabel: 'REDIRECT REQUEST FAILED', + names: ['REDIRECT_REQUEST_FAILED', 'redirect_request_failed'], + risk: '서버가 분석 요청을 차단하거나 조건부로 다른 목적지를 제공할 수 있어 실제 사용자의 이동 경로 확인이 제한됩니다.', + title: '리다이렉트 요청 실패 감지', + }, + { + description: '리다이렉트 요청 중 클라이언트 오류 응답이 확인되었습니다.', + englishLabel: 'REDIRECT CLIENT ERROR', + names: ['REDIRECT_CLIENT_ERROR', 'redirect_client_error'], + risk: '잘못된 경로 또는 접근 제한으로 인해 최종 URL을 확인하지 못했으며, 의도적으로 분석을 방해하는 구성일 수 있습니다.', + title: '리다이렉트 클라이언트 오류 감지', + }, + { + description: '리다이렉트가 같은 경로를 반복하는 순환 구조로 확인되었습니다.', + englishLabel: 'REDIRECT LOOP DETECTED', + names: ['REDIRECT_LOOP_DETECTED', 'redirect_loop_detected', 'redirect_loop'], + risk: '분석과 추적을 방해하거나 사용자를 특정 흐름에 가두려는 비정상 구성일 수 있습니다.', + title: '리다이렉트 루프 감지', + }, + { + description: '허용 범위를 넘는 과도한 리다이렉트 단계가 확인되었습니다.', + englishLabel: 'REDIRECT TOO MANY REDIRECTS', + names: ['REDIRECT_TOO_MANY_REDIRECTS', 'redirect_too_many_redirects', 'too_many_redirects'], + risk: '최종 목적지를 숨기고 보안 필터를 우회하기 위한 다단계 이동 경로일 가능성이 있습니다.', + title: '과도한 리다이렉트 감지', + }, + { + description: '리다이렉트 응답의 Location 값이 비어 있거나 유효한 URL 형식이 아닙니다.', + englishLabel: 'REDIRECT INVALID LOCATION', + names: ['REDIRECT_INVALID_LOCATION', 'redirect_invalid_location', 'invalid_redirect_location'], + risk: '비정상 응답으로 최종 목적지 확인이 제한되며, 분석 회피 또는 잘못 구성된 위험 서버일 수 있습니다.', + title: '유효하지 않은 리다이렉트 위치 감지', + }, + { + description: '서버 유형, 위치, 응답 헤더 등 인프라 정보 조회가 정상적으로 완료되지 않았습니다.', + englishLabel: 'SERVER INFO FAILED', + names: ['SERVER_INFO_FAILED', 'server_info_failed', 'server_lookup_failed'], + risk: '서버 신뢰 판단에 필요한 정보가 부족해 정상 서비스 여부와 운영 안정성을 충분히 확인하기 어렵습니다.', + title: '서버 정보 조회 실패 감지', + }, + { + description: '인증서 분석 단계 전체가 정상적으로 완료되지 않았습니다.', + englishLabel: 'CERTIFICATE FAILED', + names: ['CERTIFICATE_FAILED', 'certificate_failed', 'certificate_analysis_failed'], + risk: 'HTTPS 신뢰성 검증 결과가 제한되어 접속 대상 서버의 신원을 확정하기 어렵습니다.', + title: '인증서 분석 실패 감지', + }, + { + description: 'CharCNN 기반 URL 패턴 분석 모델 실행이 정상적으로 완료되지 않았습니다.', + englishLabel: 'CHARCNN FAILED', + names: ['CHARCNN_FAILED', 'charcnn_failed', 'char_cnn_failed'], + risk: '문자 패턴 기반 AI 탐지가 누락되어 난독화 URL이나 피싱 패턴 판단 신뢰도가 낮아질 수 있습니다.', + title: 'CharCNN 분석 실패 감지', + }, + { + description: 'XGBoost 기반 위험도 분석 모델 실행이 정상적으로 완료되지 않았습니다.', + englishLabel: 'XGB FAILED', + names: ['XGB_FAILED', 'xgb_failed', 'xgboost_failed'], + risk: '특징 기반 AI 판정이 누락되어 최종 점수 산정에 필요한 일부 근거가 부족할 수 있습니다.', + title: 'XGB 분석 실패 감지', + }, + { + description: '머신러닝 분석 단계가 정상적으로 완료되지 않았습니다.', + englishLabel: 'ML FAILED', + names: ['ML_FAILED', 'ml_failed', 'machine_learning_failed'], + risk: 'AI 기반 위험 탐지 결과가 제한되어 규칙 기반 신호와 외부 평판만으로 판단해야 합니다.', + title: 'ML 분석 실패 감지', + }, + { + description: + '탐지 결과를 종합해 최종 위험 점수를 계산하는 단계가 정상적으로 완료되지 않았습니다.', + englishLabel: 'SCORING FAILED', + names: ['SCORING_FAILED', 'scoring_failed', 'risk_scoring_failed'], + risk: '최종 위험도 산정이 제한되어 실제 위험 수준이 과소 또는 과대 표시될 수 있습니다.', + title: '위험 점수 산정 실패 감지', + }, { description: 'URL에서 접속 대상 호스트명을 확인하지 못했거나 DNS 해석에 실패한 신호입니다.', englishLabel: 'HOSTNAME MISSING', @@ -344,7 +541,7 @@ export const threatTextCatalog: RiskDetectionCatalogItem[] = [ { description: '인증서 정보를 확인하는 요청이 제한 시간 안에 완료되지 않았습니다.', englishLabel: 'CERTIFICATE REQUEST TIMEOUT', - names: ['certificate_request_timeout', 'certificate_timeout', 'tls_certificate_timeout'], + names: ['certificate_request_timeout'], risk: 'HTTPS 인증 상태를 충분히 검증하지 못해 서버 신뢰성을 판단하기 어렵고, 불안정하거나 은폐된 서버일 수 있습니다.', title: '인증서 요청 시간 초과 감지', }, @@ -368,23 +565,23 @@ export const threatTextCatalog: RiskDetectionCatalogItem[] = [ export const unknownThreatTextByTone: Record = { critical: { description: - '백엔드에서 새로운 고위험 신호가 전달되었습니다. 아직 정책 테이블에 상세 설명이 등록되지 않았지만, 위험도가 높아 즉시 확인이 필요합니다.', - englishLabel: 'UNMAPPED CRITICAL RISK SIGNAL', - risk: '알려진 탐지 유형과 매핑되지 않은 고위험 신호입니다. 접속을 차단하고 운영 정책에 새 탐지 문구를 추가해야 합니다.', - title: '미등록 고위험 신호 감지', + '검사 과정에서 안전하다고 보기 어려운 비정상 신호가 발견되었습니다. 정확한 세부 유형은 추가 확인이 필요하지만, 위험 사이트에서 자주 보이는 의심스러운 동작이나 연결 상태가 함께 나타난 경우입니다.', + englishLabel: 'UNKNOWN CRITICAL RISK SIGNAL', + risk: '공식 사이트처럼 보여도 로그인, 결제, 개인정보 입력, 파일 설치를 진행하지 않는 것이 안전합니다. 주소를 직접 검색해 공식 경로인지 확인하고, 이미 정보를 입력했다면 비밀번호 변경과 계정 보안 점검이 필요합니다.', + title: '추가 확인이 필요한 고위험 신호 감지', }, safe: { description: - '백엔드에서 참고용 신호가 전달되었지만, 현재 위험도는 낮게 분류되었습니다. 정책 테이블에 상세 설명이 등록되지 않았습니다.', - englishLabel: 'UNMAPPED LOW RISK SIGNAL', - risk: '낮은 위험도로 분류되었지만, 반복적으로 발생하면 별도 탐지 항목으로 등록하는 것이 좋습니다.', - title: '미등록 참고 신호 감지', + '위험으로 확정된 항목은 아니지만, 분석 과정에서 참고할 만한 특이점이 발견되었습니다. 정상 사이트에서도 일시적으로 나타날 수 있으므로, 주소와 화면 내용이 평소와 같은지 확인하는 것이 좋습니다.', + englishLabel: 'UNKNOWN LOW RISK SIGNAL', + risk: '현재 위험도는 낮더라도 로그인이나 결제 화면처럼 민감한 작업을 할 때는 주소창의 도메인, HTTPS 표시, 공식 앱 또는 공식 웹사이트 여부를 한 번 더 확인하는 것이 안전합니다.', + title: '추가 확인이 필요한 참고 신호 감지', }, warning: { description: - '백엔드에서 새로운 의심 신호가 전달되었습니다. 아직 정책 테이블에 상세 설명이 등록되지 않아 추가 확인이 필요합니다.', - englishLabel: 'UNMAPPED WARNING RISK SIGNAL', - risk: '정책 테이블에 매핑되지 않은 의심 유형입니다. 분석 후 사용자 안내 문구를 보강해야 합니다.', - title: '미등록 의심 신호 감지', + '검사 과정에서 평소 정상 사이트와 다른 의심 신호가 발견되었습니다. 위험으로 단정할 수는 없지만, 피싱 사이트나 임시 악성 페이지에서 보이는 패턴과 일부 유사한 상태입니다.', + englishLabel: 'UNKNOWN WARNING RISK SIGNAL', + risk: '사이트가 익숙해 보여도 바로 정보를 입력하지 말고, 공식 앱이나 검색을 통해 같은 주소가 맞는지 확인하세요. 파일 다운로드, 앱 설치, 인증번호 입력을 요구한다면 특히 주의해야 합니다.', + title: '추가 확인이 필요한 의심 신호 감지', }, }; diff --git a/src/pages/Report/lib/analysisFailureNotice.test.ts b/src/pages/Report/lib/analysisFailureNotice.test.ts new file mode 100644 index 0000000..67d8c1b --- /dev/null +++ b/src/pages/Report/lib/analysisFailureNotice.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveAnalysisFailureNotice } from './analysisFailureNotice'; + +describe('resolveAnalysisFailureNotice', () => { + it('resolves failure labels with provider or module prefixes', () => { + const notice = resolveAnalysisFailureNotice([ + 'MODEL:ML:CHARCNN_FAILED', + 'OTX:OTX_FAILED', + 'PHISHING', + ]); + + expect(notice).toEqual({ + hasFailureAlert: true, + isOnlyAnalysisFailures: false, + labels: ['문자 패턴 AI 분석', 'OTX 위협 정보 조회'], + }); + }); + + it('detects an all-failure result even when labels are deduplicated', () => { + const notice = resolveAnalysisFailureNotice(['REDIRECT_FAILED', 'REDIRECT:REDIRECT_FAILED']); + + expect(notice).toEqual({ + hasFailureAlert: true, + isOnlyAnalysisFailures: true, + labels: ['리다이렉트 분석'], + }); + }); +}); diff --git a/src/pages/Report/lib/analysisFailureNotice.ts b/src/pages/Report/lib/analysisFailureNotice.ts new file mode 100644 index 0000000..d2f2395 --- /dev/null +++ b/src/pages/Report/lib/analysisFailureNotice.ts @@ -0,0 +1,48 @@ +import { resolveRiskTypeLookupKeys } from '../constants/riskDetectionCatalog'; + +const analysisFailureTypeLabels: Record = { + certificatefailed: '인증서 분석', + charcnnfailed: '문자 패턴 AI 분석', + gsbfailed: 'Google Safe Browsing 조회', + mlfailed: '머신러닝 종합 분석', + otxfailed: 'OTX 위협 정보 조회', + redirectclienterror: '리다이렉트 추적', + redirectfailed: '리다이렉트 분석', + redirectinvalidlocation: '리다이렉트 위치 확인', + redirectloopdetected: '리다이렉트 루프 확인', + redirectrequestfailed: '리다이렉트 요청', + redirecttoomanyredirects: '과도한 리다이렉트 확인', + scoringfailed: '최종 위험 점수 산정', + serverinfofailed: '서버 정보 조회', + whoisfailed: 'WHOIS 도메인 조회', + xgbfailed: 'XGB AI 분석', +}; + +export type AnalysisFailureNotice = { + hasFailureAlert: boolean; + isOnlyAnalysisFailures: boolean; + labels: string[]; +}; + +function resolveAnalysisFailureLabel(riskType: string): string | null { + const failureKey = resolveRiskTypeLookupKeys(riskType).find((lookupKey) => + Boolean(analysisFailureTypeLabels[lookupKey]), + ); + + return failureKey ? analysisFailureTypeLabels[failureKey] : null; +} + +export function resolveAnalysisFailureNotice(detectedRiskTypes: string[]): AnalysisFailureNotice { + const resolvedLabels = detectedRiskTypes.map((riskType) => resolveAnalysisFailureLabel(riskType)); + const labels = Array.from( + new Set(resolvedLabels.filter((label): label is string => label !== null)), + ); + const failureCount = resolvedLabels.filter((label) => label !== null).length; + + return { + hasFailureAlert: labels.length > 0, + isOnlyAnalysisFailures: + detectedRiskTypes.length > 0 && failureCount === detectedRiskTypes.length, + labels, + }; +} diff --git a/src/pages/Report/lib/reportPageDataContext.test.ts b/src/pages/Report/lib/reportPageDataContext.test.ts index 70d9b25..a65e231 100644 --- a/src/pages/Report/lib/reportPageDataContext.test.ts +++ b/src/pages/Report/lib/reportPageDataContext.test.ts @@ -11,6 +11,7 @@ function createSession(overrides: Partial = {}): ScanSessio finalResult: null, historySelection: null, isUrl: true, + pendingTextScanUrl: null, riskLevel: null, scanResponse: null, schemeType: 'WEB', diff --git a/src/pages/Report/lib/toReportPageData.test.ts b/src/pages/Report/lib/toReportPageData.test.ts index e2c9016..9338d54 100644 --- a/src/pages/Report/lib/toReportPageData.test.ts +++ b/src/pages/Report/lib/toReportPageData.test.ts @@ -41,6 +41,7 @@ describe('toReportPageData', () => { finalResult: null, historySelection: null, isUrl: true, + pendingTextScanUrl: null, riskLevel: null, scanResponse: null, schemeType: 'WEB', @@ -99,6 +100,7 @@ describe('toReportPageData', () => { finalResult: null, historySelection: null, isUrl: true, + pendingTextScanUrl: null, riskLevel: null, scanResponse: null, schemeType: 'WEB', @@ -133,6 +135,7 @@ describe('toReportPageData', () => { finalResult: null, historySelection: null, isUrl: true, + pendingTextScanUrl: null, riskLevel: null, scanResponse: null, schemeType: 'WEB', @@ -155,6 +158,7 @@ describe('toReportPageData', () => { finalResult: null, historySelection: null, isUrl: true, + pendingTextScanUrl: null, riskLevel: null, scanResponse: null, schemeType: 'WEB', diff --git a/src/pages/Report/styles/reportPage.css.ts b/src/pages/Report/styles/reportPage.css.ts index f1ca5a9..9502055 100644 --- a/src/pages/Report/styles/reportPage.css.ts +++ b/src/pages/Report/styles/reportPage.css.ts @@ -223,6 +223,72 @@ export const metricsGrid = style({ }, }); +export const analysisFailureNotice = style({ + borderRadius: vars.radius.lg, + border: '1px solid #F4C56E', + backgroundColor: '#FFF8E8', + padding: vars.spacing.lg, + display: 'grid', + gap: vars.spacing.sm, + boxShadow: '0 8px 18px rgba(138, 89, 0, 0.08)', + '@media': { + print: { + breakInside: 'avoid', + boxShadow: 'none', + }, + }, +}); + +export const analysisFailureNoticeHeader = style({ + display: 'flex', + alignItems: 'flex-start', + gap: vars.spacing.sm, +}); + +export const analysisFailureNoticeIcon = style({ + width: '30px', + height: '30px', + borderRadius: '999px', + backgroundColor: '#D97706', + color: vars.colors.white, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + fontSize: vars.font.size.md, + fontWeight: vars.font.weight.bold, + lineHeight: 1, +}); + +export const analysisFailureNoticeTitleBlock = style({ + display: 'grid', + gap: '4px', +}); + +export const analysisFailureNoticeTitle = style({ + margin: 0, + color: '#7C3F00', + fontSize: vars.font.size.xl, + fontWeight: vars.font.weight.bold, + lineHeight: 1.35, +}); + +export const analysisFailureNoticeSummary = style({ + margin: 0, + color: '#8A5A12', + fontSize: vars.font.size.sm, + fontWeight: vars.font.weight.semibold, + lineHeight: 1.6, +}); + +export const analysisFailureNoticeText = style({ + margin: 0, + color: '#5F4824', + fontSize: vars.font.size.sm, + fontWeight: vars.font.weight.medium, + lineHeight: 1.65, +}); + export const sectionCard = style({ borderRadius: vars.radius.lg, border: '1px solid #F0E4BE', @@ -990,10 +1056,22 @@ export const certificateError = style({ color: vars.colors.error, }); -export const exportActionWrap = style({ +export const exportArea = style({ width: '100%', maxWidth: '860px', margin: '0 auto', + display: 'grid', + justifyItems: 'center', + gap: vars.spacing.md, + '@media': { + print: { + display: 'none', + }, + }, +}); + +export const exportActionWrap = style({ + width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -1006,6 +1084,61 @@ export const exportActionWrap = style({ }, }); +export const v3InstallWrap = style({ + width: '100%', + display: 'grid', + justifyItems: 'center', + gap: vars.spacing.xs, +}); + +export const v3InstallButton = style({ + minWidth: '220px', + minHeight: '44px', + borderRadius: vars.radius.md, + border: '1px solid rgba(37, 99, 235, 0.22)', + backgroundColor: '#2563EB', + color: vars.colors.white, + fontSize: vars.font.size.md, + fontWeight: vars.font.weight.semibold, + lineHeight: 1, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + padding: `0 ${vars.spacing.lg}`, + cursor: 'pointer', + transition: 'background-color 160ms ease, box-shadow 160ms ease, transform 120ms ease', + boxShadow: '0 6px 14px rgba(17, 29, 48, 0.12)', + selectors: { + '&:hover:not(:disabled)': { + backgroundColor: '#1D4ED8', + boxShadow: '0 8px 16px rgba(37, 99, 235, 0.22)', + transform: 'translateY(-1px)', + }, + '&:active:not(:disabled)': { + transform: 'translateY(0)', + }, + '&:focus-visible': { + outline: '2px solid rgba(37, 99, 235, 0.42)', + outlineOffset: '2px', + }, + '&:disabled': { + opacity: 0.52, + cursor: 'not-allowed', + boxShadow: 'none', + }, + }, +}); + +export const v3InstallHelpText = style({ + maxWidth: '360px', + margin: 0, + color: vars.colors.subText, + fontSize: vars.font.size.xs, + fontWeight: vars.font.weight.medium, + lineHeight: 1.55, + textAlign: 'center', +}); + export const printButton = style({ minWidth: '160px', minHeight: '44px', diff --git a/src/pages/Result/lib/toResultPageData.test.ts b/src/pages/Result/lib/toResultPageData.test.ts index e36941b..1559449 100644 --- a/src/pages/Result/lib/toResultPageData.test.ts +++ b/src/pages/Result/lib/toResultPageData.test.ts @@ -23,6 +23,7 @@ describe('toResultPageData', () => { finalResult: null, historySelection: null, isUrl: true, + pendingTextScanUrl: null, riskLevel: null, scanResponse: null, schemeType: 'WEB', @@ -44,6 +45,7 @@ describe('toResultPageData', () => { finalResult: null, historySelection: null, isUrl: true, + pendingTextScanUrl: null, riskLevel: null, scanResponse: null, schemeType: 'WEB', @@ -68,6 +70,7 @@ describe('toResultPageData', () => { finalResult: null, historySelection: null, isUrl: true, + pendingTextScanUrl: null, riskLevel: null, scanResponse: null, schemeType: 'WEB', diff --git a/src/pages/ResultNonUrl/lib/toResultNonUrlPageData.test.ts b/src/pages/ResultNonUrl/lib/toResultNonUrlPageData.test.ts index 335cf8a..bcb712c 100644 --- a/src/pages/ResultNonUrl/lib/toResultNonUrlPageData.test.ts +++ b/src/pages/ResultNonUrl/lib/toResultNonUrlPageData.test.ts @@ -12,6 +12,7 @@ const baseSession: ScanSessionSnapshot = { finalResult: null, historySelection: null, isUrl: false, + pendingTextScanUrl: null, riskLevel: null, scanResponse: null, schemeType: null, diff --git a/src/shared/api/endpoints.ts b/src/shared/api/endpoints.ts index 4b02d68..80f6bd9 100644 --- a/src/shared/api/endpoints.ts +++ b/src/shared/api/endpoints.ts @@ -4,4 +4,5 @@ export const apiEndpoints = { scanDetail: '/api/v1/scan/detail', scanHistory: '/api/v1/scan/history', scanSubscribe: '/api/v1/scan/subscribe', + scanText: '/api/v1/scan/text', } as const; diff --git a/src/shared/api/responseAccess/payloadAccess.test.ts b/src/shared/api/responseAccess/payloadAccess.test.ts index ec5b928..6316cb4 100644 --- a/src/shared/api/responseAccess/payloadAccess.test.ts +++ b/src/shared/api/responseAccess/payloadAccess.test.ts @@ -92,4 +92,19 @@ describe('payloadAccess', () => { 'address', ]); }); + + it('finds threat arrays nested inside analysis response envelopes', () => { + expect( + pickSourceStringArray( + [ + { + analysisResponse: { + threats: ['GSB:SOCIAL_ENGINEERING', 'OTX:PHISHING'], + }, + }, + ], + ['threats'], + ), + ).toEqual(['GSB:SOCIAL_ENGINEERING', 'OTX:PHISHING']); + }); }); diff --git a/src/shared/api/responseAccess/payloadAccess.ts b/src/shared/api/responseAccess/payloadAccess.ts index a0be195..8e6a8f4 100644 --- a/src/shared/api/responseAccess/payloadAccess.ts +++ b/src/shared/api/responseAccess/payloadAccess.ts @@ -2,6 +2,10 @@ type UnknownRecord = Record; const nestedRecordKeys = [ 'analysis', + 'analysisResponse', + 'analysis_response', + 'analysisResult', + 'analysis_result', 'certificate', 'data', 'detail', @@ -28,6 +32,10 @@ const nestedRecordKeys = [ 'server_info', 'shortUrl', 'short_url', + 'threatIntel', + 'threat_intel', + 'urlAnalysis', + 'url_analysis', ] as const; function pushUniqueRecord(records: UnknownRecord[], candidate: UnknownRecord | null) { diff --git a/src/shared/lib/sse/useScanSubscription.ts b/src/shared/lib/sse/useScanSubscription.ts index 8e1d15d..e79732b 100644 --- a/src/shared/lib/sse/useScanSubscription.ts +++ b/src/shared/lib/sse/useScanSubscription.ts @@ -9,6 +9,7 @@ type UseScanSubscriptionParams = { guestUuid: string | null; onError?: (errorMessage: string) => void; onFinal?: (payload: Record) => void; + onOpen?: () => void; onProgress?: (payload: Record) => void; }; @@ -73,17 +74,20 @@ export function useScanSubscription({ guestUuid, onError, onFinal, + onOpen, onProgress, }: UseScanSubscriptionParams): void { const callbacksRef = useRef({ onError, onFinal, + onOpen, onProgress, }); callbacksRef.current = { onError, onFinal, + onOpen, onProgress, }; @@ -204,6 +208,7 @@ export function useScanSubscription({ nextEventSource.onopen = () => { resetInactivityTimer(); + callbacksRef.current.onOpen?.(); clearStabilityTimer(); stabilityTimerId = window.setTimeout(() => { reconnectAttempts = 0; diff --git a/src/shared/store/scanSessionStore.test.ts b/src/shared/store/scanSessionStore.test.ts index a23fa62..cd9758f 100644 --- a/src/shared/store/scanSessionStore.test.ts +++ b/src/shared/store/scanSessionStore.test.ts @@ -66,4 +66,17 @@ describe('scanSessionStore', () => { expect(state.schemeType).toBe('WEB'); expect(state.isUrl).toBe(true); }); + + it('keeps the pending text scan URL when the accepted response omits URL fields', () => { + useScanSessionStore.getState().setPendingTextScanUrl('https://example.com/text-scan'); + useScanSessionStore.getState().setScanResponse({ + status: 'accepted', + }); + + const state = useScanSessionStore.getState(); + + expect(state.decodedUrl).toBe('https://example.com/text-scan'); + expect(state.isUrl).toBe(true); + expect(state.pendingTextScanUrl).toBeNull(); + }); }); diff --git a/src/shared/store/scanSessionStore.ts b/src/shared/store/scanSessionStore.ts index b3d4b31..8fbbc08 100644 --- a/src/shared/store/scanSessionStore.ts +++ b/src/shared/store/scanSessionStore.ts @@ -30,17 +30,20 @@ export type ScanSessionSnapshot = { finalResult: BackendSseFinalPayload | null; historySelection: ScanHistorySelection | null; isUrl: boolean | null; + pendingTextScanUrl: string | null; riskLevel: ResultTone | null; scanResponse: BackendScanResponse | null; schemeType: string | null; }; type ScanSessionState = ScanSessionSnapshot & { + clearPendingTextScanUrl: () => void; clearSession: () => void; resetForNewScan: () => void; setAnalysisDetail: (detail: BackendAnalysisDetailResponse) => void; setFinalResult: (finalResult: BackendSseFinalPayload) => void; setHistorySelection: (historySelection: ScanHistorySelection) => void; + setPendingTextScanUrl: (url: string) => void; setScanResponse: (scanResponse: BackendScanResponse) => void; }; @@ -70,6 +73,7 @@ const initialState: ScanSessionSnapshot = { finalResult: null, historySelection: null, isUrl: null, + pendingTextScanUrl: null, riskLevel: null, scanResponse: null, schemeType: null, @@ -90,6 +94,8 @@ function mergePersistedLightSession( decodedUrl: typeof persisted.decodedUrl === 'string' ? persisted.decodedUrl : null, historySelection: persisted.historySelection ?? null, isUrl: typeof persisted.isUrl === 'boolean' ? persisted.isUrl : null, + pendingTextScanUrl: + typeof persisted.pendingTextScanUrl === 'string' ? persisted.pendingTextScanUrl : null, riskLevel: persisted.riskLevel ?? null, schemeType: typeof persisted.schemeType === 'string' @@ -102,6 +108,9 @@ export const useScanSessionStore = create()( persist( (set) => ({ ...initialState, + clearPendingTextScanUrl: () => { + set({ pendingTextScanUrl: null }); + }, clearSession: () => { set(initialState); }, @@ -112,6 +121,7 @@ export const useScanSessionStore = create()( finalResult: null, historySelection: null, isUrl: null, + pendingTextScanUrl: null, riskLevel: null, scanResponse: null, schemeType: null, @@ -126,8 +136,21 @@ export const useScanSessionStore = create()( setHistorySelection: (historySelection) => { set(buildHistorySelectionPatch(historySelection)); }, + setPendingTextScanUrl: (url) => { + set({ + analysisDetail: null, + decodedUrl: url, + finalResult: null, + historySelection: null, + isUrl: true, + pendingTextScanUrl: url, + riskLevel: null, + scanResponse: null, + schemeType: 'WEB', + }); + }, setScanResponse: (scanResponse) => { - set(buildScanResponsePatch(scanResponse)); + set((state) => buildScanResponsePatch(scanResponse, state)); }, }), { @@ -136,6 +159,7 @@ export const useScanSessionStore = create()( decodedUrl: state.decodedUrl, historySelection: state.historySelection, isUrl: state.isUrl, + pendingTextScanUrl: state.pendingTextScanUrl, riskLevel: state.riskLevel, schemeType: state.schemeType, }), @@ -154,6 +178,7 @@ export function getScanSessionSnapshot(): ScanSessionSnapshot { finalResult: state.finalResult, historySelection: state.historySelection, isUrl: state.isUrl, + pendingTextScanUrl: state.pendingTextScanUrl, riskLevel: state.riskLevel, scanResponse: state.scanResponse, schemeType: state.schemeType, diff --git a/src/shared/store/scanSessionTransitions.test.ts b/src/shared/store/scanSessionTransitions.test.ts index 98c4a43..9560c2a 100644 --- a/src/shared/store/scanSessionTransitions.test.ts +++ b/src/shared/store/scanSessionTransitions.test.ts @@ -16,6 +16,7 @@ function createSnapshot(overrides: Partial = {}): ScanSessi finalResult: null, historySelection: null, isUrl: true, + pendingTextScanUrl: null, riskLevel: 'warning', scanResponse: null, schemeType: 'WEB', diff --git a/src/shared/store/scanSessionTransitions.ts b/src/shared/store/scanSessionTransitions.ts index e0624b4..bcfae06 100644 --- a/src/shared/store/scanSessionTransitions.ts +++ b/src/shared/store/scanSessionTransitions.ts @@ -45,6 +45,7 @@ export function buildFinalResultPatch( finalResult, historySelection: state.historySelection, isUrl: resolveScanIsUrl(finalResult, nextDecodedUrl, nextSchemeType) ?? state.isUrl, + pendingTextScanUrl: null, riskLevel: normalizedResult.riskLevel ?? state.riskLevel, schemeType: nextSchemeType, }; @@ -61,23 +62,30 @@ export function buildHistorySelectionPatch( finalResult: null, historySelection, isUrl: resolveScanIsUrl(historySelection, historySelection.url, schemeType), + pendingTextScanUrl: null, riskLevel: historySelection.riskLevel, scanResponse: null, schemeType, }; } -export function buildScanResponsePatch(scanResponse: BackendScanResponse): ScanSessionPatch { +export function buildScanResponsePatch( + scanResponse: BackendScanResponse, + state?: ScanSessionSnapshot, +): ScanSessionPatch { const normalizedResult = normalizeScanResult(scanResponse); + const nextDecodedUrl = normalizedResult.decodedUrl ?? state?.decodedUrl ?? null; + const nextSchemeType = normalizedResult.schemeType ?? state?.schemeType ?? null; return { analysisDetail: null, - decodedUrl: normalizedResult.decodedUrl, + decodedUrl: nextDecodedUrl, finalResult: null, historySelection: null, - isUrl: normalizedResult.isUrl, - riskLevel: normalizedResult.riskLevel, + isUrl: normalizedResult.isUrl ?? state?.isUrl ?? null, + pendingTextScanUrl: null, + riskLevel: normalizedResult.riskLevel ?? state?.riskLevel ?? null, scanResponse, - schemeType: normalizedResult.schemeType, + schemeType: nextSchemeType, }; } diff --git a/vite.config.ts b/vite.config.ts index 4d6e19b..5850249 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,11 +6,23 @@ import react from '@vitejs/plugin-react'; import { defineConfig, loadEnv } from 'vite'; const dirname = path.dirname(fileURLToPath(import.meta.url)); +const defaultBe1ProxyTarget = 'http://localhost:8081'; +const defaultBe3ProxyTarget = 'http://localhost:8083'; + +function resolveDevProxyTarget(rawValue: string | undefined, fallback: string): string { + const value = rawValue?.trim(); + + if (!value || value.startsWith('/')) { + return fallback; + } + + return value; +} export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); - const be1ProxyTarget = env.VITE_BE1_BASE_URL?.trim() || 'http://localhost:8081'; - const be3ProxyTarget = env.VITE_BE3_BASE_URL?.trim() || 'http://localhost:8083'; + const be1ProxyTarget = resolveDevProxyTarget(env.VITE_BE1_BASE_URL, defaultBe1ProxyTarget); + const be3ProxyTarget = resolveDevProxyTarget(env.VITE_BE3_BASE_URL, defaultBe3ProxyTarget); return { build: {