diff --git a/plugins/phrase-conector/README.md b/plugins/phrase-conector/README.md index 784e95d33af..17638a292f6 100644 --- a/plugins/phrase-conector/README.md +++ b/plugins/phrase-conector/README.md @@ -5,6 +5,79 @@ From the plugins tab, pick `Phrase`, it'll ask you for your phrase user name and password, make sure the user has a `Project Manager` role. ![Phrase connector configuration screen](https://cdn.builder.io/api/v1/image/assets%2F1f098a44b17d4df688d2afdc8a10ac7d%2F92e44f756ac848bca5bdc6d9459d2bfc) +## Authentication + +The plugin supports two authentication modes, selected via the **Authentication** dropdown in the plugin settings: + +- **Username / password** (default) — the classic flow. Enter the Phrase username and password of a user with a `Project Manager` role. +- **SSO / OAuth 2.0** — recommended for organizations that enforce SSO (where username/password login is blocked). Each organization registers its own Phrase OAuth app and connects via a login popup; no password is stored. + +Both modes are fully supported. You can switch between them at any time, and switching does not delete the other mode's stored credentials. + +### Switching to OAuth (SSO) + +#### Prerequisites + +- A Phrase TMS user with an **Administrator** profile (required both to register the OAuth app and to approve the connection). +- Your Phrase **data center**: US (`us.cloud.memsource.com`) or EU/default (`cloud.memsource.com`). + +#### Part A — Register an OAuth app in Phrase + +1. Sign in to Phrase TMS as an **Administrator**. +2. Go to **Settings → Integrations → Registered OAuth Apps**. +3. Create a new OAuth app. +4. Set the **Redirect URI** exactly to: + ``` + https://cdn.builder.io/api/v1/memsource/oauth/callback + ``` + If Builder gave you a custom API host, use that host with the same `/api/v1/memsource/oauth/callback` path. +5. Save the app and copy the **Client ID**. Phrase does not issue a client secret — that's expected; only the Client ID is needed. + +#### Part B — Connect the Builder plugin + +1. In Builder, open **Space Settings → Integrations → Phrase**. +2. Set **Authentication** to **SSO / OAuth 2.0**. +3. If your Phrase account is on the **US** data center, enable **"Account's data center is US based."** +4. Paste the **Client ID** from Part A into **OAuth Client ID**. +5. Click **Connect to Phrase**. +6. In the popup, log in with an **Administrator** profile and click **Allow**. +7. Confirm the panel shows **✓ Connected to Phrase**, then **Save** the settings. + +#### How the connection works + +```mermaid +sequenceDiagram + participant U as User (Builder) + participant P as Plugin UI + participant B as Builder API + participant Ph as Phrase + + U->>P: Set OAuth mode + Client ID, click Connect + P->>B: POST /oauth/prepare (Client ID, data center) + B-->>P: one-time state token + P->>B: open popup /oauth/start?state=... + B->>Ph: redirect to /oauth/authorize + U->>Ph: Log in (Admin) + Allow + Ph->>B: redirect /oauth/callback?code&state + B->>Ph: exchange code for tokens + B->>B: store tokens (server-side) + clear state + B-->>P: postMessage: connected (metadata only) + P-->>U: ✓ Connected to Phrase +``` + +The access token is stored and used only on the Builder server; the browser only ever receives connection metadata. + +#### Verify + +Run a **Translate** action on any entry — it should create the Phrase project/job as usual. + +#### Good to know + +- Each organization registers its **own** OAuth app; the Client ID is per-org and entered in plugin settings (there is no shared, Builder-wide client). +- Phrase access tokens are short-lived (~1h) and Phrase often issues no refresh token. If a translation action reports **"Phrase OAuth session expired. Please reconnect,"** click **Connect to Phrase** again. This is uncommon day-to-day but is the expected reconnect path. +- To revert, switch **Authentication** back to **Username / password** — the previously entered credentials are still stored. +- Use **Disconnect from Phrase** in the connection panel to revoke the stored OAuth token. + ## Translating content What's being translated: - All text elements in builder content [you can exclude specific element by right click + `exclude from future translations`] diff --git a/plugins/phrase-conector/package-lock.json b/plugins/phrase-conector/package-lock.json index 123168976a5..2bb3996b93e 100644 --- a/plugins/phrase-conector/package-lock.json +++ b/plugins/phrase-conector/package-lock.json @@ -1,12 +1,12 @@ { "name": "@builder.io/plugin-phrase-connector", - "version": "0.0.15", + "version": "0.0.17-1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@builder.io/plugin-phrase-connector", - "version": "0.0.15", + "version": "0.0.17-1", "license": "MIT", "dependencies": { "@builder.io/commerce-plugin-tools": "^0.3.0", @@ -18328,21 +18328,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-dom": { - "version": "16.14.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", - "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "prop-types": "^15.6.2", - "scheduler": "^0.19.1" - }, - "peerDependencies": { - "react": "^16.14.0" - } - }, "node_modules/react-event-listener": { "version": "0.6.6", "resolved": "https://registry.npmjs.org/react-event-listener/-/react-event-listener-0.6.6.tgz", @@ -19277,16 +19262,6 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "node_modules/scheduler": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", - "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, "node_modules/semantic-release": { "version": "21.0.1", "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-21.0.1.tgz", @@ -23395,8 +23370,7 @@ "mobx-react-lite": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-2.2.2.tgz", - "integrity": "sha512-2SlXALHIkyUPDsV4VTKVR9DW7K3Ksh1aaIv3NrNJygTbhXe2A9GrcKHZ2ovIiOp/BXilOcTYemfHHZubP431dg==", - "requires": {} + "integrity": "sha512-2SlXALHIkyUPDsV4VTKVR9DW7K3Ksh1aaIv3NrNJygTbhXe2A9GrcKHZ2ovIiOp/BXilOcTYemfHHZubP431dg==" } } }, @@ -24812,8 +24786,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz", "integrity": "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==", - "dev": true, - "requires": {} + "dev": true }, "@octokit/plugin-rest-endpoint-methods": { "version": "7.0.1", @@ -31224,8 +31197,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "requires": {} + "dev": true }, "jest-regex-util": { "version": "29.4.3", @@ -32000,14 +31972,12 @@ "jss-default-unit": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/jss-default-unit/-/jss-default-unit-8.0.2.tgz", - "integrity": "sha512-WxNHrF/18CdoAGw2H0FqOEvJdREXVXLazn7PQYU7V6/BWkCV0GkmWsppNiExdw8dP4TU1ma1dT9zBNJ95feLmg==", - "requires": {} + "integrity": "sha512-WxNHrF/18CdoAGw2H0FqOEvJdREXVXLazn7PQYU7V6/BWkCV0GkmWsppNiExdw8dP4TU1ma1dT9zBNJ95feLmg==" }, "jss-global": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/jss-global/-/jss-global-3.0.0.tgz", - "integrity": "sha512-wxYn7vL+TImyQYGAfdplg7yaxnPQ9RaXY/cIA8hawaVnmmWxDHzBK32u1y+RAvWboa3lW83ya3nVZ/C+jyjZ5Q==", - "requires": {} + "integrity": "sha512-wxYn7vL+TImyQYGAfdplg7yaxnPQ9RaXY/cIA8hawaVnmmWxDHzBK32u1y+RAvWboa3lW83ya3nVZ/C+jyjZ5Q==" }, "jss-nested": { "version": "6.0.1", @@ -32030,8 +32000,7 @@ "jss-props-sort": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/jss-props-sort/-/jss-props-sort-6.0.0.tgz", - "integrity": "sha512-E89UDcrphmI0LzmvYk25Hp4aE5ZBsXqMWlkFXS0EtPkunJkRr+WXdCNYbXbksIPnKlBenGB9OxzQY+mVc70S+g==", - "requires": {} + "integrity": "sha512-E89UDcrphmI0LzmvYk25Hp4aE5ZBsXqMWlkFXS0EtPkunJkRr+WXdCNYbXbksIPnKlBenGB9OxzQY+mVc70S+g==" }, "jss-vendor-prefixer": { "version": "7.0.0", @@ -33067,15 +33036,13 @@ "version": "3.4.0", "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-3.4.0.tgz", "integrity": "sha512-bRuZp3C0itgLKHu/VNxi66DN/XVkQG7xtoBVWxpvC5FhAqbOCP21+nPhULjnzEqd7xBMybp6KwytdUpZKEgpIQ==", - "dev": true, - "requires": {} + "dev": true }, "mobx-state-tree": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/mobx-state-tree/-/mobx-state-tree-5.1.5.tgz", "integrity": "sha512-jugIic0PYWW+nzzYfp4RUy9dec002Z778OC6KzoOyBHnqxupK9iPCsUJYkHjmNRHjZ8E4Z7qQpsKV3At/ntGVw==", - "dev": true, - "requires": {} + "dev": true }, "modify-values": { "version": "1.0.1", @@ -35969,18 +35936,6 @@ "prop-types": "^15.6.2" } }, - "react-dom": { - "version": "16.14.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", - "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==", - "peer": true, - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "prop-types": "^15.6.2", - "scheduler": "^0.19.1" - } - }, "react-event-listener": { "version": "0.6.6", "resolved": "https://registry.npmjs.org/react-event-listener/-/react-event-listener-0.6.6.tgz", @@ -36726,16 +36681,6 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "scheduler": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", - "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", - "peer": true, - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, "semantic-release": { "version": "21.0.1", "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-21.0.1.tgz", diff --git a/plugins/phrase-conector/package.json b/plugins/phrase-conector/package.json index 4570665e0e4..d9cbc2cf35a 100644 --- a/plugins/phrase-conector/package.json +++ b/plugins/phrase-conector/package.json @@ -1,6 +1,6 @@ { "name": "@builder.io/plugin-phrase-connector", - "version": "0.0.15", + "version": "0.0.17-1", "description": "", "keywords": [], "main": "dist/plugin.system.js", diff --git a/plugins/phrase-conector/src/oauth-client.ts b/plugins/phrase-conector/src/oauth-client.ts new file mode 100644 index 00000000000..52c99270905 --- /dev/null +++ b/plugins/phrase-conector/src/oauth-client.ts @@ -0,0 +1,192 @@ +/** + * @builder.io/plugin-phrase-connector — browser OAuth helper + * + * Opens a popup that points at the Builder API monolith + * (`/api/v1/memsource/oauth/start`). The server redirects to Phrase + * (`https://[us.]cloud.memsource.com/web/oauth/authorize`), and on + * callback persists tokens against the org's plugin settings. + * + * The popup `postMessage`s a result object back to the opener so the + * settings page can reactively re-render. + */ +import appState from '@builder.io/app-context'; +import pkg from '../package.json'; + +const PLUGIN_ID = pkg.name; + +export type PhraseOAuthTokens = { + accessToken: string; + refreshToken?: string; + expiresAt: number; + scope?: string; + tokenType: 'Bearer'; + connectedAt: number; + userUid?: string; +}; + +export function isOAuthValid(oauth?: { expiresAt?: number } | null): boolean { + // The access token stays server-side; the client only ever sees connection + // metadata (expiresAt/connectedAt). So a connection is "valid" when it has a + // not-yet-expired expiry, not when a token is present (the token never is). + if (!oauth?.expiresAt) return false; + if (oauth.expiresAt <= Date.now()) return false; + return true; +} + +// Bridges the window between a successful connect and the next full org +// reload: the server persists the real token to Firestore, but the client's +// in-memory org settings won't carry it until reload. This client-only +// marker lets ensureAuthenticated() proceed meanwhile. It is never persisted, +// so it cannot clobber the server-held access token. +// Client-only connect/disconnect markers, keyed by apiKey so one org's state +// never bleeds into another and survives switching away and back. Never +// persisted, so they can't clobber the server-held access token. +export const sessionOAuth: { + value: Record; +} = { value: {} }; +export const sessionDisconnected: { value: Record } = { value: {} }; + +// Bridges the window between a connect and the next full org reload: the server +// persists the real token to Firestore, but the client's in-memory org settings +// won't carry it until reload. Scoped to the current org. +export function getSessionOAuth(): { expiresAt: number; connectedAt?: number } | null { + return sessionOAuth.value[appState.user.apiKey] || null; +} + +// True when the user disconnected in the current org this session, so callers +// stop trusting stale in-memory org OAuth metadata until the model reloads. +export function isSessionDisconnected(): boolean { + return !!sessionDisconnected.value[appState.user.apiKey]; +} + +// Reads a field from the current org's plugin settings, tolerating MST/MobX: +// `settings.plugins` and the per-plugin node can each be an observable map +// (needs `.get(key)`) or a plain object (needs `node[key]`). Direct property +// access on an observable map silently returns undefined. +export function readOrgPluginSetting(key: string): any { + const plugins: any = appState.user.organization?.value?.settings?.plugins; + const node: any = + typeof plugins?.get === 'function' ? plugins.get(PLUGIN_ID) : plugins?.[PLUGIN_ID]; + if (!node) return undefined; + return typeof node.get === 'function' ? node.get(key) : node[key]; +} + +function getApiHost(override?: string): string { + if (override) return override; + return readOrgPluginSetting('apiHost') || 'https://cdn.builder.io'; +} + + +export async function disconnectOAuth(opts: { apiHost?: string } = {}): Promise { + const apiHost = getApiHost(opts.apiHost); + const privateKey = await appState.globalState.getPluginPrivateKey(PLUGIN_ID); + const params = new URLSearchParams({ apiKey: appState.user.apiKey, pluginId: PLUGIN_ID }); + const res = await fetch(apiHost + "/api/v1/memsource/oauth/disconnect?" + params, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + privateKey, + }, + }); + if (!res.ok) { + throw new Error("Could not disconnect from Phrase. Please try again."); + } + const apiKey = appState.user.apiKey; + delete sessionOAuth.value[apiKey]; + sessionDisconnected.value[apiKey] = true; +} + +export async function connectWithOAuth(opts: { + isUSDataCenterAccount: boolean; + apiHost?: string; + clientId?: string; +}): Promise<{ connectedAt?: number; expiresAt?: number }> { + const apiHost = getApiHost(opts.apiHost); + const privateKey = await appState.globalState.getPluginPrivateKey(PLUGIN_ID); + + // Mint a one-time, server-stored state (authenticated) instead of passing + // apiKey/pluginId through the public query string. + const prepareParams = new URLSearchParams({ + apiKey: appState.user.apiKey, + pluginId: PLUGIN_ID, + }); + const prepareRes = await fetch( + apiHost + "/api/v1/memsource/oauth/prepare?" + prepareParams, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + privateKey, + }, + body: JSON.stringify({ + isUS: !!opts.isUSDataCenterAccount, + origin: window.location.origin, + clientId: opts.clientId, + }), + } + ); + if (!prepareRes.ok) { + throw new Error("Could not start Phrase OAuth. Please try again."); + } + const { stateId } = await prepareRes.json(); + if (!stateId) { + throw new Error("Could not start Phrase OAuth. Please try again."); + } + + const url = + apiHost + "/api/v1/memsource/oauth/start?state=" + encodeURIComponent(stateId); + const popup = window.open( + url, + "phrase-oauth", + "width=560,height=720,menubar=no,toolbar=no,location=no" + ); + if (!popup) throw new Error("Popup blocked. Allow popups for this site and try again."); + + let expectedOrigin = ""; + try { + expectedOrigin = new URL(apiHost).origin; + } catch {} + + return await new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + window.removeEventListener("message", onMessage); + clearInterval(closedTimer); + }; + const onMessage = (e: MessageEvent) => { + // Fail closed: if apiHost couldn't be parsed we can't verify the sender, + // so reject rather than skip the origin check. + if (!expectedOrigin || e.origin !== expectedOrigin) return; + const data = e.data ? e.data : {}; + if (data?.type !== "memsource-oauth-result") return; + if (e.source !== popup) return; + settled = true; + cleanup(); + try { + popup.close(); + } catch {} + if (data.error) { + reject(new Error(data.error)); + } else { + const connection = data.connection ? data.connection : {}; + if (connection.expiresAt) { + const apiKey = appState.user.apiKey; + sessionOAuth.value[apiKey] = { + expiresAt: connection.expiresAt, + connectedAt: connection.connectedAt, + }; + delete sessionDisconnected.value[apiKey]; + } + resolve(connection); + } + }; + window.addEventListener("message", onMessage); + const closedTimer = setInterval(() => { + if (settled) return; + if (popup.closed) { + cleanup(); + reject(new Error("OAuth window closed before completion")); + } + }, 500); + }); +} diff --git a/plugins/phrase-conector/src/phrase-api.ts b/plugins/phrase-conector/src/phrase-api.ts new file mode 100644 index 00000000000..21756a220a7 --- /dev/null +++ b/plugins/phrase-conector/src/phrase-api.ts @@ -0,0 +1,171 @@ +/** + * @builder.io/plugin-phrase-connector — browser API wrapper + * + * Talks to the Builder API monolith. The server holds the OAuth tokens + * (or legacy username/password) and forwards calls to Phrase TMS. + * + * Endpoints used: + * POST /api/v1/memsource/job + * POST /api/v1/memsource/apply-translation + * POST /api/v1/memsource/oauth/refresh (auto-invoked on 401) + */ +import { action } from 'mobx'; +import appState from '@builder.io/app-context'; +import pkg from '../package.json'; +import { getSessionOAuth, isOAuthValid, isSessionDisconnected, readOrgPluginSetting } from './oauth-client'; + +const PLUGIN_ID = pkg.name; + + +export type Project = { uid: string }; + +export class PhraseApi { + private privateKey?: string; + private loaded: Promise; + private resolveLoaded!: () => void; + // Bumped on every init() so a stale in-flight init (e.g. from before an + // org switch) cannot assign the previous org's key or resolve the current + // loaded promise. + private initEpoch = 0; + + constructor(private settings: any) { + this.loaded = new Promise(resolve => (this.resolveLoaded = resolve)); + this.init(); + appState.globalState.orgSwitched?.subscribe( + action(async () => { + // Session markers are keyed by apiKey (see oauth-client), so they don't + // leak across orgs and nothing needs resetting here on switch. + this.loaded = new Promise(resolve => (this.resolveLoaded = resolve)); + await this.init(); + }) + ); + } + + private get apiHost(): string { + return this.settings.get('apiHost') || 'https://cdn.builder.io'; + } + + private buildUrl(path: string, search: Record = {}) { + const params = new URLSearchParams({ + ...search, + pluginId: PLUGIN_ID, + apiKey: appState.user.apiKey, + }); + const url = new URL(`${this.apiHost}/api/v1/memsource/${path}`); + url.search = params.toString(); + return url.toString(); + } + + private async init() { + const epoch = ++this.initEpoch; + const key = await appState.globalState.getPluginPrivateKey(PLUGIN_ID); + // A newer init() started while we were awaiting; let it win. + if (epoch !== this.initEpoch) return; + this.privateKey = key; + this.resolveLoaded(); + } + + /** + * Verifies that the plugin has a usable credential before issuing API + * requests. For OAuth mode the server handles refresh transparently; + * we only check that *some* form of credentials exists. + */ + async ensureAuthenticated() { + await this.loaded; + const notConnected = + 'Phrase is not connected. Please click "Connect to Phrase" in plugin settings.'; + + // The real access token lives server-side; the client only ever sees + // connection metadata. The session marker (set by connectWithOAuth) is the + // reliable signal of a fresh connect this session, since the persisted + // authMode may not have propagated to the in-memory org model yet. + const sessionOauth = getSessionOAuth(); + // Decide by persisted authMode; only let the live session marker force + // OAuth when authMode is unset (a first-ever connect whose mode hasn't + // been saved yet). Never let it override an explicit 'password'. + const persistedMode = readOrgPluginSetting('authMode'); + const isOAuthMode = persistedMode === 'oauth' || (!persistedMode && !!sessionOauth); + + if (!isOAuthMode) { + if (!readOrgPluginSetting('userName') || !readOrgPluginSetting('password')) { + throw new Error('Phrase username/password is not configured.'); + } + return; + } + + // Disconnect leaves stale oauth metadata on the org model until it reloads; + // don't treat that as connected. Markers are apiKey-scoped. + if (isSessionDisconnected() && !sessionOauth) { + throw new Error(notConnected); + } + // Prefer whichever candidate has the later expiry so a fresh reconnect isn't + // blocked by stale org OAuth metadata left over from before it. + const oauth = [readOrgPluginSetting('oauth'), sessionOauth] + .filter(Boolean) + .reduce( + (best: any, c: any) => (best && best.expiresAt >= (c?.expiresAt ?? 0) ? best : c), + null + ); + if (!isOAuthValid(oauth)) { + throw new Error(oauth ? 'Phrase OAuth session expired. Please reconnect.' : notConnected); + } + } + + private async request(path: string, init?: RequestInit, search = {}) { + await this.loaded; + const doFetch = () => + fetch(this.buildUrl(path, search), { + ...init, + headers: { + Authorization: `Bearer ${this.privateKey}`, + 'Content-Type': 'application/json', + ...(init?.headers || {}), + }, + }); + + let res = await doFetch(); + if (res.status === 401) { + const persistedMode = readOrgPluginSetting('authMode'); + const isOAuthMode = persistedMode === "oauth" || (!persistedMode && !!getSessionOAuth()); + if (isOAuthMode) { + // Ask the server to refresh the token, then retry once. If the refresh + // itself failed (e.g. no refresh token -> reconnect needed), surface + // that reason instead of retrying and reporting a misleading API error. + const refreshRes = await fetch(this.buildUrl('oauth/refresh'), { + method: 'POST', + headers: { Authorization: `Bearer ${this.privateKey}` }, + }); + if (!refreshRes.ok) { + const info = await refreshRes.json().catch(() => ({} as any)); + throw new Error(info?.error || 'Phrase session expired. Please reconnect.'); + } + res = await doFetch(); + } + } + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Phrase API error ${res.status}: ${text}`); + } + return res.json(); + } + + createJob( + contentId: string, + model: string, + sourceLang: string, + targetLangs: string[], + callbackHost?: string + ): Promise<{ project: Project }> { + return this.request('job', { + method: 'POST', + body: JSON.stringify({ contentId, model, sourceLang, targetLangs, callbackHost }), + }); + } + + applyTranslation(contentId: string, model: string) { + return this.request('apply-translation', { + method: 'POST', + body: JSON.stringify({ contentId, model }), + }); + } +} diff --git a/plugins/phrase-conector/src/plugin.tsx b/plugins/phrase-conector/src/plugin.tsx index 6b933020848..7a94042418f 100644 --- a/plugins/phrase-conector/src/plugin.tsx +++ b/plugins/phrase-conector/src/plugin.tsx @@ -1,34 +1,84 @@ +/** + * @builder.io/plugin-phrase-connector — settings UI + * + * Drop-in replacement for `src/plugin.tsx` in the Phrase plugin repo. + * Adds OAuth 2.0 (authorization code) as the primary auth mode while + { label: 'SSO / OAuth 2.0', value: 'oauth' }, + * + * Modelled on the existing Memsource integration in + * `packages/api/src/memsource.ts` of the Builder API monolith. + */ +import * as React from 'react'; import { registerCommercePlugin as registerPlugin } from '@builder.io/commerce-plugin-tools'; -import pkg from '../package.json'; import appState from '@builder.io/app-context'; +import pkg from '../package.json'; import { registerContentAction, registerContextMenuAction, fastClone, registerEditorOnLoad, + CustomReactEditorProps, } from './plugin-helpers'; -import { Phrase, Project } from './phrase'; +import { PhraseApi } from './phrase-api'; +import { connectWithOAuth, disconnectOAuth, isOAuthValid, getSessionOAuth, isSessionDisconnected, readOrgPluginSetting } from './oauth-client'; import { showJobNotification, showOutdatedNotifications, getLangPicks } from './snackbar-utils'; import { getTranslateableFields } from '@builder.io/utils'; import hash from 'object-hash'; import stringify from 'fast-json-stable-stringify'; -// translation status that indicate the content is being queued for translations +import { Builder } from '@builder.io/react'; + +const PLUGIN_ID = pkg.name; // '@builder.io/plugin-phrase-connector' + +Builder.registerEditor({ + name: 'PhraseOAuthConnect', + component: (props: CustomReactEditorProps) => , +}); + const enabledTranslationStatuses = ['pending', 'local']; registerPlugin( { name: 'Phrase', - id: pkg.name, + id: PLUGIN_ID, settings: [ + { + name: 'authMode', + friendlyName: 'Authentication', + type: 'string', + enum: [ + { label: 'Username / password', value: 'password' }, + { label: 'SSO / OAuth 2.0', value: 'oauth' }, + ], + defaultValue: 'password', + }, + { + name: 'isUSDataCenterAccount', + friendlyName: "Account's data center is US based", + type: 'boolean', + }, + { + name: 'oauthClientId', + friendlyName: 'OAuth Client ID', + helperText: + 'Client ID of your Phrase Registered OAuth App (Phrase → Settings → Integrations). Enter this before connecting.', + type: 'string', + showIf: (options: any) => options.get('authMode') === 'oauth', + }, + { + name: 'oauthStatus', + friendlyName: 'Phrase connection', + type: 'PhraseOAuthConnect', + showIf: (options: any) => options.get('authMode') === 'oauth', + }, { name: 'userName', type: 'string', - required: true, + showIf: (options: any) => options.get('authMode') !== 'oauth', }, { name: 'password', type: 'password', - required: true, + showIf: (options: any) => options.get('authMode') !== 'oauth', }, { name: 'templateUId', @@ -37,178 +87,111 @@ registerPlugin( 'Template ID is the unique identifier of a Phrase Template used when creating a new Phrase Project', type: 'string', }, - { - name: 'isUSDataCenterAccount', - friendlyName: "Account's data center is US based", - type: 'boolean', - }, - // allow developer to override callback host , e.g ngrok for local development + // Builder-admin-only overrides for local development (e.g. an ngrok + // callback host, or pointing the API base at a tunnel). Hidden from + // regular users. ...(appState.user.isBuilderAdmin ? [ - { - name: 'callbackHost', - type: 'string', - }, - { - name: 'apiHost', - type: 'string', - }, + { name: 'callbackHost', type: 'string' }, + { name: 'apiHost', type: 'string' }, ] : []), ], - ctaText: `Connect your Phrase account`, + ctaText: 'Connect your Phrase account', noPreviewTypes: true, }, async settings => { - const api = new Phrase(settings.get('apiHost')); + const api = new PhraseApi(settings); + registerEditorOnLoad(({ safeReaction }) => { safeReaction( - () => { - return String(appState.designerState.editingContentModel?.lastUpdated || ''); - }, + () => String(appState.designerState.editingContentModel?.lastUpdated || ''), async shouldCheck => { - if (!shouldCheck) { - return; - } - const translationStatus = appState.designerState.editingContentModel.meta.get( - 'translationStatus' - ); - const translationRequested = appState.designerState.editingContentModel.meta.get( - 'translationRequested' - ); - - // check if there's pending translation - const isFresh = - appState.designerState.editingContentModel.lastUpdated > new Date(translationRequested); - if (!isFresh) { - return; - } - const content = fastClone(appState.designerState.editingContentModel); - const isPending = translationStatus === 'pending'; - const sourceLocale = content.meta?.translationSourceLang; - if (isPending && sourceLocale && content.published === 'published') { - const lastPublishedContent = await fetch( - `https://cdn.builder.io/api/v3/content/${appState.designerState.editingModel.name}/${content.id}?apiKey=${appState.user.apiKey}&cachebust=true` - ).then(res => res.json()); - const translatableFields = getTranslateableFields( - lastPublishedContent, - sourceLocale, - '' - ); - const currentRevision = hash(stringify(translatableFields), { encoding: 'base64' }); - appState.designerState.editingContentModel.meta.set( - 'translationRevisionLatest', - currentRevision - ); - if (currentRevision !== content.meta.translationRevision) { - showOutdatedNotifications(async () => { - appState.globalState.showGlobalBlockingLoading('Contacting Phrase ....'); - // TODO maybe just delete old project and re-request a new one. - appState.globalState.hideGlobalBlockingLoading(); - }); - } + if (!shouldCheck) return; + const meta = appState.designerState.editingContentModel.meta; + const isPending = meta.get('translationStatus') === 'pending'; + if (isPending) { + await checkTranslationFreshness(); } }, - { - fireImmediately: true, - } + { fireImmediately: true } ); }); - const transcludedMetaKey = 'excludeFromTranslation'; + const excludeKey = 'excludeFromTranslation'; registerContextMenuAction({ label: 'Exclude from future translations', - showIf(selectedElements) { - if (selectedElements.length !== 1) { - // todo maybe apply for multiple - return false; - } - const element = selectedElements[0]; - const isExcluded = element.meta?.get(transcludedMetaKey); - return element.component?.name === 'Text' && !isExcluded; + showIf(els) { + if (els.length !== 1) return false; + const el = els[0]; + return el.component?.name === 'Text' && !el.meta?.get(excludeKey); }, - onClick(elements) { - elements.forEach(el => el.meta.set('excludeFromTranslation', true)); + onClick(els) { + els.forEach(el => el.meta.set(excludeKey, true)); }, }); - registerContextMenuAction({ label: 'Include in future translations', - showIf(selectedElements) { - if (selectedElements.length !== 1) { - // todo maybe apply for multiple - return false; - } - const element = selectedElements[0]; - const isExcluded = element.meta?.get(transcludedMetaKey); - return element.component?.name === 'Text' && isExcluded; + showIf(els) { + if (els.length !== 1) return false; + const el = els[0]; + return el.component?.name === 'Text' && !!el.meta?.get(excludeKey); }, - onClick(elements) { - elements.forEach(el => el.meta.set('excludeFromTranslation', false)); + onClick(els) { + els.forEach(el => el.meta.set(excludeKey, false)); }, }); registerContentAction({ label: 'Translate', - showIf(content, model) { + showIf(content) { return ( content.published === 'published' && !enabledTranslationStatuses.includes(content.meta?.get('translationStatus')) ); }, async onClick(content) { - const model = content.modelName; - const contentId = content.id; + await api.ensureAuthenticated(); const picks = await getLangPicks(); - if (picks) { - appState.globalState.showGlobalBlockingLoading('Contacting Phrase ....'); + if (!picks) return; + appState.globalState.showGlobalBlockingLoading('Contacting Phrase ....'); + try { const { project } = await api.createJob( - contentId, - model, + content.id, + content.modelName, picks.sourceLang, picks.targetLangs, settings.get('callbackHost') ); - appState.globalState.hideGlobalBlockingLoading(); showJobNotification(project.uid, settings.get('isUSDataCenterAccount')); + } finally { + appState.globalState.hideGlobalBlockingLoading(); } }, }); - registerContentAction({ - label: 'Request an updated translation', - showIf(content, model) { - return ( - content.published === 'published' && - content.meta?.get('translationStatus') === 'pending' && - content.meta.get('translationRevisionLatest') && - content.meta.get('translationRevision') !== content.meta.get('translationRevisionLatest') - ); - }, - async onClick(content) { - appState.globalState.showGlobalBlockingLoading('Contacting Phrase ....'); - // TODO - appState.globalState.hideGlobalBlockingLoading(); - }, - }); registerContentAction({ label: 'Apply Translation', - showIf(content, model) { + showIf(content) { return ( content.published === 'published' && content.meta.get('translationStatus') === 'pending' ); }, async onClick(content) { + await api.ensureAuthenticated(); appState.globalState.showGlobalBlockingLoading(); - const file = await api.applyTranslation(content.id, content.modelName); - appState.globalState.hideGlobalBlockingLoading(); - appState.snackBar.show('Done!'); + try { + await api.applyTranslation(content.id, content.modelName); + appState.snackBar.show('Done!'); + } finally { + appState.globalState.hideGlobalBlockingLoading(); + } }, }); registerContentAction({ label: 'Reset Translation', - showIf(content, model) { + showIf(content) { return ( content.published === 'published' && content.meta.get('translationStatus') === 'pending' ); @@ -230,4 +213,138 @@ registerPlugin( } ); -const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +/** + * React component rendered inside the plugin settings page. + * Shows a Connect button that opens the Phrase OAuth window, and a + * Disconnect button when a valid token is already on record. + */ +function OAuthConnectButton(props: CustomReactEditorProps) { + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(null); + + const serverOauth = readOrgPluginSetting('oauth'); + const [override, setOverride] = React.useState<{ connected: boolean; connectedAt?: number } | null>(null); + // The override reflects a connect/disconnect done in *this* org. If the editor + // isn't remounted across an org switch, drop it so it can't show the previous + // org's state. + React.useEffect(() => { + const sub = appState.globalState.orgSwitched?.subscribe(() => setOverride(null)); + return () => { + if (typeof sub === 'function') sub(); + else (sub as any)?.unsubscribe?.(); + }; + }, []); + // Mirror ensureAuthenticated(): connected if the server holds a valid token + // OR the client session marker set right after connect is still unexpired. + const session = getSessionOAuth(); + const sessionValid = !!(session && session.expiresAt > Date.now()); + // After a disconnect the in-memory org model may still hold stale oauth + // metadata until it reloads; ignore it unless a fresh session says otherwise. + const disconnectedThisSession = isSessionDisconnected() && !sessionValid; + const connected = override + ? override.connected + : !disconnectedThisSession && (isOAuthValid(serverOauth) || sessionValid); + const connectedAt = override + ? override.connectedAt + : serverOauth?.connectedAt ?? session?.connectedAt; + // Prefer the live edited options (Builder passes the parent object being + // edited) so an unsaved data-center toggle targets the right Phrase region; + // fall back to persisted settings. + const editedOptions = (props as any)?.object; + const hasEditedOptions = editedOptions && typeof editedOptions.get === 'function'; + const isUS = hasEditedOptions + ? !!editedOptions.get('isUSDataCenterAccount') + : !!readOrgPluginSetting('isUSDataCenterAccount'); + // Keep OAuth traffic on the same host jobs use, honouring an unsaved admin + // apiHost override. + const apiHostOverride = hasEditedOptions + ? editedOptions.get('apiHost') || undefined + : readOrgPluginSetting('apiHost') || undefined; + // Prefer the live edited Client ID so a first-time connect uses the value the + // admin just typed (before save); fall back to persisted settings. + const clientId = hasEditedOptions + ? editedOptions.get('oauthClientId') || undefined + : readOrgPluginSetting('oauthClientId') || undefined; + + const onConnect = async () => { + setBusy(true); + setError(null); + try { + const result = await connectWithOAuth({ isUSDataCenterAccount: isUS, apiHost: apiHostOverride, clientId }); + if (!result?.expiresAt) { + setError('Phrase did not return a valid session. Please try again.'); + return; + } + setOverride({ connected: true, connectedAt: result.connectedAt }); + } catch (e: any) { + setError(e?.message || 'Failed to connect to Phrase'); + } finally { + setBusy(false); + } + }; + + const onDisconnect = async () => { + setBusy(true); + try { + await disconnectOAuth({ apiHost: apiHostOverride }); + setOverride({ connected: false }); + } finally { + setBusy(false); + } + }; + + return ( +
+ {connected ? ( + <> +
+ ✓ Connected to Phrase{connectedAt ? ` (${new Date(connectedAt).toLocaleString()})` : ''} +
+ + + ) : ( + + )} + {error ?
{error}
: null} +
+ ); +} + +// Warns editors when the published source strings changed after a pending +// Phrase job was created (restored from the original plugin behavior). +async function checkTranslationFreshness() { + const model = appState.designerState.editingContentModel; + const translationStatus = model.meta.get("translationStatus"); + const translationRequested = model.meta.get("translationRequested"); + const isFresh = model.lastUpdated > new Date(translationRequested); + if (!isFresh) return; + const content = fastClone(model); + const isPending = translationStatus === "pending"; + const sourceLocale = content.meta?.translationSourceLang; + const isPublished = content.published === "published"; + if (!isPending || !sourceLocale || !isPublished) return; + const modelName = appState.designerState.editingModel.name; + const apiKey = appState.user.apiKey; + const cdnUrl = + "https://cdn.builder.io/api/v3/content/" + + modelName + + "/" + + content.id + + "?apiKey=" + + apiKey + + "&cachebust=true"; + const lastPublishedContent = await fetch(cdnUrl).then(res => res.json()); + const translatableFields = getTranslateableFields(lastPublishedContent, sourceLocale, ""); + const currentRevision = hash(stringify(translatableFields), { encoding: "base64" }); + model.meta.set("translationRevisionLatest", currentRevision); + if (currentRevision !== content.meta.translationRevision) { + showOutdatedNotifications(async () => { + appState.globalState.showGlobalBlockingLoading("Contacting Phrase ...."); + appState.globalState.hideGlobalBlockingLoading(); + }); + } +}