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..03fc092 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,34 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => { setStateIcon(data, sender.tab!.id!); sendResponse({}); break; + case 'OAUTH_LOGIN': + login(data.apiUrl, data.projectId) + .then(async (tokens) => { + await saveSession(data.apiUrl, tokens); + // 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: tokens.accessToken, + projectId: data.projectId, + }); + } + sendResponse({ accessToken: tokens.accessToken }); + }) + .catch((e) => { + console.error('[tolgee-oauth] login failed', e); + sendResponse({ error: String(e) }); + }); + return true; + case 'OAUTH_GET_TOKEN': + getValidAccessToken(data.apiUrl).then((accessToken) => + sendResponse({ accessToken }) + ); + return true; + case 'OAUTH_LOGOUT': + clearSession(data.apiUrl).then(() => sendResponse({})); + return true; default: sendResponse({}); } @@ -27,3 +65,50 @@ const setStateIcon = (state: State, tabId: number) => { tabId, }); }; + +// 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. +browser.alarms.create(REFRESH_ALARM, { periodInMinutes: 10 }); +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; + } + const accessToken = await getValidAccessToken(session.apiUrl); + if (accessToken) { + await pushTokenToTabs(session.apiUrl, accessToken); + } + } +}); + +// Update the injected access token in every tab whose applied backend matches, without reloading the page. +const pushTokenToTabs = async (apiUrl: 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, 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..344282d 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; @@ -66,22 +81,37 @@ 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) => { + if (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..763b8d7 --- /dev/null +++ b/src/oauth/oauthClient.ts @@ -0,0 +1,96 @@ +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(/\/$/, ''); + +export const getRedirectUri = () => browser.identity.getRedirectURL(); + +const parseTokenResponse = ( + data: Record, + previousRefreshToken?: string +): OAuthTokens => ({ + 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() + (data.expires_in ?? 0) * 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); +}; + +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'); + authorizeUrl.searchParams.set('state', randomUrlSafe()); + if (projectId != null) { + authorizeUrl.searchParams.set('project', String(projectId)); + } + + const redirectResponse = await browser.identity.launchWebAuthFlow({ + url: authorizeUrl.toString(), + interactive: true, + }); + const code = new URL(redirectResponse).searchParams.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/tokenStore.ts b/src/oauth/tokenStore.ts new file mode 100644 index 0000000..d868901 --- /dev/null +++ b/src/oauth/tokenStore.ts @@ -0,0 +1,57 @@ +import browser from 'webextension-polyfill'; +import { OAUTH_REFRESH_SKEW_MS } from '../constants'; +import { OAuthTokens, refresh } from './oauthClient'; + +export type StoredSession = OAuthTokens & { apiUrl: string }; + +// Sessions are keyed by the Tolgee backend origin, so one login is reused across every page that targets it. +const keyFor = (apiUrl: string) => `oauth:${new URL(apiUrl).origin}`; + +export const saveSession = async (apiUrl: string, tokens: OAuthTokens) => { + await browser.storage.local.set({ [keyFor(apiUrl)]: { ...tokens, apiUrl } }); +}; + +export const loadSession = async ( + apiUrl: string +): Promise => { + const key = keyFor(apiUrl); + const stored = await browser.storage.local.get(key); + return (stored[key] as StoredSession) ?? null; +}; + +export const clearSession = async (apiUrl: string) => { + await browser.storage.local.remove(keyFor(apiUrl)); +}; + +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); +}; + +// Returns a valid access token, refreshing (and persisting) if it is expired or near expiry. +// Returns null (and clears the session) when there is nothing valid to fall back on — the caller must re-login. +export const getValidAccessToken = async ( + apiUrl: string +): Promise => { + const session = await loadSession(apiUrl); + if (!session) { + return null; + } + if (session.expiresAt - OAUTH_REFRESH_SKEW_MS > Date.now()) { + return session.accessToken; + } + if (!session.refreshToken) { + await clearSession(apiUrl); + return null; + } + try { + const refreshed = await refresh(apiUrl, session.refreshToken); + await saveSession(apiUrl, refreshed); + return refreshed.accessToken; + } catch (e) { + await clearSession(apiUrl); + return null; + } +}; diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index 310d005..2e543a2 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -1,23 +1,40 @@ -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'; 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 [tab, setTab] = useState<'login' | 'apiKey'>('login'); + const [serverOpen, setServerOpen] = useState(false); + const [branchOpen, setBranchOpen] = useState(false); const { error, @@ -28,8 +45,27 @@ 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; + + // 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 +82,199 @@ export const TolgeeDetector = () => { } }; + const handleConnect = async () => { + const apiUrl = values?.apiUrl || DEFAULT_SERVER; + setConnecting(true); + 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 }, + }); + } + } 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 the local token (service worker + storage) but keeps the server-side consent, so reconnecting + // is silent. API-key Disconnect is just the old Clear. Either way the local session is wiped. + const handleDisconnect = async () => { + const apiUrl = activeValues?.apiUrl; + if (isOauthSession && apiUrl) { + await sendToBackground('OAUTH_LOGOUT', { apiUrl }); + } + 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 +300,223 @@ 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; + try { + serverHost = new URL(serverHost).host; + } 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. + + )} - )} + {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={handleKeyDown} + size="small" + /> + + {credentialsCheck === null + ? '' + : credentialsCheck === 'loading' + ? '...' + : credentialsCheck === 'invalid' + ? 'Invalid' + : ''} + + + + 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..9d79c22 --- /dev/null +++ b/src/popup/reducer.test.ts @@ -0,0 +1,207 @@ +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'); + }); + }); + + 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..5fb5e10 --- /dev/null +++ b/src/popup/reducer.ts @@ -0,0 +1,244 @@ +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; + 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, + 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..8e632bd --- /dev/null +++ b/src/popup/tools.test.ts @@ -0,0 +1,123 @@ +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); + }); +}); diff --git a/src/popup/tools.ts b/src/popup/tools.ts index 5f331e0..eb5809a 100644 --- a/src/popup/tools.ts +++ b/src/popup/tools.ts @@ -2,15 +2,22 @@ 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 @@ -18,6 +25,8 @@ export const compareValues = ( return ( values1?.apiKey === values2?.apiKey && values1?.apiUrl === values2?.apiUrl && + values1?.authToken === values2?.authToken && + values1?.projectId === values2?.projectId && (values1?.branch || '') === (values2?.branch || '') ); }; @@ -25,3 +34,28 @@ export const compareValues = ( export function normalizeUrl(url: string | undefined) { return url?.replace(/\/$/, ''); } + +// 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; + } +} diff --git a/src/popup/useDetectorForm.tsx b/src/popup/useDetectorForm.tsx index 735f5a6..d1aee5a 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,22 @@ 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. + const res = (await sendToBackground('OAUTH_GET_TOKEN', { + apiUrl: storedData.apiUrl, + })) 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 +137,69 @@ 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((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(); + } + throw new Error('Invalid token'); + }) + .then((data) => { + !cancelled && + setCredentialsCheck({ oauth: true, userFullName: data.name }); + }) + .catch(() => { + !cancelled && setCredentialsCheck('invalid'); + }); + } else { + fetch(`${url}/v2/api-keys/current?ak=${checkableValues!.apiKey}`) + .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 +239,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', + }, +});