diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 05449b2..48fb4a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,3 +30,6 @@ jobs: - name: Eslint run: npm run eslint + + - name: Tests + run: npm test diff --git a/manifest.json b/manifest.json index 3797d7b..a27ef7d 100644 --- a/manifest.json +++ b/manifest.json @@ -2,6 +2,7 @@ "name": "Tolgee Tools", "version": "1.9.0", "manifest_version": 3, + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnAWlBy6Y2olMvOiHeL+WSURusjlecxcb7ZYqjVYV0GTz3FvC8QCrb6kb/+nYITxfAjNgzRpZbI2m56JZ55sRSeAwFwEiCYKiH6HLrVhF/43apH8o0qyoybhXuSuH1+5PdI4Rm899MCvHxc2+1TIUbJWg3BTCyCFScF1PytzsBTPygXMtdZwGYdyKX+fuez7sURtBmwctM9KjvNwYjhVz7JAiMnV8c1Y4tkIHqhLIFeWKcwpwCufB+ZirXBRpyN1rijH+Kcz3hDzRMGgRpXQDG73q5zkzlHJmelcpu5gYtp5irwFJC3IvkemfOMBrEjQp/9uO1G6xtcAj6ReuCkvYiQIDAQAB", "description": "Translate your web application more effectively with Tolgee plugin!", "action": { "default_icon": { @@ -14,7 +15,7 @@ "service_worker": "src/background/background.ts", "type": "module" }, - "permissions": ["storage"], + "permissions": ["storage", "identity", "alarms"], "host_permissions": [""], "icons": { "128": "icons/present.png" diff --git a/package-lock.json b/package-lock.json index 9d3b532..426c734 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,8 @@ "semantic-release": "^17.4.7", "typescript": "^5.2.2", "vite": "^3.2.7", - "vite-plugin-zip-pack": "^1.0.7" + "vite-plugin-zip-pack": "^1.0.7", + "vitest": "^0.34.6" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -87,6 +88,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.6.tgz", "integrity": "sha512-FxpRyGjrMJXh7X3wGLGhNDCRiwpWEF74sKjTLDJSG5Kyvow3QZaG0Adbqzi9ZrVjTWpsX+2cxWXD71NMg93kdw==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -587,6 +589,7 @@ "version": "11.11.3", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.3.tgz", "integrity": "sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", @@ -627,6 +630,7 @@ "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz", "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", @@ -882,6 +886,19 @@ "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", "dev": true }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", @@ -915,10 +932,11 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.20", @@ -1199,6 +1217,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.6.0.tgz", "integrity": "sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q==", "dev": true, + "peer": true, "dependencies": { "@octokit/auth-token": "^2.4.4", "@octokit/graphql": "^4.5.8", @@ -4542,6 +4561,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "dev": true, + "license": "MIT" + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -4551,6 +4577,24 @@ "node": ">= 6" } }, + "node_modules/@types/chai": { + "version": "4.3.20", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", + "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai-subset": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.6.tgz", + "integrity": "sha512-m8lERkkQj+uek18hXOZuec3W/fCRTrU4hrnXjH3qhHy96ytuPaPiWGgu7sJb7tZxZonO75vYAjCvpe/e4VUwRw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/chai": "<5.2.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4563,6 +4607,17 @@ "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "dev": true }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -4583,6 +4638,7 @@ "version": "18.2.45", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.45.tgz", "integrity": "sha512-TtAxCNrlrBp8GoeEp1npd5g+d/OejJHFxS3OWmrPBMFaVQMSN0OFySozJio5BHxTuTeug00AVXVAjfDSfk+lUg==", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4670,6 +4726,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.16.0.tgz", "integrity": "sha512-H2GM3eUo12HpKZU9njig3DF5zJ58ja6ahj1GoHEHOgQvYxzoFJJEvC1MQ7T2l9Ha+69ZSOn7RTxOdpC/y3ikMw==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.16.0", "@typescript-eslint/types": "6.16.0", @@ -4856,6 +4913,118 @@ "node": ">=0.10.0" } }, + "node_modules/@vitest/expect": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz", + "integrity": "sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "0.34.6", + "@vitest/utils": "0.34.6", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.34.6.tgz", + "integrity": "sha512-1CUQgtJSLF47NnhN+F9X2ycxUP0kLHQ/JWvNHbeBfwW8CzEGgeskzNnHDyv1ieKTltuR6sdIHV+nmR6kPxQqzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "0.34.6", + "p-limit": "^4.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-0.34.6.tgz", + "integrity": "sha512-B3OZqYn6k4VaN011D+ve+AA4whM4QkcwcrwaKwAbyyvS/NB1hCWjFIBQxAQQSQir9/RtyAAGuq+4RJmbn2dH4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.1", + "pathe": "^1.1.1", + "pretty-format": "^29.5.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/spy": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.34.6.tgz", + "integrity": "sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.34.6.tgz", + "integrity": "sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.4.3", + "loupe": "^2.3.6", + "pretty-format": "^29.5.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webcomponents/custom-elements": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@webcomponents/custom-elements/-/custom-elements-1.6.0.tgz", @@ -4863,10 +5032,12 @@ "dev": true }, "node_modules/acorn": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", - "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, + "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5022,6 +5193,16 @@ "node": ">=0.10.0" } }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -5109,6 +5290,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001565", "electron-to-chromium": "^1.4.601", @@ -5122,6 +5304,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5189,6 +5381,25 @@ "cdl": "bin/cdl.js" } }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -5202,6 +5413,19 @@ "node": ">=4" } }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/cheerio": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", @@ -5312,6 +5536,13 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, "node_modules/connect-injector": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/connect-injector/-/connect-injector-0.4.4.tgz", @@ -5563,6 +5794,19 @@ "node": ">=0.10.0" } }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -5621,6 +5865,16 @@ "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", "dev": true }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -6214,6 +6468,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6269,6 +6524,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6821,6 +7077,16 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -7595,6 +7861,19 @@ "node": ">=4" } }, + "node_modules/local-pkg": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz", + "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -7669,6 +7948,16 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7707,6 +7996,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-2.1.3.tgz", "integrity": "sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA==", "dev": true, + "peer": true, "bin": { "marked": "bin/marked" }, @@ -7934,6 +8224,26 @@ "node": ">= 6" } }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", @@ -8322,6 +8632,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8420,6 +8747,25 @@ "node": ">=4" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/postcss": { "version": "8.4.47", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", @@ -8462,6 +8808,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz", "integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8484,6 +8831,34 @@ "node": ">=6.0.0" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -8591,6 +8966,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8602,6 +8978,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -8964,6 +9341,7 @@ "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-17.4.7.tgz", "integrity": "sha512-3Ghu8mKCJgCG3QzE5xphkYWM19lGE3XjFdOXQIKBM2PBpBvgFQ/lXv31oX0+fuN/UjNFO/dqhNs8ATLBhg6zBg==", "dev": true, + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^8.0.0", "@semantic-release/error": "^2.2.0", @@ -9147,6 +9525,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -9285,6 +9670,20 @@ "node": ">= 6" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-buffers": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-0.2.6.tgz", @@ -9381,6 +9780,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", + "integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -9542,6 +9954,33 @@ "node": ">= 6" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.7.0.tgz", + "integrity": "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -9619,6 +10058,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -9636,6 +10085,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9653,6 +10103,13 @@ "node": "*" } }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, "node_modules/uglify-js": { "version": "3.17.4", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", @@ -9666,6 +10123,13 @@ "node": ">=0.8.0" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", @@ -9759,6 +10223,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz", "integrity": "sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.15.9", "postcss": "^8.4.18", @@ -9803,6 +10268,30 @@ } } }, + "node_modules/vite-node": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.34.6.tgz", + "integrity": "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "mlly": "^1.4.0", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": ">=v14.18.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite-plugin-zip-pack": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/vite-plugin-zip-pack/-/vite-plugin-zip-pack-1.0.7.tgz", @@ -9830,6 +10319,94 @@ "fsevents": "~2.3.2" } }, + "node_modules/vitest": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.34.6.tgz", + "integrity": "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^4.3.5", + "@types/chai-subset": "^1.3.3", + "@types/node": "*", + "@vitest/expect": "0.34.6", + "@vitest/runner": "0.34.6", + "@vitest/snapshot": "0.34.6", + "@vitest/spy": "0.34.6", + "@vitest/utils": "0.34.6", + "acorn": "^8.9.0", + "acorn-walk": "^8.2.0", + "cac": "^6.7.14", + "chai": "^4.3.10", + "debug": "^4.3.4", + "local-pkg": "^0.4.3", + "magic-string": "^0.30.1", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.3.3", + "strip-literal": "^1.0.1", + "tinybench": "^2.5.0", + "tinypool": "^0.7.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0", + "vite-node": "0.34.6", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": ">=v14.18.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@vitest/browser": "*", + "@vitest/ui": "*", + "happy-dom": "*", + "jsdom": "*", + "playwright": "*", + "safaridriver": "*", + "webdriverio": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/webextension-polyfill": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/webextension-polyfill/-/webextension-polyfill-0.12.0.tgz", @@ -9867,6 +10444,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/package.json b/package.json index 7e0e70e..8876c52 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "preview": "vite preview", "release": "semantic-release", "release-dry": "semantic-release --no-ci -d", - "release-dry-debug": "semantic-release --no-ci -d --debug" + "release-dry-debug": "semantic-release --no-ci -d --debug", + "test": "vitest run", + "test-watch": "vitest" }, "dependencies": { "@emotion/react": "^11.11.3", @@ -46,7 +48,8 @@ "semantic-release": "^17.4.7", "typescript": "^5.2.2", "vite": "^3.2.7", - "vite-plugin-zip-pack": "^1.0.7" + "vite-plugin-zip-pack": "^1.0.7", + "vitest": "^0.34.6" }, "release": { "plugins": [ diff --git a/src/background/background.ts b/src/background/background.ts index f46884f..d69c8c0 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -1,9 +1,19 @@ import browser from 'webextension-polyfill'; import { ScreenshotMaker } from './ScreenshotMaker'; import { RuntimeMessage } from '../content/Messages'; +import { login } from '../oauth/oauthClient'; +import { + clearSession, + getValidAccessToken, + loadAllSessions, + saveSession, +} from '../oauth/tokenStore'; +import { OAUTH_REFRESH_SKEW_MS } from '../constants'; type State = 'present' | 'active' | 'inactive'; +const REFRESH_ALARM = 'tolgee-oauth-refresh'; + browser.runtime.onMessage.addListener((message, sender, sendResponse) => { const { type, data } = message as RuntimeMessage; switch (type) { @@ -16,6 +26,38 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => { setStateIcon(data, sender.tab!.id!); sendResponse({}); break; + case 'OPEN_POPUP': + // Best effort: chrome.action.openPopup() is only available on newer Chrome; if it's missing or the call is + // refused, the in-context alert still tells the user to click the extension icon. + (browser.action as { openPopup?: () => Promise }) + .openPopup?.() + .catch(() => undefined); + sendResponse({}); + break; + case 'OAUTH_LOGIN': + connect(data) + .then((accessToken) => sendResponse({ accessToken })) + .catch((e) => { + console.error('[tolgee-oauth] login failed', e); + sendResponse({ error: String(e) }); + }); + return true; + case 'OAUTH_GET_TOKEN': + getValidAccessToken(data.apiUrl, data.projectId) + .then((accessToken) => sendResponse({ accessToken })) + .catch((e) => { + console.error('[tolgee-oauth] token lookup failed', e); + sendResponse({ accessToken: null, error: String(e) }); + }); + return true; + case 'OAUTH_LOGOUT': + clearSession(data.apiUrl, data.projectId) + .then(() => sendResponse({})) + .catch((e) => { + console.error('[tolgee-oauth] logout failed', e); + sendResponse({ error: String(e) }); + }); + return true; default: sendResponse({}); } @@ -27,3 +69,93 @@ const setStateIcon = (state: State, tabId: number) => { tabId, }); }; + +// Reuse an existing usable session before launching the OAuth flow: a matching concrete-project session, or an +// all-projects one, connects a second site on the same backend with no extra round trip — this is what makes an +// all-projects login "just work" everywhere. Only when nothing serves the requested project do we run the flow. +const connect = async (data: { + apiUrl: string; + projectId?: number; + tabId?: number; +}): Promise => { + let accessToken = await getValidAccessToken(data.apiUrl, data.projectId); + if (!accessToken) { + const tokens = await login(data.apiUrl, data.projectId); + await saveSession(data.apiUrl, tokens); + accessToken = tokens.accessToken; + } + // launchWebAuthFlow steals focus, which closes the popup before it can push credentials to the page, so inject from + // here. data.tabId is the tab the popup was acting on, captured before the auth window opened. + if (data.tabId != null) { + await injectCredentials(data.tabId, { + apiUrl: data.apiUrl, + authToken: accessToken, + projectId: data.projectId, + }); + } + return accessToken; +}; + +// Inject the full credential set into a page on connect (the content script writes them to sessionStorage and reloads +// so the SDK picks them up). Runs from the service worker because the popup is already gone by the time login resolves. +const injectCredentials = async ( + tabId: number, + data: { apiUrl: string; authToken: string; projectId?: number } +) => { + await browser.tabs + .sendMessage(tabId, { type: 'SET_CREDENTIALS', data }) + .catch(() => undefined); +}; + +// Keep stored sessions fresh so the popup and the injected page token don't expire mid-use. Rotation means each +// refresh mints a new access + refresh token; getValidAccessToken persists them and pushes the access token to tabs. +// This top-level code re-runs every time the MV3 worker wakes; re-creating an existing alarm resets its schedule, so a +// worker that wakes more often than the period would never let the alarm fire — only create it when it's absent. +const ensureRefreshAlarm = async () => { + if (!(await browser.alarms.get(REFRESH_ALARM))) { + await browser.alarms.create(REFRESH_ALARM, { periodInMinutes: 10 }); + } +}; +ensureRefreshAlarm(); +browser.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name !== REFRESH_ALARM) { + return; + } + const sessions = await loadAllSessions(); + for (const session of sessions) { + if (session.expiresAt - OAUTH_REFRESH_SKEW_MS > Date.now()) { + continue; + } + // Passing the session's own project scope refreshes exactly this session (a concrete id finds it, '*' the + // all-projects one), so rotating one project's token never disturbs another's. + const accessToken = await getValidAccessToken( + session.apiUrl, + session.projectKey + ); + if (accessToken) { + await pushTokenToTabs(session.apiUrl, session.projectKey, accessToken); + } + } +}); + +// Update the injected access token in every tab whose applied backend and project the refreshed session serves, without +// reloading the page. An all-projects ('*') session serves any project; a concrete session only its own. +const pushTokenToTabs = async ( + apiUrl: string, + projectKey: string, + accessToken: string +) => { + const tabs = await browser.tabs.query({}); + await Promise.all( + tabs.map((tab) => + tab.id == null + ? undefined + : browser.tabs + .sendMessage(tab.id, { + type: 'UPDATE_AUTH_TOKEN', + data: { apiUrl, projectKey, authToken: accessToken }, + }) + .catch(() => undefined) + ) + ); +}; diff --git a/src/constants.ts b/src/constants.ts index f630aac..d15add1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,25 @@ export const API_KEY_LOCAL_STORAGE = '__tolgee_apiKey'; export const API_URL_LOCAL_STORAGE = '__tolgee_apiUrl'; export const BRANCH_LOCAL_STORAGE = '__tolgee_branch'; +// OAuth access token injected into the page alongside the api key; the SDK (tolgee-js) reads it as a Bearer token. +export const AUTH_TOKEN_LOCAL_STORAGE = '__tolgee_authToken'; +// Project id injected for the OAuth path: OAuth tokens carry no embedded project (unlike a PAK), so the user +// picks one in the popup and the SDK reads it here to know which project to fetch/edit. +export const PROJECT_ID_LOCAL_STORAGE = '__tolgee_projectId'; + +// Fixed public client id pre-registered on every Tolgee backend (see PreRegisteredClients on the platform). +export const OAUTH_CLIENT_ID = 'tolgee-browser-extension'; +// Access tokens are short-lived; refresh this many milliseconds before expiry. +export const OAUTH_REFRESH_SKEW_MS = 60_000; +// Scopes the extension requests for in-context editing. The backend intersects them with the user's live +// permissions, so requesting a broad set never grants more than the user actually holds. +export const OAUTH_SCOPES = [ + 'translations.view', + 'translations.edit', + 'translations.state-edit', + 'keys.view', + 'keys.edit', + 'screenshots.view', + 'screenshots.upload', + 'screenshots.delete', +].join(' '); diff --git a/src/content/contentScript.ts b/src/content/contentScript.ts index d9f560a..06e0eca 100644 --- a/src/content/contentScript.ts +++ b/src/content/contentScript.ts @@ -1,7 +1,9 @@ import { API_KEY_LOCAL_STORAGE, API_URL_LOCAL_STORAGE, + AUTH_TOKEN_LOCAL_STORAGE, BRANCH_LOCAL_STORAGE, + PROJECT_ID_LOCAL_STORAGE, } from '../constants'; import { LibConfig } from '../types'; import { injectUiLib } from './injectUiLib'; @@ -18,9 +20,22 @@ const getAppliedCredenials = () => { apiKey: sessionStorage.getItem(API_KEY_LOCAL_STORAGE), apiUrl: sessionStorage.getItem(API_URL_LOCAL_STORAGE), branch: sessionStorage.getItem(BRANCH_LOCAL_STORAGE), + authToken: sessionStorage.getItem(AUTH_TOKEN_LOCAL_STORAGE), + projectId: sessionStorage.getItem(PROJECT_ID_LOCAL_STORAGE), }; }; +const sameOrigin = (a: string | null, b: string | null) => { + if (!a || !b) { + return false; + } + try { + return new URL(a).origin === new URL(b).origin; + } catch (e) { + return false; + } +}; + // handshake with library messages.listenWindow('TOLGEE_READY', (c: LibConfig) => { const firstHandshake = !configuration; @@ -55,6 +70,11 @@ messages.listenWindow('TOLGEE_TAKE_SCREENSHOT', () => { }); }); +// in-context editor asks to open the popup (e.g. so the user can re-connect after their OAuth session expired) +messages.listenWindow('TOLGEE_OPEN_PLUGIN', () => { + messages.sendToPlugin('OPEN_POPUP'); +}); + messages.startRuntimeListening(); // popup will ask if tolgee is present on the page @@ -66,22 +86,50 @@ messages.listenRuntime('DETECT_TOLGEE', async () => { messages.listenRuntime('GET_CREDENTIALS', async () => getAppliedCredenials()); -messages.listenRuntime('SET_CREDENTIALS', async (data) => { - if (data.apiKey) { - sessionStorage.setItem(API_KEY_LOCAL_STORAGE, data.apiKey); - } else { - sessionStorage.removeItem(API_KEY_LOCAL_STORAGE); +const setOrRemove = (key: string, value: string | undefined | null) => { + const next = value ? String(value) : null; + if (sessionStorage.getItem(key) === next) { + return false; } - if (data.apiUrl) { - sessionStorage.setItem(API_URL_LOCAL_STORAGE, data.apiUrl); + if (next === null) { + sessionStorage.removeItem(key); } else { - sessionStorage.removeItem(API_URL_LOCAL_STORAGE); + sessionStorage.setItem(key, next); } - if (data.branch) { - sessionStorage.setItem(BRANCH_LOCAL_STORAGE, data.branch); - } else { - sessionStorage.removeItem(BRANCH_LOCAL_STORAGE); + return true; +}; + +messages.listenRuntime('SET_CREDENTIALS', async (data) => { + // Bitwise-or so every key is written before we decide; reload only when something actually changed, so a redundant + // push (e.g. both the popup and the background inject on connect) doesn't reload the page twice. + const changed = + Number(setOrRemove(API_KEY_LOCAL_STORAGE, data.apiKey)) | + Number(setOrRemove(API_URL_LOCAL_STORAGE, data.apiUrl)) | + Number(setOrRemove(BRANCH_LOCAL_STORAGE, data.branch)) | + Number(setOrRemove(AUTH_TOKEN_LOCAL_STORAGE, data.authToken)) | + Number(setOrRemove(PROJECT_ID_LOCAL_STORAGE, data.projectId)); + if (changed) { + location.reload(); } - location.reload(); updateState(configuration, messages); }); + +// Background pushes a rotated access token here on refresh; update it in place so the SDK picks it up without a reload. +messages.listenRuntime('UPDATE_AUTH_TOKEN', async (data) => { + // Only take a push whose session serves this page's project: '*' (all projects) serves any, a concrete key only its + // own. Without this, a project-2 refresh would clobber a project-3 page's token on the same backend. An absent + // projectKey (older background) matches anything, preserving the previous single-session behaviour. + const pageProjectId = sessionStorage.getItem(PROJECT_ID_LOCAL_STORAGE); + const scopeServesPage = + data.projectKey === undefined || + data.projectKey === '*' || + data.projectKey === pageProjectId; + // Skip an empty token: setItem would store the literal string "undefined" and the SDK would send `Bearer undefined`. + if ( + data.authToken && + scopeServesPage && + sameOrigin(sessionStorage.getItem(API_URL_LOCAL_STORAGE), data.apiUrl) + ) { + sessionStorage.setItem(AUTH_TOKEN_LOCAL_STORAGE, data.authToken); + } +}); diff --git a/src/oauth/oauthClient.ts b/src/oauth/oauthClient.ts new file mode 100644 index 0000000..6448e72 --- /dev/null +++ b/src/oauth/oauthClient.ts @@ -0,0 +1,152 @@ +import browser from 'webextension-polyfill'; +import { OAUTH_CLIENT_ID, OAUTH_SCOPES } from '../constants'; +import { challengeFromVerifier, randomUrlSafe } from './pkce'; + +export type OAuthTokens = { + accessToken: string; + refreshToken?: string; + // epoch milliseconds at which the access token expires + expiresAt: number; +}; + +const normalizeUrl = (url: string) => url.replace(/\/$/, ''); + +// Fallback lifetime when the token endpoint omits expires_in, so getValidAccessToken doesn't read the token as already +// expired (expiresAt === now) and trigger a refresh — and, with rotation, a refresh — on every single read. +const DEFAULT_TOKEN_LIFETIME_SECONDS = 5 * 60; + +export const getRedirectUri = () => browser.identity.getRedirectURL(); + +const parseTokenResponse = ( + data: Record, + previousRefreshToken?: string +): OAuthTokens => { + if (typeof data.access_token !== 'string' || !data.access_token) { + throw new Error('Tolgee token endpoint returned no access_token'); + } + const expiresIn = + typeof data.expires_in === 'number' && data.expires_in > 0 + ? data.expires_in + : DEFAULT_TOKEN_LIFETIME_SECONDS; + return { + accessToken: data.access_token, + // rotation returns a fresh refresh token; if a response omits it, keep the previous one + refreshToken: data.refresh_token ?? previousRefreshToken, + expiresAt: Date.now() + expiresIn * 1000, + }; +}; + +const postToken = async ( + base: string, + params: Record, + previousRefreshToken?: string +): Promise => { + const res = await fetch(`${base}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(params), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Tolgee token endpoint returned ${res.status}: ${body}`); + } + return parseTokenResponse(await res.json(), previousRefreshToken); +}; + +const AUTH_MAX_ATTEMPTS = 3; +const AUTH_RETRY_DELAY_MS = 500; + +const wasCancelledByUser = (message: string) => + /cancel|did not approve|denied|closed by the user/i.test(message); + +// launchWebAuthFlow runs the authorize flow in an isolated window that intermittently fails to load the bootstrap SPA +// ("Authorization page could not be loaded"), even though a retry succeeds. Reopen it a couple of times on such a +// transient failure, but never after the user closes/denies the window (that decision is final). +const launchAuthWithRetry = async (url: string): Promise => { + let lastError: unknown; + for (let attempt = 1; attempt <= AUTH_MAX_ATTEMPTS; attempt++) { + try { + return await browser.identity.launchWebAuthFlow({ + url, + interactive: true, + }); + } catch (e) { + lastError = e; + const message = e instanceof Error ? e.message : String(e); + if (wasCancelledByUser(message) || attempt === AUTH_MAX_ATTEMPTS) { + throw e; + } + console.warn( + `[tolgee-oauth] authorization attempt ${attempt} failed, retrying`, + message + ); + await new Promise((resolve) => setTimeout(resolve, AUTH_RETRY_DELAY_MS)); + } + } + throw lastError; +}; + +export const login = async ( + apiUrl: string, + projectId?: number +): Promise => { + const base = normalizeUrl(apiUrl); + const verifier = randomUrlSafe(); + const redirectUri = getRedirectUri(); + + const authorizeUrl = new URL(`${base}/oauth2/authorize`); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('client_id', OAUTH_CLIENT_ID); + authorizeUrl.searchParams.set('redirect_uri', redirectUri); + authorizeUrl.searchParams.set('scope', OAUTH_SCOPES); + authorizeUrl.searchParams.set( + 'code_challenge', + await challengeFromVerifier(verifier) + ); + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); + const state = randomUrlSafe(); + authorizeUrl.searchParams.set('state', state); + if (projectId != null) { + authorizeUrl.searchParams.set('project', String(projectId)); + } + + const redirectResponse = await launchAuthWithRetry(authorizeUrl.toString()); + const redirectParams = new URL(redirectResponse).searchParams; + if (redirectParams.get('state') !== state) { + throw new Error('Tolgee authorization returned an unexpected state'); + } + const error = redirectParams.get('error'); + if (error) { + throw new Error( + `Tolgee authorization failed: ${ + redirectParams.get('error_description') || error + }` + ); + } + const code = redirectParams.get('code'); + if (!code) { + throw new Error('Tolgee authorization did not return a code'); + } + + return postToken(base, { + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: OAUTH_CLIENT_ID, + code_verifier: verifier, + }); +}; + +export const refresh = ( + apiUrl: string, + refreshToken: string +): Promise => + postToken( + normalizeUrl(apiUrl), + { + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: OAUTH_CLIENT_ID, + }, + refreshToken + ); diff --git a/src/oauth/pkce.ts b/src/oauth/pkce.ts new file mode 100644 index 0000000..81d0894 --- /dev/null +++ b/src/oauth/pkce.ts @@ -0,0 +1,23 @@ +// PKCE helpers (RFC 7636), using Web Crypto — available in both the service worker and the popup. + +const base64UrlEncode = (bytes: Uint8Array): string => { + let str = ''; + bytes.forEach((b) => (str += String.fromCharCode(b))); + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +export const randomUrlSafe = (byteLength = 32): string => { + const bytes = new Uint8Array(byteLength); + crypto.getRandomValues(bytes); + return base64UrlEncode(bytes); +}; + +export const challengeFromVerifier = async ( + verifier: string +): Promise => { + const digest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(verifier) + ); + return base64UrlEncode(new Uint8Array(digest)); +}; diff --git a/src/oauth/tokenScope.ts b/src/oauth/tokenScope.ts new file mode 100644 index 0000000..8dbaa65 --- /dev/null +++ b/src/oauth/tokenScope.ts @@ -0,0 +1,44 @@ +// Reads the `tg.prj` (project set) claim the backend stamped into the OAuth access token. '*' means all projects +// (the user must then pick one to edit); a single id means the token is bound to that project and we can auto-select it. +export function decodeTokenProjectSet( + token: string | undefined +): '*' | number[] | undefined { + if (!token) { + return undefined; + } + try { + const payload = JSON.parse( + atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')) + ); + const prj = payload['tg.prj']; + if (prj === '*') { + return '*'; + } + if (Array.isArray(prj)) { + return prj.map((x) => Number(x)).filter((n) => !Number.isNaN(n)); + } + return undefined; + } catch (e) { + return undefined; + } +} + +export const ALL_PROJECTS_KEY = '*'; + +// The store key for a token's project scope: the single bound project id, or '*' for an all-projects token. Keying a +// concrete-project token by its id lets two projects on the same backend coexist instead of overwriting each other; +// an all-projects token keys as '*' so it's reused for any project. A multi-project set (the extension never mints one) +// keys by its sorted ids, which no single-project lookup matches — so it is simply never reused, never mis-served. +export function projectKeyForToken(token: string): string { + const scope = decodeTokenProjectSet(token); + if (scope === '*') { + return ALL_PROJECTS_KEY; + } + if (Array.isArray(scope) && scope.length === 1) { + return String(scope[0]); + } + if (Array.isArray(scope) && scope.length > 1) { + return [...scope].sort((a, b) => a - b).join(','); + } + return ALL_PROJECTS_KEY; +} diff --git a/src/oauth/tokenStore.test.ts b/src/oauth/tokenStore.test.ts new file mode 100644 index 0000000..ea2c391 --- /dev/null +++ b/src/oauth/tokenStore.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// In-memory chrome.storage.local. +const store = new Map(); +vi.mock('webextension-polyfill', () => ({ + default: { + storage: { + local: { + get: async (key: string | null) => { + if (key === null) { + return Object.fromEntries(store); + } + return store.has(key) ? { [key]: store.get(key) } : {}; + }, + set: async (obj: Record) => { + Object.entries(obj).forEach(([k, v]) => store.set(k, v)); + }, + remove: async (key: string) => { + store.delete(key); + }, + }, + }, + }, +})); + +// The scope key is taken verbatim from the token string ('token-3' -> '3', 'token-*' -> '*'), so tests control it. +vi.mock('./tokenScope', () => ({ + ALL_PROJECTS_KEY: '*', + projectKeyForToken: (token: string) => token.replace(/^token-/, ''), +})); + +const refresh = vi.fn(); +vi.mock('./oauthClient', () => ({ + refresh: (apiUrl: string, refreshToken: string) => + refresh(apiUrl, refreshToken), +})); + +import { + clearSession, + getValidAccessToken, + loadSession, + saveSession, +} from './tokenStore'; + +const URL_A = 'https://app.tolgee.io'; +const future = () => Date.now() + 60 * 60 * 1000; + +const tokens = ( + scope: string, + overrides: Partial<{ refreshToken: string; expiresAt: number }> = {} +) => ({ + accessToken: `token-${scope}`, + refreshToken: 'refresh', + expiresAt: future(), + ...overrides, +}); + +describe('tokenStore per-project keying', () => { + beforeEach(() => { + store.clear(); + refresh.mockReset(); + }); + + it('keeps two concrete-project sessions on the same backend from colliding', async () => { + await saveSession(URL_A, tokens('2')); + await saveSession(URL_A, tokens('3')); + + expect((await loadSession(URL_A, 2))?.accessToken).toBe('token-2'); + expect((await loadSession(URL_A, 3))?.accessToken).toBe('token-3'); + }); + + it('serves any project from an all-projects session when no concrete one exists', async () => { + await saveSession(URL_A, tokens('*')); + + expect((await loadSession(URL_A, 7))?.projectKey).toBe('*'); + expect((await loadSession(URL_A, 99))?.accessToken).toBe('token-*'); + }); + + it('prefers a concrete session over an all-projects one for its own project', async () => { + await saveSession(URL_A, tokens('*')); + await saveSession(URL_A, tokens('3')); + + expect((await loadSession(URL_A, 3))?.projectKey).toBe('3'); + expect((await loadSession(URL_A, 5))?.projectKey).toBe('*'); + }); + + it('serves the sole origin session when the requested project is unknown or mismatched', async () => { + // The popup reopens and asks with no (or a stale) projectId before it re-resolves the page's project. With a single + // session for the backend, getValidAccessToken must still resolve it — otherwise the popup treats it as + // disconnected and wipes it. (loadSession itself stays strict, so disconnect never clears the wrong session.) + await saveSession(URL_A, tokens('2')); + + expect(await getValidAccessToken(URL_A, undefined)).toBe('token-2'); + expect(await getValidAccessToken(URL_A, 999)).toBe('token-2'); + expect(await loadSession(URL_A, 999)).toBeNull(); + }); + + it('does not guess when multiple sessions exist and no project matches', async () => { + await saveSession(URL_A, tokens('2')); + await saveSession(URL_A, tokens('3')); + + expect(await getValidAccessToken(URL_A, undefined)).toBeNull(); + }); + + it('clears only the session serving the given project', async () => { + await saveSession(URL_A, tokens('2')); + await saveSession(URL_A, tokens('3')); + + await clearSession(URL_A, 2); + + expect(await loadSession(URL_A, 2)).toBeNull(); + expect((await loadSession(URL_A, 3))?.accessToken).toBe('token-3'); + }); + + it('refreshes under the original scope key, not the refreshed token’s', async () => { + await saveSession(URL_A, tokens('3', { expiresAt: Date.now() - 1 })); + // The backend never widens scope on refresh, but even if the refreshed token decoded differently the session must + // stay keyed by '3' so the concrete lookup keeps finding it. + refresh.mockResolvedValue({ + accessToken: 'token-*', + refreshToken: 'r2', + expiresAt: future(), + }); + + const token = await getValidAccessToken(URL_A, 3); + + expect(token).toBe('token-*'); + expect((await loadSession(URL_A, 3))?.projectKey).toBe('3'); + expect((await loadSession(URL_A, 3))?.refreshToken).toBe('r2'); + }); + + it('does not refresh a still-valid token', async () => { + await saveSession(URL_A, tokens('3')); + + expect(await getValidAccessToken(URL_A, 3)).toBe('token-3'); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/src/oauth/tokenStore.ts b/src/oauth/tokenStore.ts new file mode 100644 index 0000000..708988c --- /dev/null +++ b/src/oauth/tokenStore.ts @@ -0,0 +1,157 @@ +import browser from 'webextension-polyfill'; +import { OAUTH_REFRESH_SKEW_MS } from '../constants'; +import { OAuthTokens, refresh } from './oauthClient'; +import { ALL_PROJECTS_KEY, projectKeyForToken } from './tokenScope'; + +export type StoredSession = OAuthTokens & { + apiUrl: string; + projectKey: string; +}; + +const originOf = (apiUrl: string) => new URL(apiUrl).origin; + +// Sessions are keyed by (backend origin, project scope): two concrete-project logins on the same backend coexist +// instead of overwriting each other, and an all-projects ('*') session is reused for any project on that origin. +const keyFor = (apiUrl: string, projectKey: string) => + `oauth:${originOf(apiUrl)}:${projectKey}`; + +const normalizeProjectId = (projectId?: number | string) => + projectId === undefined || projectId === null || projectId === '' + ? undefined + : String(projectId); + +const persist = async ( + apiUrl: string, + tokens: OAuthTokens, + projectKey: string +) => { + await browser.storage.local.set({ + [keyFor(apiUrl, projectKey)]: { ...tokens, apiUrl, projectKey }, + }); +}; + +// Stores a freshly minted token under its own project scope, returning the key so callers can log it. A refresh keeps +// the original key (see refreshSession) so a rotated token never lands under a different scope. +export const saveSession = async ( + apiUrl: string, + tokens: OAuthTokens +): Promise => { + const projectKey = projectKeyForToken(tokens.accessToken); + await persist(apiUrl, tokens, projectKey); + return projectKey; +}; + +const loadByKey = async ( + apiUrl: string, + projectKey: string +): Promise => { + const key = keyFor(apiUrl, projectKey); + const stored = await browser.storage.local.get(key); + return (stored[key] as StoredSession) ?? null; +}; + +// The session serving a page on `projectId`: its own concrete-project session when one exists, otherwise an +// all-projects session (whose token covers every project on the backend). Strict — no guessing — so disconnect +// (clearSession) never removes a session the caller didn't ask for. +export const loadSession = async ( + apiUrl: string, + projectId?: number | string +): Promise => { + const id = normalizeProjectId(projectId); + if (id !== undefined) { + const exact = await loadByKey(apiUrl, id); + if (exact) { + return exact; + } + } + return loadByKey(apiUrl, ALL_PROJECTS_KEY); +}; + +// The sole session for an origin, or null when there are zero or several. The read path falls back to this so a caller +// that doesn't know the project yet (e.g. the popup reopening before it re-resolves the page's project) still resolves +// its one session instead of being told "not connected". +const soleOriginSession = async ( + apiUrl: string +): Promise => { + const originSessions = (await loadAllSessions()).filter( + (s) => originOf(s.apiUrl) === originOf(apiUrl) + ); + return originSessions.length === 1 ? originSessions[0] : null; +}; + +export const clearSession = async ( + apiUrl: string, + projectId?: number | string +) => { + const session = await loadSession(apiUrl, projectId); + if (session) { + await browser.storage.local.remove(keyFor(apiUrl, session.projectKey)); + } +}; + +export const loadAllSessions = async (): Promise => { + const all = await browser.storage.local.get(null); + return Object.entries(all) + .filter(([key]) => key.startsWith('oauth:')) + .map(([, value]) => value as StoredSession); +}; + +// A token-endpoint 4xx means the refresh token is dead (rotated away or revoked) — terminal, clear the session. A +// network failure (fetch rejects, no status) is transient — keep the session so a later call can retry instead of +// logging the user out on a blip. postToken throws `... returned : ...` for non-ok responses. +const isTerminalRefreshFailure = (e: unknown) => + e instanceof Error && /returned 4\d\d/.test(e.message); + +// A refresh rotates the refresh token, so two concurrent refreshes for the same session would both spend the same +// (single-use) token: the first wins, the second gets invalid_grant and clears the just-refreshed session. The alarm +// handler and an OAUTH_GET_TOKEN message can land in the same worker at once, so share one in-flight refresh per key. +const inFlightRefresh = new Map>(); + +const refreshSession = async ( + session: StoredSession +): Promise => { + try { + const refreshed = await refresh(session.apiUrl, session.refreshToken!); + // Persist under the session's original key: a refresh must keep the same project scope, never re-key the session. + await persist(session.apiUrl, refreshed, session.projectKey); + return refreshed.accessToken; + } catch (e) { + if (isTerminalRefreshFailure(e)) { + await browser.storage.local.remove( + keyFor(session.apiUrl, session.projectKey) + ); + } + return null; + } +}; + +// Returns a valid access token for the given project, refreshing (and persisting) if it is expired or near expiry. +// Falls back to an all-projects session, then to the origin's sole session; returns null when there is nothing valid +// to serve it. +export const getValidAccessToken = async ( + apiUrl: string, + projectId?: number | string +): Promise => { + const session = + (await loadSession(apiUrl, projectId)) ?? (await soleOriginSession(apiUrl)); + if (!session) { + return null; + } + if (session.expiresAt - OAUTH_REFRESH_SKEW_MS > Date.now()) { + return session.accessToken; + } + if (!session.refreshToken) { + await browser.storage.local.remove(keyFor(apiUrl, session.projectKey)); + return null; + } + const key = keyFor(session.apiUrl, session.projectKey); + const existing = inFlightRefresh.get(key); + if (existing) { + return existing; + } + const pending = refreshSession(session).finally(() => + inFlightRefresh.delete(key) + ); + inFlightRefresh.set(key, pending); + return pending; +}; diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index 310d005..305fccf 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -1,23 +1,42 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; +import browser from 'webextension-polyfill'; import { + Alert, Autocomplete, Box, Button, CircularProgress, FormControl, FormHelperText, + Link, Switch, + Tab, + Tabs, TextField, Typography, } from '@mui/material'; import { useDetectorForm } from './useDetectorForm'; -import { validateValues } from './tools'; +import { decodeTokenProjectSet, isOAuth, validateValues } from './tools'; +import { sendToBackground } from './sendToBackground'; +import { useApiKeyCheck } from './useApiKeyCheck'; const POPUP_WIDTH = 400; +const DEFAULT_SERVER = 'https://app.tolgee.io'; +const LEARN_MORE_PROJECT_ID = + 'https://docs.tolgee.io/js-sdk/api/core_package/options#projectid'; +const API_KEY_HELP = + 'https://docs.tolgee.io/platform/account_settings/api_keys_and_pat_tokens'; +// Sentinel option for an unscoped ("all projects") token; a negative id can't collide with a real project id. +const ALL_PROJECTS_OPTION = { id: -1, name: 'All projects' }; export const TolgeeDetector = () => { const [state, dispatch] = useDetectorForm(); + const [connecting, setConnecting] = useState(false); + const [connectError, setConnectError] = useState(null); + const [tab, setTab] = useState<'login' | 'apiKey'>('login'); + const [serverOpen, setServerOpen] = useState(false); + const [branchOpen, setBranchOpen] = useState(false); const { error, @@ -28,8 +47,40 @@ export const TolgeeDetector = () => { tolgeePresent, credentialsCheck, branches, + declaredProject, + declaredProjectInaccessible, } = state; - const [branchOpen, setBranchOpen] = useState(false); + + const oauthUser = + credentialsCheck !== null && + typeof credentialsCheck === 'object' && + 'oauth' in credentialsCheck + ? credentialsCheck + : null; + + // Live-validate the key being typed on the API KEY tab (before it's applied), so a key that's invalid for the target + // server can't be silently connected. + const notConnected = !storedValues && !appliedValues; + const apiKeyCheck = useApiKeyCheck( + values?.apiUrl, + values?.apiKey, + tab === 'apiKey' && notConnected + ); + const apiKeyValid = + apiKeyCheck !== null && + typeof apiKeyCheck === 'object' && + 'projectName' in apiKeyCheck; + + // A single-project token auto-selects its project (done in the reducer); only an "all projects" token needs the + // manual picker below. + const allProjectsToken = decodeTokenProjectSet(values?.authToken) === '*'; + + // A restored API-key session should reopen on the API KEY tab; an OAuth session stays on LOGIN. + useEffect(() => { + if (values?.apiKey && !values?.authToken) { + setTab('apiKey'); + } + }, [values?.apiKey, values?.authToken]); const handleApplyChange = async () => { if (appliedValues) { @@ -46,7 +97,205 @@ export const TolgeeDetector = () => { } }; + const handleConnect = async () => { + const apiUrl = values?.apiUrl || DEFAULT_SERVER; + setConnecting(true); + setConnectError(null); + try { + // Hint the project the page is configured for (exposed via the handshake), so the consent screen pre-selects it + // and the minted token is scoped to it. On a public project the hint resolves via the community floor. + const hinted = (libConfig?.config as { projectId?: number | string }) + ?.projectId; + const projectId = + hinted !== undefined && hinted !== '' ? Number(hinted) : undefined; + // Capture the target tab now: launchWebAuthFlow closes the popup, so the background does the injection and needs + // the tab id up front. + const [activeTab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + const res = (await sendToBackground('OAUTH_LOGIN', { + apiUrl, + projectId, + tabId: activeTab?.id, + })) as { + accessToken?: string; + error?: string; + }; + if (res?.accessToken) { + dispatch({ + type: 'OAUTH_APPLY', + payload: { apiUrl, authToken: res.accessToken }, + }); + } else { + setConnectError(res?.error || 'Connection failed'); + } + } finally { + setConnecting(false); + } + }; + const dataPresent = storedValues || appliedValues; + + // Which credentials the session is actually built on, regardless of the Applied toggle: applied when live, otherwise + // the stored ones (an OAuth session's token is re-fetched into storedValues on load). + const activeValues = appliedValues || storedValues || values; + const isOauthSession = isOAuth(activeValues); + + // OAuth Disconnect drops this project's local token (service worker + storage); the server keeps the consent, so + // reconnecting the same account skips the consent screen (by design). API-key Disconnect is just the old Clear. + const handleDisconnect = async () => { + const apiUrl = activeValues?.apiUrl; + if (isOauthSession && apiUrl) { + await sendToBackground('OAUTH_LOGOUT', { + apiUrl, + projectId: activeValues?.projectId, + }); + } + dispatch({ type: 'CLEAR_ALL' }); + }; + + const serverField = ( + + + dispatch({ + type: 'CHANGE_VALUES', + payload: { apiUrl: e.target.value }, + }) + } + onKeyDown={handleKeyDown} + size="small" + /> + + ); + + const branchField = credentialsCheck !== null && + typeof credentialsCheck === 'object' && + 'branchingEnabled' in credentialsCheck && + credentialsCheck.branchingEnabled && ( + setBranchOpen(true)} + onClose={() => setBranchOpen(false)} + freeSolo + size="small" + disablePortal + slotProps={{ + popper: { + placement: 'bottom', + modifiers: [{ name: 'flip', enabled: false }], + }, + }} + ListboxProps={{ style: { maxHeight: 150 } }} + options={branches ?? []} + getOptionLabel={(option) => + typeof option === 'string' ? option : option.name + } + value={ + branches?.find((b) => b.name === values?.branch) ?? + values?.branch ?? + null + } + onChange={(_e: any, newValue: any) => { + dispatch({ + type: 'CHANGE_VALUES', + payload: { + branch: + typeof newValue === 'string' ? newValue : newValue?.name ?? '', + }, + }); + }} + onInputChange={(_e: any, newInput: string, reason: string) => { + if (reason === 'input') { + dispatch({ + type: 'CHANGE_VALUES', + payload: { branch: newInput }, + }); + } + }} + renderOption={(props, option) => ( +
  • + {option.name} + {option.isDefault && ( + default + )} +
  • + )} + renderInput={(params) => ( + + )} + /> + ); + + const projectPicker = declaredProject && ( + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={values?.projectId != null ? declaredProject : ALL_PROJECTS_OPTION} + onChange={(_e, newValue) => { + dispatch({ + type: 'OAUTH_SET_PROJECT', + payload: { + projectId: + newValue && newValue.id !== ALL_PROJECTS_OPTION.id + ? newValue.id + : undefined, + }, + }); + }} + renderInput={(params) => ( + + )} + /> + ); + + const footer = ( + + + + Applied + + + + ); + if (error) { return ( @@ -72,178 +321,249 @@ export const TolgeeDetector = () => { libConfig?.config.apiUrl === values?.apiUrl && (libConfig?.config.branch || '') === (values?.branch || ''); + const detectedProjectId = ( + libConfig?.config as { projectId?: number | string } + )?.projectId; + const projectDetected = + detectedProjectId !== undefined && detectedProjectId !== ''; + + let serverHost = values?.apiUrl || DEFAULT_SERVER; + // Restrict the link target to http(s): the Server field is editable, and a value like `javascript:...` would become + // an executable link running with extension privileges. Fall back to the default when it isn't a valid web URL yet. + let serverLink = DEFAULT_SERVER; + try { + const parsed = new URL(values?.apiUrl || DEFAULT_SERVER); + serverHost = parsed.host; + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + serverLink = parsed.toString(); + } + } catch { + // keep the raw value if it's not a full URL yet + } + + // Once a session exists (either auth method) the popup is a single status view — no tabs, no auth-key field. + if (dataPresent) { + return ( + + Tolgee plugin + + + + + + {isOauthSession ? ( + <> + + {oauthUser + ? `Connected as ${oauthUser.userFullName}` + : 'Connected'} + + {declaredProjectInaccessible ? ( + + This site requests a project you can’t edit on {serverHost}. + Check the projectId in the site’s Tolgee configuration, or ask + for access. + + ) : ( + projectPicker + )} + + ) : ( + <> + {credentialsCheck !== null && + typeof credentialsCheck === 'object' && + 'projectName' in credentialsCheck ? ( + + {credentialsCheck.projectName} + + ) : credentialsCheck === 'invalid' ? ( + + Invalid API key + + ) : null} + {branchField} + + )} + + {footer} + + ); + } + + // No session yet — let the user pick how to connect. return ( - - Tolgee settings - - - dispatch({ - type: 'CHANGE_VALUES', - payload: { apiUrl: e.target.value }, - }) - } - onKeyDown={handleKeyDown} - size="small" - /> - - - dispatch({ - type: 'CHANGE_VALUES', - payload: { apiKey: e.target.value }, - }) - } - onKeyDown={handleKeyDown} - size="small" - /> - - {credentialsCheck === null ? ( - '' - ) : credentialsCheck === 'loading' ? ( - '...' - ) : credentialsCheck === 'invalid' ? ( - 'Invalid' - ) : ( - - {credentialsCheck.projectName} - - )} - - - {typeof credentialsCheck === 'object' && - credentialsCheck?.branchingEnabled && ( - setBranchOpen(true)} - onClose={() => setBranchOpen(false)} - freeSolo - size="small" - disablePortal - slotProps={{ - popper: { - placement: 'bottom', - modifiers: [{ name: 'flip', enabled: false }], - }, - }} - ListboxProps={{ style: { maxHeight: 150 } }} - options={branches ?? []} - getOptionLabel={(option) => - typeof option === 'string' ? option : option.name - } - value={ - branches?.find((b) => b.name === values?.branch) ?? - values?.branch ?? - null - } - onChange={(_e: any, newValue: any) => { - dispatch({ - type: 'CHANGE_VALUES', - payload: { - branch: - typeof newValue === 'string' - ? newValue - : newValue?.name ?? '', - }, - }); - }} - onInputChange={(_e: any, newInput: string, reason: string) => { - if (reason === 'input') { - dispatch({ - type: 'CHANGE_VALUES', - payload: { branch: newInput }, - }); - } - }} - renderOption={(props, option) => ( -
  • - {option.name} - {option.isDefault && ( - - default - - )} -
  • - )} - renderInput={(params) => ( - - )} - /> - )} - Tolgee plugin + + setTab(v)} + textColor="primary" + indicatorColor="primary" > - - {dataPresent ? ( - <> - - Applied - - ) : isInDevelopmentMode ? ( - - Api key is included directly in Tolgee configuration.
    Use - this setup only in development environment. -
    - ) : ( - '' - )} -
    - - {dataPresent && ( + + +
    + + {tab === 'login' && + (projectDetected ? ( + + {serverOpen ? ( + serverField + ) : ( + + Connect to your account on{' '} + + {serverHost} + {' '} + and start translating. + + )} - )} + {connectError && {connectError}} + {serverOpen ? ( + + Change if you have your own instance of Tolgee. + + ) : ( + + setServerOpen(true)} + > + Change server + + + )} + + ) : ( + + + Project not detected + + + Ask the website administrator to add projectId to the Tolgee + configuration.{' '} + + Learn more + + + + + ))} + + {tab === 'apiKey' && ( + + {serverField} + + + dispatch({ + type: 'CHANGE_VALUES', + payload: { apiKey: e.target.value }, + }) + } + onKeyDown={(e) => { + if ( + e.keyCode === 13 && + validateValues(values) && + apiKeyValid + ) { + dispatch({ type: 'APPLY_VALUES' }); + } + }} + size="small" + /> + + {apiKeyCheck === null ? ( + '' + ) : apiKeyCheck === 'loading' ? ( + '...' + ) : apiKeyCheck === 'invalid' ? ( + 'Invalid API key for this server' + ) : apiKeyCheck === 'unreachable' ? ( + 'Could not reach the server' + ) : ( + + {apiKeyCheck.projectName} + + )} + + + + Where can I get an{' '} + + API key + + ? + + {isInDevelopmentMode && ( + + Api key is included directly in Tolgee configuration.
    Use + this setup only in development environment. +
    + )}
    -
    + )}
    ); } else if (tolgeePresent === 'legacy') { diff --git a/src/popup/reducer.test.ts b/src/popup/reducer.test.ts new file mode 100644 index 0000000..dc805f6 --- /dev/null +++ b/src/popup/reducer.test.ts @@ -0,0 +1,223 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LibConfig } from '../types'; +import { + Action, + createReducer, + initialState, + ProjectInfo, + State, +} from './reducer'; + +const lib = (overrides: Partial): LibConfig => + ({ + uiPresent: true, + mode: 'production', + config: { apiUrl: 'https://app.tolgee.io', apiKey: '' }, + ...overrides, + }) as unknown as LibConfig; + +describe('detector reducer', () => { + let apply: ReturnType; + let reduce: (state: State, action: Action) => State; + + beforeEach(() => { + apply = vi.fn(); + reduce = createReducer(apply); + }); + + describe('CHANGE_LIB_CONFIG', () => { + it('marks Tolgee present and seeds values from the page config', () => { + const next = reduce(initialState, { + type: 'CHANGE_LIB_CONFIG', + payload: { + libData: lib({ + config: { apiUrl: 'https://x.io', apiKey: '' } as any, + }), + frameId: 0, + }, + }); + expect(next.tolgeePresent).toBe('present'); + expect(next.values?.apiUrl).toBe('https://x.io'); + }); + + it('reports legacy when uiPresent is missing', () => { + const next = reduce(initialState, { + type: 'CHANGE_LIB_CONFIG', + payload: { libData: lib({ uiPresent: undefined as any }), frameId: 0 }, + }); + expect(next.tolgeePresent).toBe('legacy'); + }); + + it('reports not_present when there is no lib data', () => { + const next = reduce(initialState, { + type: 'CHANGE_LIB_CONFIG', + payload: { libData: null, frameId: 0 }, + }); + expect(next.tolgeePresent).toBe('not_present'); + }); + + it('errors when a second instance is detected in another frame', () => { + const first = reduce(initialState, { + type: 'CHANGE_LIB_CONFIG', + payload: { libData: lib({}), frameId: 0 }, + }); + const second = reduce(first, { + type: 'CHANGE_LIB_CONFIG', + payload: { libData: lib({}), frameId: 1 }, + }); + expect(second.error).toBe('Detected multiple Tolgee instances'); + }); + }); + + describe('OAUTH_APPLY', () => { + it('applies the token and preserves a previously picked project', () => { + const restored: State = { + ...initialState, + values: { apiUrl: 'https://app.tolgee.io', projectId: 42 }, + }; + const next = reduce(restored, { + type: 'OAUTH_APPLY', + payload: { apiUrl: 'https://app.tolgee.io', authToken: 'jwt' }, + }); + expect(next.values).toEqual({ + apiUrl: 'https://app.tolgee.io', + authToken: 'jwt', + projectId: 42, + }); + expect(next.appliedValues).toEqual(next.values); + expect(next.storedValues).toEqual(next.values); + expect(apply).toHaveBeenCalledOnce(); + }); + }); + + describe('OAUTH_SET_PROJECT', () => { + it('sets the project across values/applied/stored', () => { + const connected: State = { + ...initialState, + values: { apiUrl: 'https://app.tolgee.io', authToken: 'jwt' }, + }; + const next = reduce(connected, { + type: 'OAUTH_SET_PROJECT', + payload: { projectId: 7 }, + }); + expect(next.values?.projectId).toBe(7); + expect(next.appliedValues?.projectId).toBe(7); + expect(next.storedValues?.projectId).toBe(7); + expect(apply).toHaveBeenCalledOnce(); + }); + }); + + describe('APPLY_VALUES', () => { + const withBranch: State = { + ...initialState, + values: { + apiUrl: 'https://app.tolgee.io', + apiKey: 'tgpak_x', + branch: 'feature', + }, + }; + + it('drops the branch when branching is disabled', () => { + const next = reduce(withBranch, { type: 'APPLY_VALUES' }); + expect(next.appliedValues?.branch).toBeUndefined(); + expect(apply).toHaveBeenCalledOnce(); + }); + + it('keeps the branch when branching is enabled', () => { + const enabled: State = { + ...withBranch, + credentialsCheck: { + projectName: 'Demo', + projectId: 2, + scopes: [], + userFullName: 'Jo', + branchingEnabled: true, + } as ProjectInfo, + }; + const next = reduce(enabled, { type: 'APPLY_VALUES' }); + expect(next.appliedValues?.branch).toBe('feature'); + }); + + it('preserves the OAuth token and project (Enter in the Server field must not drop them)', () => { + const oauth: State = { + ...initialState, + values: { + apiUrl: 'https://app.tolgee.io', + authToken: 'access-token', + projectId: 7, + }, + }; + const next = reduce(oauth, { type: 'APPLY_VALUES' }); + expect(next.appliedValues?.authToken).toBe('access-token'); + expect(next.appliedValues?.projectId).toBe(7); + expect(next.storedValues?.authToken).toBe('access-token'); + expect(next.storedValues?.projectId).toBe(7); + }); + }); + + describe('CLEAR_ALL', () => { + it('wipes credentials and lib config', () => { + const dirty: State = { + ...initialState, + values: { apiUrl: 'https://app.tolgee.io', apiKey: 'tgpak_x' }, + appliedValues: { apiUrl: 'https://app.tolgee.io', apiKey: 'tgpak_x' }, + storedValues: { apiUrl: 'https://app.tolgee.io', apiKey: 'tgpak_x' }, + libConfig: lib({}), + }; + const next = reduce(dirty, { type: 'CLEAR_ALL' }); + expect(next.values).toBeNull(); + expect(next.storedValues).toBeNull(); + expect(next.appliedValues).toBeUndefined(); + expect(next.libConfig).toBeNull(); + expect(apply).toHaveBeenCalledOnce(); + }); + }); + + it('CHANGE_VALUES merges a partial patch', () => { + const next = reduce( + { ...initialState, values: { apiUrl: 'https://app.tolgee.io' } }, + { type: 'CHANGE_VALUES', payload: { apiKey: 'tgpak_x' } } + ); + expect(next.values).toEqual({ + apiUrl: 'https://app.tolgee.io', + apiKey: 'tgpak_x', + }); + expect(apply).not.toHaveBeenCalled(); + }); + + describe('RESOLVE_PROJECT', () => { + const connected: State = { + ...initialState, + values: { apiUrl: 'https://app.tolgee.io', authToken: 'jwt' }, + }; + + it('binds the resolved declared project and injects its id', () => { + const next = reduce(connected, { + type: 'RESOLVE_PROJECT', + payload: { project: { id: 7, name: 'Demo' }, inaccessible: false }, + }); + expect(next.declaredProject).toEqual({ id: 7, name: 'Demo' }); + expect(next.declaredProjectInaccessible).toBe(false); + expect(next.values?.projectId).toBe(7); + expect(next.appliedValues?.projectId).toBe(7); + expect(apply).toHaveBeenCalledOnce(); + }); + + it('flags an inaccessible declared project without injecting', () => { + const next = reduce(connected, { + type: 'RESOLVE_PROJECT', + payload: { project: null, inaccessible: true }, + }); + expect(next.declaredProjectInaccessible).toBe(true); + expect(next.declaredProject).toBeNull(); + expect(next.values?.projectId).toBeUndefined(); + expect(apply).not.toHaveBeenCalled(); + }); + }); + + it('throws on an unknown action', () => { + expect(() => + reduce(initialState, { type: 'NOPE' } as unknown as Action) + ).toThrow(); + }); +}); diff --git a/src/popup/reducer.ts b/src/popup/reducer.ts new file mode 100644 index 0000000..ed97557 --- /dev/null +++ b/src/popup/reducer.ts @@ -0,0 +1,245 @@ +import { LibConfig } from '../types'; +import { validateValues, Values } from './tools'; + +export type ProjectInfo = { + projectName: string; + projectId: number; + scopes: string[]; + userFullName: string; + branchingEnabled: boolean; +}; + +export type OAuthUser = { + oauth: true; + userFullName: string; +}; + +export type CredentialsCheck = + | null + | 'loading' + | 'invalid' + | ProjectInfo + | OAuthUser; +export type TolgeePresent = 'loading' | 'present' | 'not_present' | 'legacy'; + +export type BranchOption = { + name: string; + isDefault: boolean; +}; + +export type ProjectOption = { + id: number; + name: string; +}; + +export const initialState = { + values: null as Values | null, + storedValues: null as Values | null, + appliedValues: null as Values | null | undefined, + tolgeePresent: 'loading' as TolgeePresent, + credentialsCheck: null as CredentialsCheck, + libConfig: null as LibConfig | null, + error: null as string | null, + frameId: null as number | null, + branches: null as BranchOption[] | null, + // The project the page declared (via its Tolgee config), resolved against the connected server: the project when the + // user can edit it there, or `declaredProjectInaccessible` when they can't (wrong id / no access). + declaredProject: null as ProjectOption | null, + declaredProjectInaccessible: false, +}; + +export type State = typeof initialState; +export type Action = + | { type: 'CHANGE_VALUES'; payload: Partial } + | { + type: 'CHANGE_LIB_CONFIG'; + payload: { libData: LibConfig | null; frameId: number | null }; + } + | { type: 'SET_ERROR'; payload: string } + | { type: 'SET_APPLIED_VALUES'; payload: Values | null } + | { type: 'SET_CREDENTIALS_CHECK'; payload: CredentialsCheck } + | { type: 'LOAD_STORED_VALUES'; payload: Values | null } + | { type: 'APPLY_VALUES' } + | { type: 'CLEAR_ALL' } + | { type: 'STORE_VALUES' } + | { type: 'LOAD_VALUES' } + | { type: 'OAUTH_APPLY'; payload: { apiUrl: string; authToken: string } } + | { type: 'OAUTH_SET_PROJECT'; payload: { projectId: number | undefined } } + | { type: 'SET_BRANCHES'; payload: BranchOption[] | null } + | { + type: 'RESOLVE_PROJECT'; + payload: { project: ProjectOption | null; inaccessible: boolean }; + }; + +/** + * The reducer is a pure state transition, but a few actions also need to flag that the new state must be synced out to + * storage/sessionStorage. That side effect is injected as `apply` so the reducer stays testable without React. + */ +export const createReducer = + (apply: () => void) => + (state: State, action: Action): State => { + switch (action.type) { + case 'CHANGE_VALUES': + return { ...state, values: { ...state.values, ...action.payload } }; + case 'CHANGE_LIB_CONFIG': { + const { libData, frameId } = action.payload; + const newValues = { + apiKey: libData?.config?.apiKey, + apiUrl: libData?.config?.apiUrl, + branch: libData?.config?.branch, + }; + if (state.libConfig !== null && state.frameId !== frameId) { + return { + ...state, + error: 'Detected multiple Tolgee instances', + }; + } + return { + ...state, + libConfig: libData, + frameId, + values: validateValues(state.values) || newValues, + tolgeePresent: !libData + ? 'not_present' + : libData.uiPresent === undefined + ? 'legacy' + : 'present', + }; + } + case 'SET_ERROR': + return { + ...state, + tolgeePresent: 'not_present', + error: action.payload, + }; + case 'SET_APPLIED_VALUES': + return { + ...state, + appliedValues: action.payload, + }; + case 'SET_CREDENTIALS_CHECK': + return { + ...state, + credentialsCheck: action.payload, + }; + case 'LOAD_STORED_VALUES': + return { + ...state, + storedValues: action.payload, + values: action.payload, + }; + case 'APPLY_VALUES': { + // sync values with storage/localStorage + apply(); + const branchEnabled = + state.credentialsCheck !== null && + typeof state.credentialsCheck === 'object' && + 'branchingEnabled' in state.credentialsCheck && + state.credentialsCheck.branchingEnabled; + const effectiveBranch = branchEnabled + ? state.values?.branch + : undefined; + // Carry the OAuth fields through: this action also fires on the Login tab (Enter in the Server field), and + // dropping authToken/projectId there would wipe the token from state and remove the stored OAuth session. + const nextValues = { + apiKey: state.values?.apiKey, + apiUrl: state.values?.apiUrl, + branch: effectiveBranch, + authToken: state.values?.authToken, + projectId: state.values?.projectId, + }; + return { + ...state, + appliedValues: nextValues, + storedValues: nextValues, + }; + } + case 'CLEAR_ALL': { + apply(); + return { + ...state, + appliedValues: undefined, + storedValues: null, + values: null, + libConfig: null, + declaredProject: null, + declaredProjectInaccessible: false, + }; + } + case 'OAUTH_APPLY': { + apply(); + // Keep any project the user already picked for this backend (restored from storage) across a re-connect. + const oauthValues = { + apiUrl: action.payload.apiUrl, + authToken: action.payload.authToken, + projectId: state.values?.projectId, + }; + return { + ...state, + values: oauthValues, + appliedValues: oauthValues, + storedValues: oauthValues, + declaredProject: null, + declaredProjectInaccessible: false, + }; + } + case 'OAUTH_SET_PROJECT': { + apply(); + const oauthValues = { + ...state.values, + projectId: action.payload.projectId, + }; + return { + ...state, + values: oauthValues, + appliedValues: oauthValues, + storedValues: oauthValues, + }; + } + case 'RESOLVE_PROJECT': { + const { project, inaccessible } = action.payload; + if (!project) { + return { + ...state, + declaredProject: null, + declaredProjectInaccessible: inaccessible, + }; + } + // Bind the declared project so in-context editing has a target: an all-projects token carries none, and even a + // single-project token needs the id sent explicitly on every request. + apply(); + const oauthValues = { ...state.values, projectId: project.id }; + return { + ...state, + declaredProject: project, + declaredProjectInaccessible: false, + values: oauthValues, + appliedValues: oauthValues, + storedValues: oauthValues, + }; + } + case 'STORE_VALUES': + apply(); + return { + ...state, + storedValues: state.appliedValues || null, + values: state.appliedValues || null, + appliedValues: null, + }; + case 'LOAD_VALUES': + apply(); + return { + ...state, + appliedValues: state.storedValues, + values: state.storedValues, + }; + case 'SET_BRANCHES': + return { + ...state, + branches: action.payload, + }; + default: + // @ts-expect-error action type is type uknown + throw new Error(`Unknown action ${action.type}`); + } + }; diff --git a/src/popup/sendToBackground.ts b/src/popup/sendToBackground.ts new file mode 100644 index 0000000..55d9eb9 --- /dev/null +++ b/src/popup/sendToBackground.ts @@ -0,0 +1,6 @@ +import browser from 'webextension-polyfill'; + +// Messages the service worker (OAuth login/refresh/logout), unlike sendMessage which targets the page content script. +export const sendToBackground = async (type: string, data?: any) => { + return browser.runtime.sendMessage({ type, data }); +}; diff --git a/src/popup/storage.ts b/src/popup/storage.ts index 19b1abb..7c0b848 100644 --- a/src/popup/storage.ts +++ b/src/popup/storage.ts @@ -4,6 +4,11 @@ type Values = { apiUrl?: string; apiKey?: string; branch?: string; + // OAuth sessions persist only a marker + backend url (+ the picked project) here; the token itself lives in the + // service worker's tokenStore (kept fresh via refresh) and is re-fetched on load, so a short-lived token is never + // stored stale. + oauth?: boolean; + projectId?: number; }; const getCurrentTab = async () => { @@ -17,11 +22,21 @@ const getCurrentTabOrigin = async () => { return url.origin; }; -export const storeValues = async (values: Values | null) => { +export const storeValues = async ( + values: (Values & { authToken?: string }) | null +) => { try { const origin = await getCurrentTabOrigin(); - if (values?.apiKey && values?.apiUrl) { + if (values?.authToken && values?.apiUrl) { + browser.storage.local.set({ + [origin]: { + apiUrl: values.apiUrl, + oauth: true, + projectId: values.projectId, + }, + }); + } else if (values?.apiKey && values?.apiUrl) { browser.storage.local.set({ [origin]: { apiUrl: values.apiUrl, @@ -48,6 +63,8 @@ export const loadValues = async () => { apiKey: data?.apiKey, apiUrl: data?.apiUrl, branch: data?.branch, + oauth: data?.oauth, + projectId: data?.projectId, }; } catch (e) { console.error(e); diff --git a/src/popup/tools.test.ts b/src/popup/tools.test.ts new file mode 100644 index 0000000..701fc5a --- /dev/null +++ b/src/popup/tools.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { + compareValues, + decodeTokenProjectSet, + isOAuth, + normalizeUrl, + validateValues, +} from './tools'; + +// Builds a JWT-shaped string (header.payload.signature) whose payload base64url-encodes the given claims, so we can +// exercise the token parsing without a real signature. +const tokenWith = (claims: Record) => { + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url'); + return `eyJhbGciOiJSUzI1NiJ9.${payload}.signature`; +}; + +describe('decodeTokenProjectSet', () => { + it('returns "*" for an all-projects token', () => { + expect(decodeTokenProjectSet(tokenWith({ 'tg.prj': '*' }))).toBe('*'); + }); + + it('returns the ids for a project-scoped token', () => { + expect(decodeTokenProjectSet(tokenWith({ 'tg.prj': [2] }))).toEqual([2]); + expect(decodeTokenProjectSet(tokenWith({ 'tg.prj': [2, 3] }))).toEqual([ + 2, 3, + ]); + }); + + it('coerces string ids to numbers and drops non-numeric entries', () => { + expect( + decodeTokenProjectSet(tokenWith({ 'tg.prj': ['2', 'x', 3] })) + ).toEqual([2, 3]); + }); + + it('returns undefined when the claim is absent', () => { + expect(decodeTokenProjectSet(tokenWith({ sub: '1' }))).toBeUndefined(); + }); + + it('returns undefined for an empty or malformed token', () => { + expect(decodeTokenProjectSet(undefined)).toBeUndefined(); + expect(decodeTokenProjectSet('not-a-jwt')).toBeUndefined(); + }); +}); + +describe('validateValues', () => { + it('accepts an api key with a url', () => { + const v = { apiKey: 'tgpak_x', apiUrl: 'https://app.tolgee.io' }; + expect(validateValues(v)).toBe(v); + }); + + it('accepts an oauth token with a url', () => { + const v = { authToken: 'jwt', apiUrl: 'https://app.tolgee.io' }; + expect(validateValues(v)).toBe(v); + }); + + it('rejects a credential without a url', () => { + expect(validateValues({ apiKey: 'tgpak_x' })).toBeNull(); + }); + + it('rejects a url without any credential', () => { + expect(validateValues({ apiUrl: 'https://app.tolgee.io' })).toBeNull(); + }); + + it('rejects null/undefined', () => { + expect(validateValues(null)).toBeNull(); + expect(validateValues(undefined)).toBeNull(); + }); +}); + +describe('isOAuth', () => { + it('is true only for a bare auth token', () => { + expect(isOAuth({ authToken: 'jwt' })).toBe(true); + }); + + it('is false when an api key is also present', () => { + expect(isOAuth({ authToken: 'jwt', apiKey: 'tgpak_x' })).toBe(false); + }); + + it('is false for an api key alone or nothing', () => { + expect(isOAuth({ apiKey: 'tgpak_x' })).toBe(false); + expect(isOAuth(undefined)).toBe(false); + }); +}); + +describe('normalizeUrl', () => { + it('strips a single trailing slash', () => { + expect(normalizeUrl('https://app.tolgee.io/')).toBe( + 'https://app.tolgee.io' + ); + }); + + it('leaves a url without a trailing slash untouched', () => { + expect(normalizeUrl('https://app.tolgee.io')).toBe('https://app.tolgee.io'); + }); + + it('passes through undefined', () => { + expect(normalizeUrl(undefined)).toBeUndefined(); + }); +}); + +describe('compareValues', () => { + const base = { + apiUrl: 'https://app.tolgee.io', + apiKey: 'tgpak_x', + branch: 'main', + projectId: 2, + }; + + it('treats identical values as equal', () => { + expect(compareValues(base, { ...base })).toBe(true); + }); + + it('treats an empty branch and undefined branch as equal', () => { + expect( + compareValues({ ...base, branch: '' }, { ...base, branch: undefined }) + ).toBe(true); + }); + + it('detects a differing field', () => { + expect(compareValues(base, { ...base, projectId: 3 })).toBe(false); + expect(compareValues(base, { ...base, authToken: 'jwt' })).toBe(false); + }); + + it('treats a string projectId and a numeric projectId as equal', () => { + expect(compareValues({ ...base, projectId: '2' as any }, { ...base })).toBe( + true + ); + }); + + it('treats a null apiKey and an undefined apiKey as equal', () => { + const oauth = { apiUrl: base.apiUrl, authToken: 'jwt', projectId: 1 }; + expect(compareValues({ ...oauth, apiKey: null as any }, { ...oauth })).toBe( + true + ); + }); +}); diff --git a/src/popup/tools.ts b/src/popup/tools.ts index 5f331e0..769e05d 100644 --- a/src/popup/tools.ts +++ b/src/popup/tools.ts @@ -2,22 +2,37 @@ export type Values = { apiUrl?: string; apiKey?: string; branch?: string; + // OAuth access token (from "Connect with Tolgee"); an alternative to apiKey + authToken?: string; + // Project selected in the OAuth path; PAKs embed the project id, OAuth tokens don't, so the user picks one. + projectId?: number; }; export const validateValues = (values?: Values | null) => { - if (values?.apiKey && values?.apiUrl) { + if ((values?.apiKey || values?.authToken) && values?.apiUrl) { return values; } return null; }; +export const isOAuth = (values?: Values | null) => + Boolean(values?.authToken && !values?.apiKey); + export const compareValues = ( values1?: Values | null, values2?: Values | null ) => { + // A restored session and the page's applied values describe the same credentials but not identically: sessionStorage + // hands back projectId as a string and apiKey as null, while the stored copy holds a number and undefined. Normalize + // so a healthy OAuth session isn't seen as "changed" (which would skip the connected-user lookup). + const str = (v?: string | null) => v || undefined; + const num = (v?: number | string | null) => + v === undefined || v === null || v === '' ? undefined : Number(v); return ( - values1?.apiKey === values2?.apiKey && - values1?.apiUrl === values2?.apiUrl && + str(values1?.apiKey) === str(values2?.apiKey) && + str(values1?.apiUrl) === str(values2?.apiUrl) && + str(values1?.authToken) === str(values2?.authToken) && + num(values1?.projectId) === num(values2?.projectId) && (values1?.branch || '') === (values2?.branch || '') ); }; @@ -25,3 +40,7 @@ export const compareValues = ( export function normalizeUrl(url: string | undefined) { return url?.replace(/\/$/, ''); } + +// The project-set decoder lives with the OAuth token store so the service worker can key sessions by scope; re-exported +// here for the popup, which reads it to drive the project picker. +export { decodeTokenProjectSet } from '../oauth/tokenScope'; diff --git a/src/popup/useApiKeyCheck.ts b/src/popup/useApiKeyCheck.ts new file mode 100644 index 0000000..36ab49e --- /dev/null +++ b/src/popup/useApiKeyCheck.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react'; +import { normalizeUrl } from './tools'; + +// Result of live-validating a typed API key against its server, before it's applied. +export type ApiKeyCheck = + | null + | 'loading' + | 'invalid' + | 'unreachable' + | { projectName: string }; + +/** + * Validates the API key the user is typing against `/v2/api-keys/current` on the target server, so an invalid key + * (e.g. a cloud key pointed at a local backend) can't be silently applied. Debounced; only runs while `enabled`. + */ +export const useApiKeyCheck = ( + apiUrl: string | undefined, + apiKey: string | undefined, + enabled: boolean +): ApiKeyCheck => { + const [check, setCheck] = useState(null); + + useEffect(() => { + const url = normalizeUrl(apiUrl || ''); + if (!enabled || !apiKey || !url) { + setCheck(null); + return; + } + + let cancelled = false; + setCheck('loading'); + const timer = setTimeout(() => { + // Send the key in the header, not the query string, so it can't leak via URLs/history/logs and an `&`/`#` in it + // can't corrupt the request. + fetch(`${url}/v2/api-keys/current`, { headers: { 'X-API-Key': apiKey } }) + .then((r) => { + if (r.ok) { + return r.json().then((data) => { + if (!cancelled) setCheck({ projectName: data.projectName }); + }); + } + // Only an auth/permission rejection means the key itself is wrong; any other status is a server problem, so + // don't tell the user a valid key is invalid just because the backend is down or misconfigured. + if (!cancelled) { + setCheck( + [400, 401, 403].includes(r.status) ? 'invalid' : 'unreachable' + ); + } + }) + .catch(() => { + // Network failure, DNS, or a CORS block — the server couldn't be reached, which is not an invalid key. + if (!cancelled) setCheck('unreachable'); + }); + }, 400); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [enabled, apiUrl, apiKey]); + + return check; +}; diff --git a/src/popup/useDetectorForm.tsx b/src/popup/useDetectorForm.tsx index 735f5a6..602c654 100644 --- a/src/popup/useDetectorForm.tsx +++ b/src/popup/useDetectorForm.tsx @@ -1,171 +1,25 @@ /* eslint-disable react-hooks/exhaustive-deps */ import browser, { type Runtime } from 'webextension-polyfill'; import { useEffect, useReducer } from 'react'; -import { LibConfig } from '../types'; import { loadAppliedValues } from './loadConfig'; import { sendMessage } from './sendMessage'; +import { sendToBackground } from './sendToBackground'; import { loadValues, storeValues } from './storage'; -import { compareValues, normalizeUrl, validateValues, Values } from './tools'; +import { + compareValues, + isOAuth, + normalizeUrl, + validateValues, + Values, +} from './tools'; import { useApplier } from './useApplier'; import { RuntimeMessage } from '../content/Messages'; - -type ProjectInfo = { - projectName: string; - projectId: number; - scopes: string[]; - userFullName: string; - branchingEnabled: boolean; -}; - -type CredentialsCheck = null | 'loading' | 'invalid' | ProjectInfo; -type TolgeePresent = 'loading' | 'present' | 'not_present' | 'legacy'; - -type BranchOption = { - name: string; - isDefault: boolean; -}; - -const initialState = { - values: null as Values | null, - storedValues: null as Values | null, - appliedValues: null as Values | null | undefined, - tolgeePresent: 'loading' as TolgeePresent, - credentialsCheck: null as CredentialsCheck, - libConfig: null as LibConfig | null, - error: null as string | null, - frameId: null as number | null, - branches: null as BranchOption[] | null, -}; - -type State = typeof initialState; -type Action = - | { type: 'CHANGE_VALUES'; payload: Partial } - | { - type: 'CHANGE_LIB_CONFIG'; - payload: { libData: LibConfig | null; frameId: number | null }; - } - | { type: 'SET_ERROR'; payload: string } - | { type: 'SET_APPLIED_VALUES'; payload: Values | null } - | { type: 'SET_CREDENTIALS_CHECK'; payload: CredentialsCheck } - | { type: 'LOAD_STORED_VALUES'; payload: Values | null } - | { type: 'APPLY_VALUES' } - | { type: 'CLEAR_ALL' } - | { type: 'STORE_VALUES' } - | { type: 'LOAD_VALUES' } - | { type: 'SET_BRANCHES'; payload: BranchOption[] | null }; +import { CredentialsCheck, createReducer, initialState } from './reducer'; export const useDetectorForm = () => { const { applyRequired, apply } = useApplier(); - const reducer = (state: State, action: Action): State => { - switch (action.type) { - case 'CHANGE_VALUES': - return { ...state, values: { ...state.values, ...action.payload } }; - case 'CHANGE_LIB_CONFIG': { - const { libData, frameId } = action.payload; - const newValues = { - apiKey: libData?.config?.apiKey, - apiUrl: libData?.config?.apiUrl, - branch: libData?.config?.branch, - }; - if (state.libConfig !== null && state.frameId !== frameId) { - return { - ...state, - error: 'Detected multiple Tolgee instances', - }; - } - return { - ...state, - libConfig: libData, - frameId, - values: validateValues(state.values) || newValues, - tolgeePresent: !libData - ? 'not_present' - : libData.uiPresent === undefined - ? 'legacy' - : 'present', - }; - } - case 'SET_ERROR': - return { - ...state, - tolgeePresent: 'not_present', - error: action.payload, - }; - case 'SET_APPLIED_VALUES': - return { - ...state, - appliedValues: action.payload, - }; - case 'SET_CREDENTIALS_CHECK': - return { - ...state, - credentialsCheck: action.payload, - }; - case 'LOAD_STORED_VALUES': - return { - ...state, - storedValues: action.payload, - values: action.payload, - }; - case 'APPLY_VALUES': { - // sync values with storage/localStorage - apply(); - const branchEnabled = - typeof state.credentialsCheck === 'object' && - state.credentialsCheck?.branchingEnabled; - const effectiveBranch = branchEnabled - ? state.values?.branch - : undefined; - return { - ...state, - appliedValues: { - apiKey: state.values?.apiKey, - apiUrl: state.values?.apiUrl, - branch: effectiveBranch, - }, - storedValues: { - apiKey: state.values?.apiKey, - apiUrl: state.values?.apiUrl, - branch: effectiveBranch, - }, - }; - } - case 'CLEAR_ALL': { - apply(); - return { - ...state, - appliedValues: undefined, - storedValues: null, - values: null, - libConfig: null, - }; - } - case 'STORE_VALUES': - apply(); - return { - ...state, - storedValues: state.appliedValues || null, - values: state.appliedValues || null, - appliedValues: null, - }; - case 'LOAD_VALUES': - apply(); - return { - ...state, - appliedValues: state.storedValues, - values: state.storedValues, - }; - case 'SET_BRANCHES': - return { - ...state, - branches: action.payload, - }; - default: - // @ts-expect-error action type is type uknown - throw new Error(`Unknown action ${action.type}`); - } - }; + const reducer = createReducer(apply); const [state, dispatch] = useReducer(reducer, initialState); const { storedValues, appliedValues, libConfig } = state; @@ -218,7 +72,24 @@ export const useDetectorForm = () => { } const storedData = await loadValues(); - if (validateValues(storedData)) { + if (storedData.oauth && storedData.apiUrl) { + // OAuth sessions store no token; ask the service worker for a fresh (auto-refreshed) one for this project (its + // own session if concrete, else the all-projects one). + const res = (await sendToBackground('OAUTH_GET_TOKEN', { + apiUrl: storedData.apiUrl, + projectId: storedData.projectId, + })) as { accessToken?: string }; + if (res?.accessToken) { + dispatch({ + type: 'LOAD_STORED_VALUES', + payload: { + apiUrl: storedData.apiUrl, + authToken: res.accessToken, + projectId: storedData.projectId, + }, + }); + } + } else if (validateValues(storedData)) { dispatch({ type: 'LOAD_STORED_VALUES', payload: storedData }); } }; @@ -268,43 +139,72 @@ export const useDetectorForm = () => { const url = normalizeUrl(checkableValues!.apiUrl); - fetch(`${url}/v2/api-keys/current?ak=${checkableValues!.apiKey}`) - .then((r) => { - if (r.ok) { - return r.json(); - } else { - throw r.json(); - } + if (isOAuth(checkableValues)) { + // OAuth tokens are not tied to a single project; confirm the token and show the connected user instead. + fetch(`${url}/v2/user`, { + headers: { Authorization: `Bearer ${checkableValues!.authToken}` }, }) - .catch(() => { - !cancelled && setCredentialsCheck('invalid'); + .then((r) => { + if (r.ok) { + return r.json(); + } + throw new Error('Invalid token'); + }) + .then((data) => { + !cancelled && + setCredentialsCheck({ oauth: true, userFullName: data.name }); + }) + .catch(() => { + !cancelled && setCredentialsCheck('invalid'); + }); + } else { + // Send the key in the header, not the query string, so it can't leak via URLs/history/logs. + fetch(`${url}/v2/api-keys/current`, { + headers: { 'X-API-Key': checkableValues!.apiKey! }, }) - .then((data) => { - !cancelled && - data && - setCredentialsCheck({ - projectName: data.projectName, - projectId: data.projectId, - scopes: data.scopes, - userFullName: data.userFullName, - branchingEnabled: data.branchingEnabled ?? false, - }); - }); + .then((r) => { + if (r.ok) { + return r.json(); + } else { + throw r.json(); + } + }) + .catch(() => { + !cancelled && setCredentialsCheck('invalid'); + }) + .then((data) => { + !cancelled && + data && + setCredentialsCheck({ + projectName: data.projectName, + projectId: data.projectId, + scopes: data.scopes, + userFullName: data.userFullName, + branchingEnabled: data.branchingEnabled ?? false, + }); + }); + } } else { setCredentialsCheck(null); } return () => { cancelled = true; }; - }, [checkableValues?.apiUrl, checkableValues?.apiKey]); + }, [ + checkableValues?.apiUrl, + checkableValues?.apiKey, + checkableValues?.authToken, + ]); // fetch branches when credentials are valid and branching is enabled useEffect(() => { let cancelled = false; const check = state.credentialsCheck; if ( + check !== null && typeof check === 'object' && - check?.branchingEnabled && + 'branchingEnabled' in check && + check.branchingEnabled && validateValues(checkableValues) ) { const url = normalizeUrl(checkableValues!.apiUrl); @@ -344,5 +244,61 @@ export const useDetectorForm = () => { }; }, [state.credentialsCheck]); + // The page declares which project it edits (required by the extension), but an OAuth token isn't inherently bound to + // it. Resolve that declared id against the connected server: bind it when the user can edit it there, or flag it + // inaccessible — otherwise the token stays unscoped and in-context editing fails with "project not selected". + useEffect(() => { + let cancelled = false; + const check = state.credentialsCheck; + const isOauthCheck = + check !== null && + typeof check === 'object' && + 'oauth' in check && + isOAuth(checkableValues); + const declaredId = Number( + (libConfig?.config as { projectId?: number | string } | undefined) + ?.projectId + ); + if (!isOauthCheck || !declaredId) { + dispatch({ + type: 'RESOLVE_PROJECT', + payload: { project: null, inaccessible: false }, + }); + return; + } + const url = normalizeUrl(checkableValues!.apiUrl); + fetch(`${url}/v2/projects/${declaredId}`, { + headers: { Authorization: `Bearer ${checkableValues!.authToken}` }, + }) + .then((r) => { + if (!r.ok) { + throw new Error('inaccessible'); + } + return r.json(); + }) + .then((data) => { + if (!cancelled) { + dispatch({ + type: 'RESOLVE_PROJECT', + payload: { + project: { id: data.id, name: data.name }, + inaccessible: false, + }, + }); + } + }) + .catch(() => { + if (!cancelled) { + dispatch({ + type: 'RESOLVE_PROJECT', + payload: { project: null, inaccessible: true }, + }); + } + }); + return () => { + cancelled = true; + }; + }, [state.credentialsCheck]); + return [state, dispatch] as const; }; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fc0cd24 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +// Standalone from the crxjs build configs on purpose: these are pure unit tests (popup logic + helpers) that need +// neither the extension bundler plugins nor a DOM. +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + }, +});