From 3c9a5d0e93324b29baa7d63b9f0282cb3ede6227 Mon Sep 17 00:00:00 2001 From: Sanyam Kamat <1625114+sanyamkamat@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:24:16 +0530 Subject: [PATCH 01/23] feat: add OAuth 2.0 support and settings UI for Phrase integration --- plugins/phrase-conector/src/oauth-client.ts | 107 ++++++++ plugins/phrase-conector/src/phrase-api.ts | 123 +++++++++ plugins/phrase-conector/src/plugin.tsx | 274 +++++++++++--------- 3 files changed, 377 insertions(+), 127 deletions(-) create mode 100644 plugins/phrase-conector/src/oauth-client.ts create mode 100644 plugins/phrase-conector/src/phrase-api.ts diff --git a/plugins/phrase-conector/src/oauth-client.ts b/plugins/phrase-conector/src/oauth-client.ts new file mode 100644 index 00000000000..b3ad66e823b --- /dev/null +++ b/plugins/phrase-conector/src/oauth-client.ts @@ -0,0 +1,107 @@ +/** + * @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?: PhraseOAuthTokens | null): boolean { + if (!oauth?.accessToken) return false; + if (oauth.expiresAt <= Date.now()) return false; + // Valid = we have an access token that has not yet expired. + return true; +} + +function getApiHost(): string { + const orgSettings: any = + appState.user.organization?.value?.settings?.plugins?.get?.(PLUGIN_ID) || {}; + return orgSettings.apiHost || 'https://cdn.builder.io'; +} + +export async function connectWithOAuth(opts: { + isUSDataCenterAccount: boolean; +}): Promise { + const apiHost = getApiHost(); + const orgId = appState.user.organization.value.id; + const apiKey = appState.user.apiKey; + const stateParam = encodeURIComponent( + JSON.stringify({ + orgId, + apiKey, + pluginId: PLUGIN_ID, + isUS: !!opts.isUSDataCenterAccount, + // anti-CSRF nonce + nonce: Math.random().toString(36).slice(2), + }) + ); + const url = `${apiHost}/api/v1/memsource/oauth/start?state=${stateParam}`; + + 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.'); + + return await new Promise((resolve, reject) => { + const cleanup = () => { + window.removeEventListener('message', onMessage); + clearInterval(closedTimer); + }; + const onMessage = (e: MessageEvent) => { + const data = e.data || {}; + if (data?.type !== 'memsource-oauth-result') return; + if (e.source !== popup) return; + cleanup(); + try { + popup.close(); + } catch {} + if (data.error) { + reject(new Error(data.error)); + } else if (data.tokens) { + resolve(data.tokens as PhraseOAuthTokens); + } else { + reject(new Error('Unknown OAuth response')); + } + }; + window.addEventListener('message', onMessage); + const closedTimer = setInterval(() => { + if (popup.closed) { + cleanup(); + reject(new Error('OAuth window closed before completion')); + } + }, 500); + }); +} + +export async function disconnectOAuth(): Promise { + const apiHost = getApiHost(); + const privateKey = await appState.globalState.getPluginPrivateKey(PLUGIN_ID); + const params = new URLSearchParams({ apiKey: appState.user.apiKey, pluginId: PLUGIN_ID }); + await fetch(apiHost + "/api/v1/memsource/oauth/disconnect?" + params, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + privateKey, + }, + }); +} diff --git a/plugins/phrase-conector/src/phrase-api.ts b/plugins/phrase-conector/src/phrase-api.ts new file mode 100644 index 00000000000..82343db9c22 --- /dev/null +++ b/plugins/phrase-conector/src/phrase-api.ts @@ -0,0 +1,123 @@ +/** + * @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'; + +const PLUGIN_ID = pkg.name; + +export type Project = { uid: string }; + +export class PhraseApi { + private privateKey?: string; + private loaded: Promise; + private resolveLoaded!: () => void; + + constructor(private settings: any) { + this.loaded = new Promise(resolve => (this.resolveLoaded = resolve)); + this.init(); + appState.globalState.orgSwitched?.subscribe( + action(async () => { + 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() { + this.privateKey = await appState.globalState.getPluginPrivateKey(PLUGIN_ID); + 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 orgSettings: any = + appState.user.organization?.value?.settings?.plugins?.get?.(PLUGIN_ID) || {}; + if (orgSettings.authMode === 'password') { + if (!orgSettings.userName || !orgSettings.password) { + throw new Error('Phrase username/password is not configured.'); + } + return; + } + if (!orgSettings.oauth?.refreshToken) { + throw new Error('Phrase is not connected. Please click "Connect to Phrase" in plugin settings.'); + } + } + + 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) { + // Ask the server to refresh the token, then retry once. + await fetch(this.buildUrl('oauth/refresh'), { + method: 'POST', + headers: { Authorization: `Bearer ${this.privateKey}` }, + }); + 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..8e0270370b3 100644 --- a/plugins/phrase-conector/src/plugin.tsx +++ b/plugins/phrase-conector/src/plugin.tsx @@ -1,34 +1,73 @@ +/** + * @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 } 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: 'oauthStatus', + friendlyName: 'Phrase connection', + type: 'PhraseOAuthConnect', + showIf: (options: any) => options.get('authMode') !== 'password', + }, { name: 'userName', type: 'string', - required: true, + showIf: (options: any) => options.get('authMode') === 'password', }, { name: 'password', type: 'password', - required: true, + showIf: (options: any) => options.get('authMode') === 'password', }, { name: 'templateUId', @@ -37,178 +76,102 @@ 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 - ...(appState.user.isBuilderAdmin - ? [ - { - 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) { + // freshness check placeholder } }, - { - 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 +193,61 @@ 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: { value: any; onChange: (v: any) => void; context: any }) { + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(null); + + const orgSettings = + appState.user.organization?.value?.settings?.plugins?.get?.(PLUGIN_ID) || ({} as any); + const [tokens, setTokens] = React.useState(props.value ?? orgSettings.oauth ?? null); + const connected = isOAuthValid(tokens); + const isUS = !!orgSettings.isUSDataCenterAccount; + + const onConnect = async () => { + setBusy(true); + setError(null); + try { + const result = await connectWithOAuth({ isUSDataCenterAccount: isUS }); + setTokens(result); props.onChange(result); + } catch (e: any) { + setError(e?.message || 'Failed to connect to Phrase'); + } finally { + setBusy(false); + } + }; + + const onDisconnect = async () => { + setBusy(true); + try { + await disconnectOAuth(); + setTokens(null); props.onChange(null); + } finally { + setBusy(false); + } + }; + + return ( +
+ {connected ? ( + <> +
+ ✓ Connected to Phrase{tokens?.connectedAt ? ` (${new Date(tokens.connectedAt).toLocaleString()})` : ''} +
+ + + ) : ( + + )} + {error ?
{error}
: null} +
+ ); +} From 89f072a0d6be3b6ecde18b4b7c9d0a24c7e71a3d Mon Sep 17 00:00:00 2001 From: Sanyam Kamat <1625114+sanyamkamat@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:16:44 +0530 Subject: [PATCH 02/23] feat: implement OAuth 2.0 authentication for Phrase connector with connection and disconnection functionality --- .changeset/phrase-oauth-sso.md | 5 ++ plugins/phrase-conector/src/oauth-client.ts | 96 +++++++++++++-------- plugins/phrase-conector/src/phrase-api.ts | 10 ++- plugins/phrase-conector/src/plugin.tsx | 52 +++++++++-- 4 files changed, 116 insertions(+), 47 deletions(-) create mode 100644 .changeset/phrase-oauth-sso.md diff --git a/.changeset/phrase-oauth-sso.md b/.changeset/phrase-oauth-sso.md new file mode 100644 index 00000000000..e4558de7cfb --- /dev/null +++ b/.changeset/phrase-oauth-sso.md @@ -0,0 +1,5 @@ +--- +"@builder.io/plugin-phrase-connector": minor +--- + +feat: add OAuth 2.0 (SSO) authentication option to the Phrase connector alongside username/password diff --git a/plugins/phrase-conector/src/oauth-client.ts b/plugins/phrase-conector/src/oauth-client.ts index b3ad66e823b..53c9b22cc26 100644 --- a/plugins/phrase-conector/src/oauth-client.ts +++ b/plugins/phrase-conector/src/oauth-client.ts @@ -37,71 +37,91 @@ function getApiHost(): string { return orgSettings.apiHost || 'https://cdn.builder.io'; } + +export async function disconnectOAuth(): Promise { + const apiHost = getApiHost(); + const privateKey = await appState.globalState.getPluginPrivateKey(PLUGIN_ID); + const params = new URLSearchParams({ apiKey: appState.user.apiKey, pluginId: PLUGIN_ID }); + await fetch(apiHost + "/api/v1/memsource/oauth/disconnect?" + params, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + privateKey, + }, + }); +} + export async function connectWithOAuth(opts: { isUSDataCenterAccount: boolean; -}): Promise { +}): Promise<{ connectedAt?: number; expiresAt?: number }> { const apiHost = getApiHost(); - const orgId = appState.user.organization.value.id; - const apiKey = appState.user.apiKey; - const stateParam = encodeURIComponent( - JSON.stringify({ - orgId, - apiKey, - pluginId: PLUGIN_ID, - isUS: !!opts.isUSDataCenterAccount, - // anti-CSRF nonce - nonce: Math.random().toString(36).slice(2), - }) + 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, + }), + } ); - const url = `${apiHost}/api/v1/memsource/oauth/start?state=${stateParam}`; + 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' + "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.'); + if (!popup) throw new Error("Popup blocked. Allow popups for this site and try again."); - return await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { + let settled = false; const cleanup = () => { - window.removeEventListener('message', onMessage); + window.removeEventListener("message", onMessage); clearInterval(closedTimer); }; const onMessage = (e: MessageEvent) => { - const data = e.data || {}; - if (data?.type !== 'memsource-oauth-result') 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 if (data.tokens) { - resolve(data.tokens as PhraseOAuthTokens); } else { - reject(new Error('Unknown OAuth response')); + resolve(data.connection ? data.connection : {}); } }; - window.addEventListener('message', onMessage); + window.addEventListener("message", onMessage); const closedTimer = setInterval(() => { + if (settled) return; if (popup.closed) { cleanup(); - reject(new Error('OAuth window closed before completion')); + reject(new Error("OAuth window closed before completion")); } }, 500); }); } - -export async function disconnectOAuth(): Promise { - const apiHost = getApiHost(); - const privateKey = await appState.globalState.getPluginPrivateKey(PLUGIN_ID); - const params = new URLSearchParams({ apiKey: appState.user.apiKey, pluginId: PLUGIN_ID }); - await fetch(apiHost + "/api/v1/memsource/oauth/disconnect?" + params, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer " + privateKey, - }, - }); -} diff --git a/plugins/phrase-conector/src/phrase-api.ts b/plugins/phrase-conector/src/phrase-api.ts index 82343db9c22..5747828e9f4 100644 --- a/plugins/phrase-conector/src/phrase-api.ts +++ b/plugins/phrase-conector/src/phrase-api.ts @@ -62,15 +62,18 @@ export class PhraseApi { await this.loaded; const orgSettings: any = appState.user.organization?.value?.settings?.plugins?.get?.(PLUGIN_ID) || {}; - if (orgSettings.authMode === 'password') { + if (orgSettings.authMode !== 'oauth') { if (!orgSettings.userName || !orgSettings.password) { throw new Error('Phrase username/password is not configured.'); } return; } - if (!orgSettings.oauth?.refreshToken) { + if (!orgSettings.oauth?.accessToken) { throw new Error('Phrase is not connected. Please click "Connect to Phrase" in plugin settings.'); } + if (orgSettings.oauth.expiresAt <= Date.now()) { + throw new Error("Phrase OAuth session expired. Please reconnect."); + } } private async request(path: string, init?: RequestInit, search = {}) { @@ -87,12 +90,15 @@ export class PhraseApi { let res = await doFetch(); if (res.status === 401) { + const mode = (appState.user.organization?.value?.settings?.plugins?.get?.(PLUGIN_ID) ?? {}).authMode; + if (mode === "oauth") { // Ask the server to refresh the token, then retry once. await fetch(this.buildUrl('oauth/refresh'), { method: 'POST', headers: { Authorization: `Bearer ${this.privateKey}` }, }); res = await doFetch(); + } } if (!res.ok) { const text = await res.text().catch(() => ''); diff --git a/plugins/phrase-conector/src/plugin.tsx b/plugins/phrase-conector/src/plugin.tsx index 8e0270370b3..e20ccddc6d5 100644 --- a/plugins/phrase-conector/src/plugin.tsx +++ b/plugins/phrase-conector/src/plugin.tsx @@ -22,6 +22,9 @@ import { import { PhraseApi } from './phrase-api'; import { connectWithOAuth, disconnectOAuth, isOAuthValid } 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'; import { Builder } from '@builder.io/react'; const PLUGIN_ID = pkg.name; // '@builder.io/plugin-phrase-connector' @@ -91,7 +94,7 @@ registerPlugin( const meta = appState.designerState.editingContentModel.meta; const isPending = meta.get('translationStatus') === 'pending'; if (isPending) { - // freshness check placeholder + await checkTranslationFreshness(); } }, { fireImmediately: true } @@ -198,14 +201,14 @@ registerPlugin( * 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: { value: any; onChange: (v: any) => void; context: any }) { +function OAuthConnectButton(_props: CustomReactEditorProps) { const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const orgSettings = appState.user.organization?.value?.settings?.plugins?.get?.(PLUGIN_ID) || ({} as any); - const [tokens, setTokens] = React.useState(props.value ?? orgSettings.oauth ?? null); - const connected = isOAuthValid(tokens); + const serverOauth = orgSettings.oauth; const [override, setOverride] = React.useState<{ connected: boolean; connectedAt?: number } | null>(null); + const connected = override ? override.connected : isOAuthValid(serverOauth); const connectedAt = override ? override.connectedAt : serverOauth?.connectedAt; const isUS = !!orgSettings.isUSDataCenterAccount; const onConnect = async () => { @@ -213,7 +216,7 @@ function OAuthConnectButton(props: { value: any; onChange: (v: any) => void; con setError(null); try { const result = await connectWithOAuth({ isUSDataCenterAccount: isUS }); - setTokens(result); props.onChange(result); + setOverride({ connected: true, connectedAt: result.connectedAt }); } catch (e: any) { setError(e?.message || 'Failed to connect to Phrase'); } finally { @@ -225,7 +228,7 @@ function OAuthConnectButton(props: { value: any; onChange: (v: any) => void; con setBusy(true); try { await disconnectOAuth(); - setTokens(null); props.onChange(null); + setOverride({ connected: false }); } finally { setBusy(false); } @@ -236,7 +239,7 @@ function OAuthConnectButton(props: { value: any; onChange: (v: any) => void; con {connected ? ( <>
- ✓ Connected to Phrase{tokens?.connectedAt ? ` (${new Date(tokens.connectedAt).toLocaleString()})` : ''} + ✓ Connected to Phrase{connectedAt ? ` (${new Date(connectedAt).toLocaleString()})` : ''}