From 7dd05dfc79627029e9bd6986de1ee0c307a0510b Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Thu, 6 Aug 2026 12:59:18 +0200 Subject: [PATCH 1/8] feat: add "Connect with Tolgee" OAuth login Adds a browser-redirect OAuth 2.1 login alongside the existing API-key path: - oauth/: PKCE (S256) via Web Crypto, authorization-code flow through chrome.identity.launchWebAuthFlow, token exchange + refresh, and a per-backend token store in the service worker. - background: OAUTH_LOGIN/OAUTH_GET_TOKEN/OAUTH_LOGOUT handlers plus a chrome.alarms routine that proactively refreshes rotating tokens and pushes the new access token into matching tabs without reloading. - content: injects the access token as __tolgee_authToken into page sessionStorage (the refresh token never leaves the service worker) and updates it in place on refresh. - popup: a "Connect with Tolgee" button; OAuth sessions persist only a marker + backend url and re-fetch a fresh token on open, so a short-lived token is never stored stale. - manifest: adds the "identity" and "alarms" permissions. --- manifest.json | 2 +- package-lock.json | 17 +++++ src/background/background.ts | 62 ++++++++++++++++++ src/constants.ts | 19 ++++++ src/content/contentScript.ts | 25 +++++++ src/oauth/oauthClient.ts | 95 +++++++++++++++++++++++++++ src/oauth/pkce.ts | 23 +++++++ src/oauth/tokenStore.ts | 57 ++++++++++++++++ src/popup/TolgeeDetector.tsx | 43 +++++++++++- src/popup/sendToBackground.ts | 6 ++ src/popup/storage.ts | 14 +++- src/popup/tools.ts | 8 ++- src/popup/useDetectorForm.tsx | 119 ++++++++++++++++++++++++++-------- 13 files changed, 457 insertions(+), 33 deletions(-) create mode 100644 src/oauth/oauthClient.ts create mode 100644 src/oauth/pkce.ts create mode 100644 src/oauth/tokenStore.ts create mode 100644 src/popup/sendToBackground.ts diff --git a/manifest.json b/manifest.json index 3797d7b..0f312d2 100644 --- a/manifest.json +++ b/manifest.json @@ -14,7 +14,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..b5523fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,6 +87,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 +588,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 +629,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", @@ -1199,6 +1202,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", @@ -4583,6 +4587,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 +4675,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", @@ -4867,6 +4873,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5109,6 +5116,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001565", "electron-to-chromium": "^1.4.601", @@ -6214,6 +6222,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 +6278,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" }, @@ -7707,6 +7717,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" }, @@ -8462,6 +8473,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz", "integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8591,6 +8603,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 +8615,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 +8978,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", @@ -9636,6 +9651,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" @@ -9759,6 +9775,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", diff --git a/src/background/background.ts b/src/background/background.ts index f46884f..599b717 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,22 @@ 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); + sendResponse({ accessToken: tokens.accessToken }); + }) + .catch((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 +53,39 @@ const setStateIcon = (state: State, tabId: number) => { tabId, }); }; + +// 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..07c8b7b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,22 @@ 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'; + +// 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..bccd026 100644 --- a/src/content/contentScript.ts +++ b/src/content/contentScript.ts @@ -1,6 +1,7 @@ import { API_KEY_LOCAL_STORAGE, API_URL_LOCAL_STORAGE, + AUTH_TOKEN_LOCAL_STORAGE, BRANCH_LOCAL_STORAGE, } from '../constants'; import { LibConfig } from '../types'; @@ -18,9 +19,21 @@ 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), }; }; +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; @@ -82,6 +95,18 @@ messages.listenRuntime('SET_CREDENTIALS', async (data) => { } else { sessionStorage.removeItem(BRANCH_LOCAL_STORAGE); } + if (data.authToken) { + sessionStorage.setItem(AUTH_TOKEN_LOCAL_STORAGE, data.authToken); + } else { + sessionStorage.removeItem(AUTH_TOKEN_LOCAL_STORAGE); + } 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..f391dc1 --- /dev/null +++ b/src/oauth/oauthClient.ts @@ -0,0 +1,95 @@ +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) { + throw new Error(`Tolgee token endpoint returned ${res.status}`); + } + 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..ffa0b3f 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -13,11 +13,13 @@ import { import { useDetectorForm } from './useDetectorForm'; import { validateValues } from './tools'; +import { sendToBackground } from './sendToBackground'; const POPUP_WIDTH = 400; export const TolgeeDetector = () => { const [state, dispatch] = useDetectorForm(); + const [connecting, setConnecting] = useState(false); const { error, @@ -46,6 +48,28 @@ export const TolgeeDetector = () => { } }; + const handleConnect = async () => { + const apiUrl = values?.apiUrl; + if (!apiUrl) { + return; + } + setConnecting(true); + try { + const res = (await sendToBackground('OAUTH_LOGIN', { apiUrl })) as { + accessToken?: string; + error?: string; + }; + if (res?.accessToken) { + dispatch({ + type: 'OAUTH_APPLY', + payload: { apiUrl, authToken: res.accessToken }, + }); + } + } finally { + setConnecting(false); + } + }; + const dataPresent = storedValues || appliedValues; if (error) { return ( @@ -123,6 +147,10 @@ export const TolgeeDetector = () => { '...' ) : credentialsCheck === 'invalid' ? ( 'Invalid' + ) : 'oauth' in credentialsCheck ? ( + + Connected as {credentialsCheck.userFullName} + ) : ( {credentialsCheck.projectName} @@ -130,8 +158,19 @@ export const TolgeeDetector = () => { )} - {typeof credentialsCheck === 'object' && - credentialsCheck?.branchingEnabled && ( + + {credentialsCheck !== null && + typeof credentialsCheck === 'object' && + 'branchingEnabled' in credentialsCheck && + credentialsCheck.branchingEnabled && ( { + return browser.runtime.sendMessage({ type, data }); +}; diff --git a/src/popup/storage.ts b/src/popup/storage.ts index 19b1abb..e33a7db 100644 --- a/src/popup/storage.ts +++ b/src/popup/storage.ts @@ -4,6 +4,9 @@ type Values = { apiUrl?: string; apiKey?: string; branch?: string; + // OAuth sessions persist only a marker + backend url 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; }; const getCurrentTab = async () => { @@ -17,11 +20,17 @@ 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 }, + }); + } else if (values?.apiKey && values?.apiUrl) { browser.storage.local.set({ [origin]: { apiUrl: values.apiUrl, @@ -48,6 +57,7 @@ export const loadValues = async () => { apiKey: data?.apiKey, apiUrl: data?.apiUrl, branch: data?.branch, + oauth: data?.oauth, }; } catch (e) { console.error(e); diff --git a/src/popup/tools.ts b/src/popup/tools.ts index 5f331e0..a8ed249 100644 --- a/src/popup/tools.ts +++ b/src/popup/tools.ts @@ -2,15 +2,20 @@ export type Values = { apiUrl?: string; apiKey?: string; branch?: string; + // OAuth access token (from "Connect with Tolgee"); an alternative to apiKey + authToken?: string; }; 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 +23,7 @@ export const compareValues = ( return ( values1?.apiKey === values2?.apiKey && values1?.apiUrl === values2?.apiUrl && + values1?.authToken === values2?.authToken && (values1?.branch || '') === (values2?.branch || '') ); }; diff --git a/src/popup/useDetectorForm.tsx b/src/popup/useDetectorForm.tsx index 735f5a6..3bbdd22 100644 --- a/src/popup/useDetectorForm.tsx +++ b/src/popup/useDetectorForm.tsx @@ -4,8 +4,15 @@ 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'; @@ -17,7 +24,12 @@ type ProjectInfo = { branchingEnabled: boolean; }; -type CredentialsCheck = null | 'loading' | 'invalid' | ProjectInfo; +type OAuthUser = { + oauth: true; + userFullName: string; +}; + +type CredentialsCheck = null | 'loading' | 'invalid' | ProjectInfo | OAuthUser; type TolgeePresent = 'loading' | 'present' | 'not_present' | 'legacy'; type BranchOption = { @@ -52,6 +64,7 @@ type Action = | { type: 'CLEAR_ALL' } | { type: 'STORE_VALUES' } | { type: 'LOAD_VALUES' } + | { type: 'OAUTH_APPLY'; payload: { apiUrl: string; authToken: string } } | { type: 'SET_BRANCHES'; payload: BranchOption[] | null }; export const useDetectorForm = () => { @@ -112,8 +125,10 @@ export const useDetectorForm = () => { // sync values with storage/localStorage apply(); const branchEnabled = + state.credentialsCheck !== null && typeof state.credentialsCheck === 'object' && - state.credentialsCheck?.branchingEnabled; + 'branchingEnabled' in state.credentialsCheck && + state.credentialsCheck.branchingEnabled; const effectiveBranch = branchEnabled ? state.values?.branch : undefined; @@ -141,6 +156,19 @@ export const useDetectorForm = () => { libConfig: null, }; } + case 'OAUTH_APPLY': { + apply(); + const oauthValues = { + apiUrl: action.payload.apiUrl, + authToken: action.payload.authToken, + }; + return { + ...state, + values: oauthValues, + appliedValues: oauthValues, + storedValues: oauthValues, + }; + } case 'STORE_VALUES': apply(); return { @@ -218,7 +246,18 @@ 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 }, + }); + } + } else if (validateValues(storedData)) { dispatch({ type: 'LOAD_STORED_VALUES', payload: storedData }); } }; @@ -268,43 +307,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(); - } - }) - .catch(() => { - !cancelled && setCredentialsCheck('invalid'); + 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}` }, }) - .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); From 8af4a7af803984544e50e6c4143174eaa1d14463 Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Fri, 7 Aug 2026 00:27:14 +0200 Subject: [PATCH 2/8] feat: restructure popup around "Connect with Tolgee" Make OAuth the primary sign-in: the "Connect with Tolgee" button sits directly under the API url, and the API-key input + Apply are tucked into a collapsible "API key sign in" block (collapsed by default). Also surface OAuth login failures via console.error instead of swallowing them. --- src/background/background.ts | 5 +- src/oauth/oauthClient.ts | 3 +- src/popup/TolgeeDetector.tsx | 309 +++++++++++++++++++---------------- 3 files changed, 171 insertions(+), 146 deletions(-) diff --git a/src/background/background.ts b/src/background/background.ts index 599b717..5534bf6 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -32,7 +32,10 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => { await saveSession(data.apiUrl, tokens); sendResponse({ accessToken: tokens.accessToken }); }) - .catch((e) => sendResponse({ error: String(e) })); + .catch((e) => { + console.error('[tolgee-oauth] login failed', e); + sendResponse({ error: String(e) }); + }); return true; case 'OAUTH_GET_TOKEN': getValidAccessToken(data.apiUrl).then((accessToken) => diff --git a/src/oauth/oauthClient.ts b/src/oauth/oauthClient.ts index f391dc1..763b8d7 100644 --- a/src/oauth/oauthClient.ts +++ b/src/oauth/oauthClient.ts @@ -34,7 +34,8 @@ const postToken = async ( body: new URLSearchParams(params), }); if (!res.ok) { - throw new Error(`Tolgee token endpoint returned ${res.status}`); + const body = await res.text().catch(() => ''); + throw new Error(`Tolgee token endpoint returned ${res.status}: ${body}`); } return parseTokenResponse(await res.json(), previousRefreshToken); }; diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index ffa0b3f..67d5c7a 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -4,6 +4,7 @@ import { Box, Button, CircularProgress, + Collapse, FormControl, FormHelperText, Switch, @@ -20,6 +21,7 @@ const POPUP_WIDTH = 400; export const TolgeeDetector = () => { const [state, dispatch] = useDetectorForm(); const [connecting, setConnecting] = useState(false); + const [keySigninOpen, setKeySigninOpen] = useState(false); const { error, @@ -122,167 +124,186 @@ export const TolgeeDetector = () => { onKeyDown={handleKeyDown} size="small" /> - - - dispatch({ - type: 'CHANGE_VALUES', - payload: { apiKey: e.target.value }, - }) - } - onKeyDown={handleKeyDown} - size="small" - /> - - {credentialsCheck === null ? ( - '' - ) : credentialsCheck === 'loading' ? ( - '...' - ) : credentialsCheck === 'invalid' ? ( - 'Invalid' - ) : 'oauth' in credentialsCheck ? ( - - Connected as {credentialsCheck.userFullName} - - ) : ( - - {credentialsCheck.projectName} - - )} - - - {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 }, - }); + payload: { apiKey: e.target.value }, + }) } - }} - renderOption={(props, option) => ( -
  • - {option.name} - {option.isDefault && ( - - default - + onKeyDown={handleKeyDown} + size="small" + /> + + {credentialsCheck === null ? ( + '' + ) : credentialsCheck === 'loading' ? ( + '...' + ) : credentialsCheck === 'invalid' ? ( + 'Invalid' + ) : 'oauth' in credentialsCheck ? ( + + Connected as {credentialsCheck.userFullName} + + ) : ( + + {credentialsCheck.projectName} + + )} + + + {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) => ( + )} - - )} - renderInput={(params) => ( - )} - /> - )} - - - {dataPresent ? ( - <> - + + {dataPresent ? ( + <> + + Applied + + ) : isInDevelopmentMode ? ( + + Api key is included directly in Tolgee configuration.
    {' '} + Use this setup only in development environment. +
    + ) : ( + '' + )} +
    + + {dataPresent && ( + + )} + - )} - + disabled={!validateValues(values) || valuesNotChanged} + > + Apply + + +
    -
    +
    ); } else if (tolgeePresent === 'legacy') { From 8a9f3652c2607320ae2eb3899a8bd332e6631861 Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Fri, 7 Aug 2026 13:06:21 +0200 Subject: [PATCH 3/8] feat: label the API-key apply button 'Connect with API key' --- src/popup/TolgeeDetector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index 67d5c7a..1dbc62f 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -298,7 +298,7 @@ export const TolgeeDetector = () => { color="primary" disabled={!validateValues(values) || valuesNotChanged} > - Apply + Connect with API key From 5314c12f4eb5182182094b22bdafcddf5a93906b Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Sun, 9 Aug 2026 12:14:38 +0200 Subject: [PATCH 4/8] feat: select and inject the OAuth project for in-context editing OAuth access tokens carry no embedded project (unlike a PAK), so the popup now resolves one: it hints the page's configured project on connect, reads the consented project back from the token's tg.prj and injects it into the page as __tolgee_projectId, and shows a manual project picker only when the token is bound to all projects. --- src/constants.ts | 3 + src/content/contentScript.ts | 7 +++ src/popup/TolgeeDetector.tsx | 58 ++++++++++++++++++- src/popup/storage.ts | 13 ++++- src/popup/tools.ts | 28 +++++++++ src/popup/useDetectorForm.tsx | 106 +++++++++++++++++++++++++++++++++- 6 files changed, 208 insertions(+), 7 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 07c8b7b..d15add1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,6 +3,9 @@ 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'; diff --git a/src/content/contentScript.ts b/src/content/contentScript.ts index bccd026..50b8672 100644 --- a/src/content/contentScript.ts +++ b/src/content/contentScript.ts @@ -3,6 +3,7 @@ import { 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'; @@ -20,6 +21,7 @@ const getAppliedCredenials = () => { 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), }; }; @@ -100,6 +102,11 @@ messages.listenRuntime('SET_CREDENTIALS', async (data) => { } else { sessionStorage.removeItem(AUTH_TOKEN_LOCAL_STORAGE); } + if (data.projectId) { + sessionStorage.setItem(PROJECT_ID_LOCAL_STORAGE, String(data.projectId)); + } else { + sessionStorage.removeItem(PROJECT_ID_LOCAL_STORAGE); + } location.reload(); updateState(configuration, messages); }); diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index 1dbc62f..7af1e30 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -13,7 +13,7 @@ import { } from '@mui/material'; import { useDetectorForm } from './useDetectorForm'; -import { validateValues } from './tools'; +import { decodeTokenProjectSet, validateValues } from './tools'; import { sendToBackground } from './sendToBackground'; const POPUP_WIDTH = 400; @@ -32,9 +32,21 @@ export const TolgeeDetector = () => { tolgeePresent, credentialsCheck, branches, + projects, } = 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) === '*'; + const handleApplyChange = async () => { if (appliedValues) { dispatch({ type: 'STORE_VALUES' }); @@ -57,7 +69,16 @@ export const TolgeeDetector = () => { } setConnecting(true); try { - const res = (await sendToBackground('OAUTH_LOGIN', { apiUrl })) as { + // 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; + const res = (await sendToBackground('OAUTH_LOGIN', { + apiUrl, + projectId, + })) as { accessToken?: string; error?: string; }; @@ -133,6 +154,39 @@ export const TolgeeDetector = () => { > {connecting ? 'Connecting…' : 'Connect with Tolgee'} + {oauthUser && ( + <> + + Connected as {oauthUser.userFullName} + + {allProjectsToken && ( + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={ + projects?.find((p) => p.id === values?.projectId) ?? null + } + onChange={(_e, newValue) => { + dispatch({ + type: 'OAUTH_SET_PROJECT', + payload: { projectId: newValue?.id }, + }); + }} + renderInput={(params) => ( + + )} + /> + )} + + )} + )} + + + ); + if (error) { return ( @@ -127,86 +266,164 @@ 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 + } + return ( - - Tolgee settings - - - dispatch({ - type: 'CHANGE_VALUES', - payload: { apiUrl: e.target.value }, - }) - } - onKeyDown={handleKeyDown} - size="small" - /> - - {oauthUser && ( - <> - - Connected as {oauthUser.userFullName} - - {allProjectsToken && ( - option.name} - isOptionEqualToValue={(option, value) => option.id === value.id} - value={ - projects?.find((p) => p.id === values?.projectId) ?? null - } - onChange={(_e, newValue) => { - dispatch({ - type: 'OAUTH_SET_PROJECT', - payload: { projectId: newValue?.id }, - }); - }} - renderInput={(params) => ( - - )} - /> - )} - - )} - - + + + + + {tab === 'login' && + (oauthUser ? ( + + + Connected as {oauthUser.userFullName} + + {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. + + ) : 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) => ( + + )} + /> + ) : ( + + + + )} + {appliedControls(isInDevelopmentMode)} + + ) : 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} + { /> {credentialsCheck === null ? ( @@ -242,130 +459,30 @@ export const TolgeeDetector = () => { )} - {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) => ( - - )} - /> - )} - + Where can I get an{' '} + + API key + + ? + + {branchField} + - )} - - -
    + Connect with API key + + {appliedControls(isInDevelopmentMode)}
    - + )}
    ); } 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/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/useDetectorForm.tsx b/src/popup/useDetectorForm.tsx index 077efd3..d1aee5a 100644 --- a/src/popup/useDetectorForm.tsx +++ b/src/popup/useDetectorForm.tsx @@ -1,14 +1,12 @@ /* 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, - decodeTokenProjectSet, isOAuth, normalizeUrl, validateValues, @@ -16,213 +14,12 @@ import { } from './tools'; import { useApplier } from './useApplier'; import { RuntimeMessage } from '../content/Messages'; - -type ProjectInfo = { - projectName: string; - projectId: number; - scopes: string[]; - userFullName: string; - branchingEnabled: boolean; -}; - -type OAuthUser = { - oauth: true; - userFullName: string; -}; - -type CredentialsCheck = null | 'loading' | 'invalid' | ProjectInfo | OAuthUser; -type TolgeePresent = 'loading' | 'present' | 'not_present' | 'legacy'; - -type BranchOption = { - name: string; - isDefault: boolean; -}; - -type ProjectOption = { - id: number; - name: string; -}; - -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, - projects: null as ProjectOption[] | 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: 'OAUTH_APPLY'; payload: { apiUrl: string; authToken: string } } - | { type: 'OAUTH_SET_PROJECT'; payload: { projectId: number | undefined } } - | { type: 'SET_BRANCHES'; payload: BranchOption[] | null } - | { type: 'SET_PROJECTS'; payload: ProjectOption[] | 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 = - 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, - }; - } - 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, - }; - } - case 'OAUTH_SET_PROJECT': { - apply(); - const oauthValues = { - ...state.values, - projectId: action.payload.projectId, - }; - return { - ...state, - values: oauthValues, - appliedValues: oauthValues, - storedValues: oauthValues, - }; - } - case 'SET_PROJECTS': - return { - ...state, - projects: action.payload, - }; - 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; @@ -442,74 +239,61 @@ export const useDetectorForm = () => { }; }, [state.credentialsCheck]); - // OAuth tokens carry no project, so once the token is confirmed, load the user's accessible projects to pick from. + // 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; - if ( + const isOauthCheck = check !== null && typeof check === 'object' && 'oauth' in check && - isOAuth(checkableValues) - ) { - const url = normalizeUrl(checkableValues!.apiUrl); - fetch(`${url}/v2/projects?size=1000`, { - headers: { Authorization: `Bearer ${checkableValues!.authToken}` }, - }) - .then((r) => { - if (!r.ok) { - throw new Error('Failed to load projects'); - } - return r.json(); - }) - .then((data) => { - if (!cancelled) { - dispatch({ - type: 'SET_PROJECTS', - payload: - data?._embedded?.projects?.map((p: any) => ({ - id: p.id, - name: p.name, - })) ?? [], - }); - } - }) - .catch(() => { - if (!cancelled) { - dispatch({ type: 'SET_PROJECTS', payload: null }); - } - }); - } else { - dispatch({ type: 'SET_PROJECTS', payload: null }); + 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]); - // A token bound to a single project (chosen on the consent screen) auto-selects it — no popup pick needed. Only the - // "all projects" case falls back to the dropdown above. - useEffect(() => { - const check = state.credentialsCheck; - if ( - check !== null && - typeof check === 'object' && - 'oauth' in check && - isOAuth(checkableValues) - ) { - const projectSet = decodeTokenProjectSet(checkableValues!.authToken); - if ( - Array.isArray(projectSet) && - projectSet.length === 1 && - state.values?.projectId !== projectSet[0] - ) { - dispatch({ - type: 'OAUTH_SET_PROJECT', - payload: { projectId: projectSet[0] }, - }); - } - } - }, [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', + }, +}); From b3d9f424213b7ddb78a63e5b55014a35608cfe11 Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Thu, 13 Aug 2026 14:23:47 +0200 Subject: [PATCH 8/8] feat: unify the connected popup into a single Disconnect view --- src/popup/TolgeeDetector.tsx | 250 ++++++++++++++++++++--------------- 1 file changed, 142 insertions(+), 108 deletions(-) diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index 564e5a3..2e543a2 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -17,7 +17,7 @@ import { } from '@mui/material'; import { useDetectorForm } from './useDetectorForm'; -import { decodeTokenProjectSet, validateValues } from './tools'; +import { decodeTokenProjectSet, isOAuth, validateValues } from './tools'; import { sendToBackground } from './sendToBackground'; const POPUP_WIDTH = 400; @@ -119,6 +119,21 @@ export const TolgeeDetector = () => { 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 = ( { /> ); - const appliedControls = (isInDevelopmentMode: boolean) => ( - - - {dataPresent ? ( - <> - - Applied - - ) : isInDevelopmentMode ? ( - - Api key is included directly in Tolgee configuration.
    Use - this setup only in development environment. -
    - ) : ( - '' - )} -
    - - {dataPresent && ( - - )} + 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 + ); @@ -279,6 +313,66 @@ export const TolgeeDetector = () => { // 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 ( { {tab === 'login' && - (oauthUser ? ( - - - Connected as {oauthUser.userFullName} - - {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. - - ) : 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) => ( - - )} - /> - ) : ( - - - - )} - {appliedControls(isInDevelopmentMode)} - - ) : projectDetected ? ( + (projectDetected ? ( {serverOpen ? ( serverField @@ -442,21 +480,13 @@ export const TolgeeDetector = () => { style={{ minHeight: 15 }} sx={{ marginLeft: 0 }} > - {credentialsCheck === null ? ( - '' - ) : credentialsCheck === 'loading' ? ( - '...' - ) : credentialsCheck === 'invalid' ? ( - 'Invalid' - ) : 'oauth' in credentialsCheck ? ( - - Connected as {credentialsCheck.userFullName} - - ) : ( - - {credentialsCheck.projectName} - - )} + {credentialsCheck === null + ? '' + : credentialsCheck === 'loading' + ? '...' + : credentialsCheck === 'invalid' + ? 'Invalid' + : ''}
    @@ -471,7 +501,6 @@ export const TolgeeDetector = () => { ? - {branchField} - {appliedControls(isInDevelopmentMode)} + {isInDevelopmentMode && ( + + Api key is included directly in Tolgee configuration.
    Use + this setup only in development environment. +
    + )} )}