diff --git a/.gitignore b/.gitignore index 3c00580..3371996 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,8 @@ node_modules/ .agents/loop-state/ .aidevops.json + +# Local-only smoke tests / dev scratch — never ship: contains personal +# paths, hardcoded admin passwords, and developer-host URLs. +/wp-*-test.mjs +/wp-*-test.js diff --git a/build/connector.js b/build/connector.js index f7b1d10..e907067 100644 --- a/build/connector.js +++ b/build/connector.js @@ -1 +1 @@ -import{__experimentalConnectorItem as e,__experimentalRegisterConnector as n}from"@wordpress/connectors";function t(){var e,n,a="function"==typeof Symbol?Symbol:{},o=a.iterator||"@@iterator",i=a.toStringTag||"@@toStringTag";function c(t,a,o,i){var c=a&&a.prototype instanceof l?a:l,s=Object.create(c.prototype);return r(s,"_invoke",function(t,r,a){var o,i,c,l=0,s=a||[],p=!1,f={p:0,n:0,v:e,a:d,f:d.bind(e,4),d:function(n,t){return o=n,i=0,c=e,f.n=t,u}};function d(t,r){for(i=t,c=r,n=0;!p&&l&&!a&&n3?(a=h===r)&&(c=o[(i=o[4])?5:(i=3,3)],o[4]=o[5]=e):o[0]<=d&&((a=t<2&&dr||r>h)&&(o[4]=t,o[5]=r,f.n=h,i=0))}if(a||t>1)return u;throw p=!0,r}return function(a,s,h){if(l>1)throw TypeError("Generator is already running");for(p&&1===s&&d(s,h),i=s,c=h;(n=i<2?e:c)||!p;){o||(i?i<3?(i>1&&(f.n=-1),d(i,c)):f.n=c:f.v=c);try{if(l=2,o){if(i||(a="next"),n=o[a]){if(!(n=n.call(o,c)))throw TypeError("iterator result is not an object");if(!n.done)return n;c=n.value,i<2&&(i=0)}else 1===i&&(n=o.return)&&n.call(o),i<2&&(c=TypeError("The iterator does not provide a '"+a+"' method"),i=1);o=e}else if((n=(p=f.n<0)?c:t.call(r,f))!==u)break}catch(n){o=e,i=1,c=n}finally{l=1}}return{value:n,done:p}}}(t,o,i),!0),s}var u={};function l(){}function s(){}function p(){}n=Object.getPrototypeOf;var f=[][o]?n(n([][o]())):(r(n={},o,function(){return this}),n),d=p.prototype=l.prototype=Object.create(f);function h(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,p):(e.__proto__=p,r(e,i,"GeneratorFunction")),e.prototype=Object.create(d),e}return s.prototype=p,r(d,"constructor",p),r(p,"constructor",s),s.displayName="GeneratorFunction",r(p,i,"GeneratorFunction"),r(d),r(d,i,"Generator"),r(d,o,function(){return this}),r(d,"toString",function(){return"[object Generator]"}),(t=function(){return{w:c,m:h}})()}function r(e,n,t,a){var o=Object.defineProperty;try{o({},"",{})}catch(e){o=0}r=function(e,n,t,a){function i(n,t){r(e,n,function(e){return this._invoke(n,t,e)})}n?o?o(e,n,{value:t,enumerable:!a,configurable:!a,writable:!a}):e[n]=t:(i("next",0),i("throw",1),i("return",2))},r(e,n,t,a)}function a(e,n,t,r,a,o,i){try{var c=e[o](i),u=c.value}catch(e){return void t(e)}c.done?n(u):Promise.resolve(u).then(r,a)}function o(e){return function(){var n=this,t=arguments;return new Promise(function(r,o){var i=e.apply(n,t);function c(e){a(i,r,o,c,u,"next",e)}function u(e){a(i,r,o,c,u,"throw",e)}c(void 0)})}}function i(e,n){return function(e){if(Array.isArray(e))return e}(e)||function(e,n){var t=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=t){var r,a,o,i,c=[],u=!0,l=!1;try{if(o=(t=t.call(e)).next,0===n){if(Object(t)!==t)return;u=!1}else for(;!(u=(r=o.call(t)).done)&&(c.push(r.value),c.length!==n);u=!0);}catch(e){l=!0,a=e}finally{try{if(!u&&null!=t.return&&(i=t.return(),Object(i)!==i))return}finally{if(l)throw a}}return c}}(e,n)||function(e,n){if(e){if("string"==typeof e)return c(e,n);var t={}.toString.call(e).slice(8,-1);return"Object"===t&&e.constructor&&(t=e.constructor.name),"Map"===t||"Set"===t?Array.from(e):"Arguments"===t||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t)?c(e,n):void 0}}(e,n)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function c(e,n){(null==n||n>e.length)&&(n=e.length);for(var t=0,r=Array(n);t0?Math.round(n.expiresIn/60)+"m "+__("remaining"):""),l(m,{variant:"tertiary",size:"small",onClick:function(){return r(n.email)},disabled:a||!n.hasRefresh},__("Refresh")),l(m,{variant:"tertiary",size:"small",isDestructive:!0,onClick:function(){return t(n.email)},disabled:a},__("Remove")))}function A(){var e;if(null!==(e=window.wp)&&void 0!==e&&e.playground)return!0;try{var n=window.location.href;if(n.includes("playground.wordpress.net")||n.includes("playground.wordpress.org"))return!0}catch(e){return!0}try{window.parent!==window&&window.parent.location.href}catch(e){return!0}return!1}function O(e){var n=e.onComplete,r=e.onCancel,a=i(s("email"),2),c=a[0],u=a[1],p=i(s(""),2),f=p[0],h=p[1],g=i(s(""),2),x=g[0],C=g[1],_=i(s(""),2),S=_[0],z=_[1],O=i(s(""),2),T=O[0],P=O[1],j=i(s(!1),2),E=j[0],M=j[1],R=i(s(null),2),B=R[0],D=R[1],F=i(s(!1),2),I=F[0],N=F[1],W=function(){var e=o(t().m(function e(){var n,r;return t().w(function(e){for(;;)switch(e.p=e.n){case 0:if(f&&f.includes("@")){e.n=1;break}return D(__("Enter a valid email address.")),e.a(2);case 1:return D(null),M(!0),e.p=2,e.n=3,k({path:"/anthropic-max-pool/v1/authorize"});case 3:n=e.v,C(n.state),P(n.authorize_url),A()?N(!0):(window.open(n.authorize_url,"_blank","noopener"),N(!1)),u("code"),e.n=5;break;case 4:e.p=4,r=e.v,D(r instanceof Error?r.message:__("Failed to start OAuth flow."));case 5:return e.p=5,M(!1),e.f(5);case 6:return e.a(2)}},e,null,[[2,4,5,6]])}));return function(){return e.apply(this,arguments)}}(),G=function(){var e=o(t().m(function e(){var r;return t().w(function(e){for(;;)switch(e.p=e.n){case 0:if(S.trim()){e.n=1;break}return D(__("Paste the authorization code.")),e.a(2);case 1:return D(null),M(!0),e.p=2,e.n=3,k({method:"POST",path:"/anthropic-max-pool/v1/exchange",data:{code:S.trim(),state:x,email:f}});case 3:n(),e.n=5;break;case 4:e.p=4,r=e.v,D(r instanceof Error?r.message:__("Code exchange failed."));case 5:return e.p=5,M(!1),e.f(5);case 6:return e.a(2)}},e,null,[[2,4,5,6]])}));return function(){return e.apply(this,arguments)}}();return l(w,{spacing:3,style:{marginTop:"12px"}},B&&l(y,{status:"error",isDismissible:!1},B),"email"===c&&l(d,null,l(v,{__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0,label:__("Claude Max Account Email"),type:"email",value:f,onChange:h,placeholder:"you@example.com",disabled:E,help:__("The email address of your Claude Max subscription account.")}),l(b,{spacing:2},l(m,{__next40pxDefaultSize:!0,variant:"primary",onClick:W,disabled:E||!f,isBusy:E},__("Authorize with Claude")),l(m,{variant:"tertiary",onClick:r},__("Cancel")))),"code"===c&&l(d,null,I?l(y,{status:"warning",isDismissible:!1},l(w,{spacing:2},l("span",null,__("Popups to claude.ai are blocked in this environment (e.g. WordPress Playground). Open the authorization link below in a new browser tab, log in, then copy the code shown and paste it here.")),l(b,{spacing:2,wrap:!0},l("a",{href:T,target:"_blank",rel:"noopener noreferrer",style:{fontWeight:500}},__("Open Claude Authorization Page")),l(m,{variant:"link",style:{fontSize:"12px"},onClick:function(){navigator.clipboard.writeText(T).then(function(){return D(null)})}},__("Copy link"))))):l(d,null,l(y,{status:"info",isDismissible:!1},__("A new window opened for Claude authorization. Log in, then copy the authorization code shown and paste it below.")),!I&&T&&l(m,{variant:"link",onClick:function(){return N(!0)},style:{fontSize:"12px",padding:0}},__("Window didn't open or was blocked? Click here for a direct link."))),l(v,{__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0,label:__("Authorization Code"),value:S,onChange:z,placeholder:__("Paste the code from the Claude window"),disabled:E}),l(b,{spacing:2},l(m,{__next40pxDefaultSize:!0,variant:"primary",onClick:G,disabled:E||!S,isBusy:E},__("Add Account")),l(m,{variant:"tertiary",onClick:r},__("Cancel")))))}var T={label:__("Anthropic Max"),description:__("Use Claude with your Max subscription via OAuth. Supports account pool rotation for reliability."),logo:l(C,null),render:function(n){n.slug;var r=n.label,a=n.description,c=n.logo,u=i(s([]),2),d=u[0],h=u[1],v=i(s(!1),2),S=v[0],A=v[1],T=i(s(!1),2),P=T[0],j=T[1],E=i(s(!0),2),M=E[0],R=E[1],B=i(s(!1),2),D=B[0],F=B[1],I=i(s(null),2),N=I[0],W=I[1],G=d.length>0,H=f(o(t().m(function e(){var n;return t().w(function(e){for(;;)switch(e.p=e.n){case 0:return e.p=0,e.n=1,k({path:"/anthropic-max-pool/v1/accounts"});case 1:n=e.v,h(Array.isArray(n)?n:[]),e.n=3;break;case 2:e.p=2,e.v,h([]);case 3:return e.p=3,R(!1),e.f(3);case 4:return e.a(2)}},e,null,[[0,2,3,4]])})),[]);p(function(){H()},[H]);var U=function(){var e=o(t().m(function e(n){var r;return t().w(function(e){for(;;)switch(e.p=e.n){case 0:return F(!0),W(null),e.p=1,e.n=2,k({method:"POST",path:"/anthropic-max-pool/v1/accounts/remove",data:{email:n}});case 2:return e.n=3,H();case 3:e.n=5;break;case 4:e.p=4,r=e.v,W({status:"error",message:r instanceof Error?r.message:__("Failed to remove account.")});case 5:return e.p=5,F(!1),e.f(5);case 6:return e.a(2)}},e,null,[[1,4,5,6]])}));return function(_x){return e.apply(this,arguments)}}(),L=function(){var e=o(t().m(function e(n){var r;return t().w(function(e){for(;;)switch(e.p=e.n){case 0:return F(!0),W(null),e.p=1,e.n=2,k({method:"POST",path:"/anthropic-max-pool/v1/accounts/refresh",data:{email:n}});case 2:return W({status:"success",message:__("Token refreshed.")}),e.n=3,H();case 3:e.n=5;break;case 4:e.p=4,r=e.v,W({status:"error",message:r instanceof Error?r.message:__("Refresh failed.")});case 5:return e.p=5,F(!1),e.f(5);case 6:return e.a(2)}},e,null,[[1,4,5,6]])}));return function(n){return e.apply(this,arguments)}}(),V=function(){var e=o(t().m(function e(){var n,r;return t().w(function(e){for(;;)switch(e.p=e.n){case 0:return F(!0),W(null),e.p=1,e.n=2,k({path:"/anthropic-max-pool/v1/health"});case 2:return n=e.v,r=n.filter(function(e){return"ok"===e.validity}).length,W({status:r===n.length?"success":"warning",message:r+"/"+n.length+" "+__("accounts healthy")}),e.n=3,H();case 3:e.n=5;break;case 4:e.p=4,e.v,W({status:"error",message:__("Health check failed.")});case 5:return e.p=5,F(!1),e.f(5);case 6:return e.a(2)}},e,null,[[1,4,5,6]])}));return function(){return e.apply(this,arguments)}}(),X=l(b,{spacing:3,expanded:!1},G&&!S&&l(_,{count:d.length}),l(m,{variant:S||G?"tertiary":"secondary",size:S||G?void 0:"compact",onClick:function(){A(!S),j(!1),W(null)},disabled:M,"aria-expanded":S},__(M?"Loading…":S?"Close":G?"Manage":"Set up"))),$=S?l(w,{spacing:4,className:"connector-settings"},N&&l(y,{status:N.status,isDismissible:!0,onDismiss:function(){return W(null)}},N.message),d.length>0&&l(w,{spacing:0},d.map(function(e){return l(z,{key:e.email,account:e,onRemove:U,onRefresh:L,isBusy:D})})),0===d.length&&!P&&l(x,{style:{color:"#757575"}},__("No accounts configured. Add a Claude Max account to get started.")),P?l(O,{onComplete:function(){j(!1),H()},onCancel:function(){return j(!1)}}):l(b,{spacing:2,justify:"flex-start"},l(m,{__next40pxDefaultSize:!0,variant:"primary",onClick:function(){return j(!0)},disabled:D},__("Add Account")),d.length>0&&l(m,{variant:"tertiary",onClick:V,disabled:D},D?l(g,null):__("Health Check")))):null;return l(e,{className:"connector-item--ultimate-ai-connector-anthropic-max",logo:c||l(C,null),name:r,description:a,actionArea:X},$)}};function P(){n("ultimate-ai-connector-anthropic-max",T)}P(),Promise.resolve().then(P),setTimeout(P,0),setTimeout(P,50),setTimeout(P,250),setTimeout(P,1e3); \ No newline at end of file +import{__experimentalConnectorItem as e,__experimentalRegisterConnector as t}from"@wordpress/connectors";function n(e){return n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},n(e)}function r(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),n.push.apply(n,r)}return n}function o(e){for(var t=1;t3?(o=h===r)&&(c=a[(i=a[4])?5:(i=3,3)],a[4]=a[5]=e):a[0]<=d&&((o=n<2&&dr||r>h)&&(a[4]=n,a[5]=r,f.n=h,i=0))}if(o||n>1)return s;throw p=!0,r}return function(o,l,h){if(u>1)throw TypeError("Generator is already running");for(p&&1===l&&d(l,h),i=l,c=h;(t=i<2?e:c)||!p;){a||(i?i<3?(i>1&&(f.n=-1),d(i,c)):f.n=c:f.v=c);try{if(u=2,a){if(i||(o="next"),t=a[o]){if(!(t=t.call(a,c)))throw TypeError("iterator result is not an object");if(!t.done)return t;c=t.value,i<2&&(i=0)}else 1===i&&(t=a.return)&&t.call(a),i<2&&(c=TypeError("The iterator does not provide a '"+o+"' method"),i=1);a=e}else if((t=(p=f.n<0)?c:n.call(r,f))!==s)break}catch(t){a=e,i=1,c=t}finally{u=1}}return{value:t,done:p}}}(n,o,a),!0),l}var s={};function u(){}function l(){}function p(){}t=Object.getPrototypeOf;var f=[][r]?t(t([][r]())):(c(t={},r,function(){return this}),t),d=p.prototype=u.prototype=Object.create(f);function h(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,p):(e.__proto__=p,c(e,o,"GeneratorFunction")),e.prototype=Object.create(d),e}return l.prototype=p,c(d,"constructor",p),c(p,"constructor",l),l.displayName="GeneratorFunction",c(p,o,"GeneratorFunction"),c(d),c(d,o,"Generator"),c(d,r,function(){return this}),c(d,"toString",function(){return"[object Generator]"}),(i=function(){return{w:a,m:h}})()}function c(e,t,n,r){var o=Object.defineProperty;try{o({},"",{})}catch(e){o=0}c=function(e,t,n,r){function a(t,n){c(e,t,function(e){return this._invoke(t,n,e)})}t?o?o(e,t,{value:n,enumerable:!r,configurable:!r,writable:!r}):e[t]=n:(a("next",0),a("throw",1),a("return",2))},c(e,t,n,r)}function s(e,t,n,r,o,a,i){try{var c=e[a](i),s=c.value}catch(e){return void n(e)}c.done?t(s):Promise.resolve(s).then(r,o)}function u(e){return function(){var t=this,n=arguments;return new Promise(function(r,o){var a=e.apply(t,n);function i(e){s(a,r,o,i,c,"next",e)}function c(e){s(a,r,o,i,c,"throw",e)}i(void 0)})}}function l(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var r,o,a,i,c=[],s=!0,u=!1;try{if(a=(n=n.call(e)).next,0===t){if(Object(n)!==n)return;s=!1}else for(;!(s=(r=a.call(n)).done)&&(c.push(r.value),c.length!==t);s=!0);}catch(e){u=!0,o=e}finally{try{if(!s&&null!=n.return&&(i=n.return(),Object(i)!==i))return}finally{if(u)throw o}}return c}}(e,t)||function(e,t){if(e){if("string"==typeof e)return p(e,t);var n={}.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?p(e,t):void 0}}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function p(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=Array(t);njust a moment")?__("The upstream provider returned an HTML challenge page (likely Cloudflare bot protection). Please wait a minute and try again."):e.length>300?"".concat(e.slice(0,300),"…"):e}(n,t)}var R={anthropic:{id:"anthropic",slug:"ultimate-ai-connector-anthropic-max",label:__("Anthropic Max"),description:__("Use Claude with your Max subscription via OAuth. Supports account pool rotation for reliability."),mode:"oauth-paste",emailRequired:!0,instructions:__("A new window opened for Claude authorization. Log in, then copy the authorization code shown and paste it below."),iconText:"MAX"},openai:{id:"openai",slug:"ultimate-ai-connector-chatgpt-codex",label:__("OpenAI ChatGPT (Codex)"),description:__("ChatGPT Plus/Pro/Team via OAuth — unlocks GPT-5.4 mini, GPT-5.4, GPT-5.5, GPT-5.2 through the Codex backend. No localhost server required."),mode:"device-code",iconText:"GPT"},cursor:{id:"cursor",slug:"ultimate-ai-connector-cursor-pro",label:__("Cursor Pro"),description:__("Use Cursor Pro tokens. Cursor has no public OAuth — paste your access/refresh tokens from the IDE."),mode:"manual-token",emailRequired:!1,instructions:__("Find your tokens in Cursor IDE: ~/.cursor/auth.json (Linux/macOS) or %APPDATA%/Cursor/auth.json (Windows). Email is auto-derived from the token."),iconText:"CUR"},google:{id:"google",slug:"ultimate-ai-connector-google-ai-pro",label:__("Google AI Pro"),description:__("Use Google AI Pro/Ultra or Workspace Gemini via OAuth. Paste the OOB code shown by Google."),mode:"oauth-paste",emailRequired:!0,instructions:__("After signing in, Google shows a code in the browser. Copy and paste it below."),iconText:"GAI"}};function N(e){var t=e.message;return t?d("div",{role:"alert",style:{background:"#fcf0f1",borderLeft:"4px solid #cc1818",color:"#621e1e",padding:"10px 12px",fontSize:"13px",lineHeight:"1.4"}},t):null}function E(e){var t=e.text,n="string"==typeof t?t:"";return d("svg",{width:40,height:40,viewBox:"0 0 40 40",fill:"none",xmlns:"http://www.w3.org/2000/svg"},d("text",{x:"20",y:"24",textAnchor:"middle",dominantBaseline:"central",fontFamily:"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",fontSize:n.length>3?11:14,fontWeight:"800",letterSpacing:"0.5",fill:"currentColor"},n))}function D(e){var t=e.count;return d("span",{style:{color:"#345b37",backgroundColor:"#eff8f0",padding:"4px 12px",borderRadius:"2px",fontSize:"13px",fontWeight:500,whiteSpace:"nowrap"}},1===t?__("1 account"):t+" "+__("accounts"))}function I(e){var t=e.status,n=e.validity,r={active:{color:"#345b37",bg:"#eff8f0"},idle:{color:"#5a5a5a",bg:"#f0f0f0"},"rate-limited":{color:"#8a4600",bg:"#fff3e0"},"refresh-failed":{color:"#cc1818",bg:"#fce8e8"}},o=r[t]||r.idle,a=t;return"invalid"===n&&(a="invalid token"),d("span",{style:{color:o.color,backgroundColor:o.bg,padding:"2px 8px",borderRadius:"2px",fontSize:"12px",fontWeight:500}},a)}function G(e){var t=e.account,n=e.onRemove,r=e.onRefresh,o=e.isBusy,a=e.providerSupportsRefresh;return d(S,{spacing:3,style:{padding:"8px 0",borderBottom:"1px solid #e0e0e0",alignItems:"center"}},d("span",{style:{flex:1,fontWeight:500}},t.email),d(I,{status:t.status,validity:t.validity}),d("span",{style:{fontSize:"12px",color:"#757575"}},t.tokenExpired?__("expired"):t.expiresIn>0?Math.round(t.expiresIn/60)+"m "+__("remaining"):""),a&&d(b,{variant:"tertiary",size:"small",onClick:function(){return r(t.email)},disabled:o||!t.hasRefresh},__("Refresh")),d(b,{variant:"tertiary",size:"small",isDestructive:!0,onClick:function(){return n(t.email)},disabled:o},__("Remove")))}function M(){var e;if(null!==(e=window.wp)&&void 0!==e&&e.playground)return!0;try{var t=window.location.href;if(t.includes("playground.wordpress.net")||t.includes("playground.wordpress.org"))return!0}catch(e){return!0}try{window.parent!==window&&window.parent.location.href}catch(e){return!0}return!1}function B(e){var t=e.providerCfg,n=e.onComplete,r=e.onCancel,a=t.id,c=t.label,s=t.instructions,p=t.emailRequired,f=l(h(p?"email":"authorize"),2),m=f[0],g=f[1],y=l(h(""),2),x=y[0],k=y[1],_=l(h(""),2),R=_[0],E=_[1],D=l(h(""),2),I=D[0],G=D[1],B=l(h(""),2),F=B[0],W=B[1],H=l(h(!1),2),L=H[0],q=H[1],V=l(h(""),2),X=V[0],Y=V[1],$=l(h(!1),2),J=$[0],K=$[1],Q=function(){var e=u(i().m(function e(){var t,n;return i().w(function(e){for(;;)switch(e.p=e.n){case 0:if(!p||x&&x.includes("@")){e.n=1;break}return Y(__("Enter a valid email address.")),e.a(2);case 1:return Y(""),q(!0),e.p=2,e.n=3,O({path:"".concat(j,"/").concat(a,"/authorize")});case 3:t=e.v,E(t.state),W(t.authorize_url),M()?K(!0):(window.open(t.authorize_url,"_blank","noopener"),K(!1)),g("code"),e.n=5;break;case 4:e.p=4,n=e.v,Y(z(n,__("Failed to start OAuth flow.")));case 5:return e.p=5,q(!1),e.f(5);case 6:return e.a(2)}},e,null,[[2,4,5,6]])}));return function(){return e.apply(this,arguments)}}(),Z=function(){var e=u(i().m(function e(){var t;return i().w(function(e){for(;;)switch(e.p=e.n){case 0:if(I.trim()){e.n=1;break}return Y(__("Paste the authorization code.")),e.a(2);case 1:return Y(""),q(!0),e.p=2,e.n=3,O({method:"POST",path:"".concat(j,"/").concat(a,"/exchange"),data:{code:I.trim(),state:R,email:x||"unknown"}});case 3:(0,P().createSuccessNotice)(U("%s account connected.",c),o(o({},A),{},{id:"".concat(a,"-add-success")})),n(),e.n=5;break;case 4:e.p=4,t=e.v,Y(z(t,__("Code exchange failed.")));case 5:return e.p=5,q(!1),e.f(5);case 6:return e.a(2)}},e,null,[[2,4,5,6]])}));return function(){return e.apply(this,arguments)}}();return d(T,{spacing:3,style:{marginTop:"12px"}},X&&d(N,{message:X}),"email"===m&&p&&d(v,null,d(w,{__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0,label:U("%s Account Email",c),type:"email",value:x,onChange:k,placeholder:"you@example.com",disabled:L,help:__("The email address of your subscription account.")}),d(S,{spacing:2},d(b,{__next40pxDefaultSize:!0,variant:"primary",onClick:Q,disabled:L||!x,isBusy:L},__("Authorize")),d(b,{variant:"tertiary",onClick:r},__("Cancel")))),"code"===m&&d(v,null,d(C,{status:J?"warning":"info",isDismissible:!1,politeness:"polite",spokenMessage:J?__("Popups appear to be blocked. Open the authorization link below in a new tab, sign in, then copy the URL you land on and paste it below."):s},d(T,{spacing:2},d("span",null,J?__("Popups appear to be blocked. Open the authorization link below in a new tab, sign in, then copy the URL you land on and paste it below."):s),F&&d(S,{spacing:2,wrap:!0},d("a",{href:F,target:"_blank",rel:"noopener noreferrer",style:{fontWeight:500}},__("Open Authorization Page")),d(b,{variant:"link",style:{fontSize:"12px"},onClick:function(){navigator.clipboard.writeText(F).then(function(){return Y("")})}},__("Copy link"))))),d(w,{__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0,label:__("Authorization Code or Redirect URL"),value:I,onChange:G,placeholder:__("Paste the full URL from your browser bar (or just the code)"),disabled:L}),d(S,{spacing:2},d(b,{__next40pxDefaultSize:!0,variant:"primary",onClick:Z,disabled:L||!I,isBusy:L},__("Add Account")),d(b,{variant:"tertiary",onClick:r},__("Cancel")))))}function F(e){var t=e.providerCfg,n=e.onComplete,r=e.onCancel,a=t.id,c=t.label,s=t.instructions,p=l(h(""),2),f=p[0],m=p[1],g=l(h(""),2),v=g[0],y=g[1],k=l(h(""),2),_=k[0],R=k[1],E=l(h(!1),2),D=E[0],I=E[1],G=l(h(""),2),M=G[0],B=G[1],F=function(){var e=u(i().m(function e(){var t;return i().w(function(e){for(;;)switch(e.p=e.n){case 0:if(f.trim()){e.n=1;break}return B(__("Paste the access token.")),e.a(2);case 1:return B(""),I(!0),e.p=2,e.n=3,O({method:"POST",path:"".concat(j,"/").concat(a,"/manual"),data:{access_token:f.trim(),refresh_token:v.trim(),email:_.trim()}});case 3:(0,P().createSuccessNotice)(U("%s account connected.",c),o(o({},A),{},{id:"".concat(a,"-add-success")})),n(),e.n=5;break;case 4:e.p=4,t=e.v,B(z(t,__("Failed to add account.")));case 5:return e.p=5,I(!1),e.f(5);case 6:return e.a(2)}},e,null,[[2,4,5,6]])}));return function(){return e.apply(this,arguments)}}(),W="string"==typeof s?s:"";return d(T,{spacing:3,style:{marginTop:"12px"}},M&&d(N,{message:M}),d(C,{status:"info",isDismissible:!1,politeness:"polite",spokenMessage:W},W),d(x,{__nextHasNoMarginBottom:!0,label:__("Access Token"),value:f,onChange:m,rows:3,disabled:D}),d(x,{__nextHasNoMarginBottom:!0,label:__("Refresh Token (optional)"),value:v,onChange:y,rows:3,disabled:D}),d(w,{__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0,label:__("Email (optional — auto-derived from the token if blank)"),type:"email",value:_,onChange:R,placeholder:"you@example.com",disabled:D}),d(S,{spacing:2},d(b,{__next40pxDefaultSize:!0,variant:"primary",onClick:F,disabled:D||!f,isBusy:D},U("Add %s Account",c)),d(b,{variant:"tertiary",onClick:r},__("Cancel"))))}function W(e){var t=e.providerCfg,n=e.onComplete,r=e.onCancel,a=t.id,i=l(h("loading"),2),c=i[0],s=i[1],u=l(h(""),2),p=u[0],f=u[1],g=l(h(""),2),v=g[0],y=g[1],w=l(h(5e3),2),x=w[0],R=w[1],E=l(h(0),2),D=E[0],I=E[1],G=l(h(0),2),M=G[0],B=G[1],F=l(h(""),2),W=F[0],H=F[1],L=l(h(""),2),q=L[0],V=L[1],X=l(h(!1),2),Y=X[0],$=X[1],J="https://auth.openai.com/codex/device";if(m(function(){var e=!1;return O({method:"POST",path:"".concat(j,"/").concat(a,"/device-start")}).then(function(t){if(!e){f(t.user_code),y(t.session_key),R(t.interval_ms||5e3);var n=Date.now()+(t.expires_in_ms||9e5);I(n),B(t.expires_in_ms||9e5),s("waiting")}}).catch(function(t){e||(H(z(t,__("Failed to start device-code flow."))),s("error"))}),function(){e=!0}},[a]),m(function(){if("waiting"===c&&0!==D){var e=setInterval(function(){var t=D-Date.now();t<=0?(B(0),s("expired"),clearInterval(e)):B(t)},1e3);return function(){return clearInterval(e)}}},[c,D]),m(function(){if("waiting"===c&&v){var e=setInterval(function(){O({method:"POST",path:"".concat(j,"/").concat(a,"/device-poll"),data:{session_key:v}}).then(function(e){"complete"===e.status?(V(e.email||""),s("success")):"expired"===e.status&&s("expired")}).catch(function(e){H(z(e,__("Polling failed."))),s("error")})},x);return function(){return clearInterval(e)}}},[c,v,x,a]),m(function(){if("success"===c){(0,P().createSuccessNotice)(q?U("Connected: %s",q):__("Account connected successfully."),o(o({},A),{},{id:"".concat(a,"-device-code-success")}));var e=setTimeout(n,1500);return function(){return clearTimeout(e)}}},[c,n,q,a]),"loading"===c)return d(T,{spacing:3,style:{marginTop:"12px",alignItems:"center"}},d(k,null),d(_,null,__("Requesting device code…")));if("error"===c)return d(T,{spacing:3,style:{marginTop:"12px"}},d(N,{message:"string"==typeof W&&W?W:__("Device-code flow failed.")}),d(S,{spacing:2},d(b,{variant:"primary",onClick:function(){return window.location.reload()}},__("Try again")),d(b,{variant:"tertiary",onClick:r},__("Cancel"))));if("expired"===c){var K=__('Device code expired. Click "Start again" to get a new one.');return d(T,{spacing:3,style:{marginTop:"12px"}},d(C,{status:"warning",isDismissible:!1,politeness:"polite",spokenMessage:K},K),d(S,{spacing:2},d(b,{variant:"primary",onClick:function(){return window.location.reload()}},__("Start again")),d(b,{variant:"tertiary",onClick:r},__("Cancel"))))}if("success"===c)return null;var Q,Z,ee,te,ne=__("Open the link below in your browser, enter the code shown, then sign in with your ChatGPT Plus/Pro/Team account. This page will update automatically once authorised.");return d(T,{spacing:4,style:{marginTop:"12px"}},d(C,{status:"info",isDismissible:!1,politeness:"polite",spokenMessage:ne},d(T,{spacing:2},d("span",null,ne),d("a",{href:J,target:"_blank",rel:"noopener noreferrer",style:{fontWeight:600}},J))),d(T,{spacing:1,style:{alignItems:"center"}},d(_,{style:{fontSize:"11px",color:"#757575",textTransform:"uppercase",letterSpacing:"0.5px"}},__("Your device code")),d(S,{spacing:2,style:{alignItems:"center"}},d("span",{style:{fontFamily:"monospace",fontSize:"28px",fontWeight:700,letterSpacing:"6px",padding:"8px 16px",background:"#f6f7f7",borderRadius:"4px",border:"1px solid #ddd",userSelect:"all"}},p),d(b,{variant:"secondary",size:"small",onClick:function(){navigator.clipboard.writeText(p).then(function(){$(!0),setTimeout(function(){return $(!1)},2e3)})}},__(Y?"Copied!":"Copy"))),d(_,{style:{fontSize:"12px",color:"#757575"}},__("Expires in")+" "+(Q=M,Z=Math.max(0,Math.floor(Q/1e3)),ee=Math.floor(Z/60),te=String(Z%60).padStart(2,"0"),"".concat(ee,":").concat(te)))),d(S,{spacing:3,style:{alignItems:"center"}},d(k,null),d(_,{style:{color:"#757575"}},__("Waiting for authorisation…")),d(b,{variant:"tertiary",onClick:r},__("Cancel"))))}function U(e,t){return e.replace("%s",t)}function H(t){var n=t.providerCfg,r=n.id,a=n.label,c=n.description,s=n.mode,p=n.iconText,f=l(h([]),2),v=f[0],y=f[1],w=l(h(!1),2),x=w[0],C=w[1],R=l(h(!1),2),N=R[0],I=R[1],M=l(h(!0),2),H=M[0],L=M[1],q=l(h(!1),2),V=q[0],X=q[1],Y=v.length>0,$="oauth-paste"===s||"device-code"===s,J=g(u(i().m(function e(){var t;return i().w(function(e){for(;;)switch(e.p=e.n){case 0:return e.p=0,e.n=1,O({path:"".concat(j,"/").concat(r,"/accounts")});case 1:t=e.v,y(Array.isArray(t)?t:[]),e.n=3;break;case 2:e.p=2,e.v,y([]);case 3:return e.p=3,L(!1),e.f(3);case 4:return e.a(2)}},e,null,[[0,2,3,4]])})),[r]);m(function(){J()},[J]);var K=function(){var e=u(i().m(function e(t){var n,c,s,u;return i().w(function(e){for(;;)switch(e.p=e.n){case 0:return X(!0),n=P(),c=n.createSuccessNotice,s=n.createErrorNotice,e.p=1,e.n=2,O({method:"POST",path:"".concat(j,"/").concat(r,"/accounts/remove"),data:{email:t}});case 2:return c(U("%s account removed.",a),o(o({},A),{},{id:"".concat(r,"-remove-success")})),e.n=3,J();case 3:e.n=5;break;case 4:e.p=4,u=e.v,s(z(u,__("Failed to remove account.")),o(o({},A),{},{id:"".concat(r,"-remove-error")}));case 5:return e.p=5,X(!1),e.f(5);case 6:return e.a(2)}},e,null,[[1,4,5,6]])}));return function(_x){return e.apply(this,arguments)}}(),Q=function(){var e=u(i().m(function e(t){var n,a,c,s;return i().w(function(e){for(;;)switch(e.p=e.n){case 0:return X(!0),n=P(),a=n.createSuccessNotice,c=n.createErrorNotice,e.p=1,e.n=2,O({method:"POST",path:"".concat(j,"/").concat(r,"/accounts/refresh"),data:{email:t}});case 2:return a(__("Token refreshed."),o(o({},A),{},{id:"".concat(r,"-refresh-success")})),e.n=3,J();case 3:e.n=5;break;case 4:e.p=4,s=e.v,c(z(s,__("Refresh failed.")),o(o({},A),{},{id:"".concat(r,"-refresh-error")}));case 5:return e.p=5,X(!1),e.f(5);case 6:return e.a(2)}},e,null,[[1,4,5,6]])}));return function(t){return e.apply(this,arguments)}}(),Z=function(){var e=u(i().m(function e(){var t,n,a,c,s,u,l,p,f;return i().w(function(e){for(;;)switch(e.p=e.n){case 0:return X(!0),t=P(),n=t.createSuccessNotice,a=t.createErrorNotice,e.p=1,e.n=2,O({path:"".concat(j,"/").concat(r,"/health")});case 2:return c=e.v,s=Array.isArray(c)?c:[],u=s.length,l=s.filter(function(e){return e&&"ok"===e.validity}).length,p=u>0&&l===u,f=l+"/"+u+" "+__("accounts healthy"),p?n(f,o(o({},A),{},{id:"".concat(r,"-health-success")})):a(f,o(o({},A),{},{id:"".concat(r,"-health-warning")})),e.n=3,J();case 3:e.n=5;break;case 4:e.p=4,e.v,a(__("Health check failed."),o(o({},A),{},{id:"".concat(r,"-health-error")}));case 5:return e.p=5,X(!1),e.f(5);case 6:return e.a(2)}},e,null,[[1,4,5,6]])}));return function(){return e.apply(this,arguments)}}(),ee=d(S,{spacing:3,expanded:!1},Y&&!x&&d(D,{count:v.length}),d(b,{variant:x||Y?"tertiary":"secondary",size:x||Y?void 0:"compact",onClick:function(){C(!x),I(!1)},disabled:H,"aria-expanded":x},__(H?"Loading…":x?"Close":Y?"Manage":"Set up"))),te="manual-token"===s?F:"device-code"===s?W:B,ne=x?d(T,{spacing:4,className:"connector-settings"},v.length>0&&d(T,{spacing:0},v.map(function(e){return d(G,{key:e.email,account:e,onRemove:K,onRefresh:Q,isBusy:V,providerSupportsRefresh:$})})),0===v.length&&!N&&d(_,{style:{color:"#757575"}},U("No %s accounts configured.",a)),N?d(te,{providerCfg:n,onComplete:function(){I(!1),J()},onCancel:function(){return I(!1)}}):d(S,{spacing:2,justify:"flex-start"},d(b,{__next40pxDefaultSize:!0,variant:"primary",onClick:function(){return I(!0)},disabled:V},__("Add Account")),v.length>0&&d(b,{variant:"tertiary",onClick:Z,disabled:V},V?d(k,null):__("Health Check")))):null;return d(e,{className:"connector-item--".concat(n.slug),logo:d(E,{text:p}),name:a,description:c,actionArea:ee},ne)}function L(e){t(e.slug,Object.freeze({label:e.label,description:e.description,logo:d(E,{text:e.iconText}),render:function(){return d(H,{providerCfg:e})}}))}function q(){Object.values(R).forEach(L)}q(),Promise.resolve().then(q),setTimeout(q,0),setTimeout(q,50),setTimeout(q,250),setTimeout(q,1e3); \ No newline at end of file diff --git a/inc/provider-registration.php b/inc/provider-registration.php index 403004f..0499d38 100644 --- a/inc/provider-registration.php +++ b/inc/provider-registration.php @@ -18,38 +18,52 @@ use WordPress\AiClient\AiClient; use AnthropicMaxAiProvider\Authentication\AnthropicOAuthRequestAuthentication; +use AnthropicMaxAiProvider\Authentication\ChatGptCodexOAuthRequestAuthentication; use AnthropicMaxAiProvider\OAuthPool\PoolManager; +use AnthropicMaxAiProvider\OAuthPool\PoolRegistry; use AnthropicMaxAiProvider\Provider\AnthropicMaxProvider; +use AnthropicMaxAiProvider\Provider\ChatGptCodexProvider; /** - * Registers the Anthropic Max provider with the AI Client on init. + * Registers all available OAuth-backed AI providers with the AI Client on init. * - * Runs at priority 5 so the provider is available before most plugins - * act on `init` at the default priority of 10. + * Runs at priority 5 so providers are available before most plugins + * act on `init` at the default priority of 10. Each provider is only + * registered when its underlying account pool has at least one entry, + * so an empty pool leaves the WordPress AiClient registry untouched. */ function register_provider(): void { if ( ! class_exists( AiClient::class ) ) { return; } - $pool = PoolManager::getInstance(); - - // Only register if there are accounts in the pool. - if ( $pool->count() === 0 ) { - return; - } - $registry = AiClient::defaultRegistry(); - if ( $registry->hasProvider( AnthropicMaxProvider::class ) ) { - return; + // --- Anthropic Max (Claude via Max subscription OAuth) --- + $anthropic_pool = PoolManager::getInstance(); + if ( $anthropic_pool->count() > 0 + && ! $registry->hasProvider( AnthropicMaxProvider::class ) + ) { + $registry->registerProvider( AnthropicMaxProvider::class ); + $registry->setProviderRequestAuthentication( + AnthropicMaxProvider::class, + new AnthropicOAuthRequestAuthentication( $anthropic_pool ) + ); } - $registry->registerProvider( AnthropicMaxProvider::class ); - - // Inject the OAuth authentication using the pool manager. - $registry->setProviderRequestAuthentication( - AnthropicMaxProvider::class, - new AnthropicOAuthRequestAuthentication( $pool ) - ); + // --- ChatGPT Codex (OpenAI ChatGPT Plus/Pro/Team OAuth → Codex backend) --- + // Distinct provider id (`ultimate-ai-connector-chatgpt-codex`) because + // the OAuth token is rejected by `api.openai.com` and only works + // against `chatgpt.com/backend-api/codex/responses`. See + // ChatGptCodexProvider class docblock for the live-test evidence. + $openai_pool = PoolRegistry::pool( 'openai' ); + if ( $openai_pool->count() > 0 + && ! $registry->hasProvider( ChatGptCodexProvider::class ) + ) { + $registry->registerProvider( ChatGptCodexProvider::class ); + $registry->setProviderRequestAuthentication( + ChatGptCodexProvider::class, + new ChatGptCodexOAuthRequestAuthentication( $openai_pool ) + ); + } } diff --git a/inc/rest-api.php b/inc/rest-api.php index 1e88a88..c8771a2 100644 --- a/inc/rest-api.php +++ b/inc/rest-api.php @@ -1,9 +1,30 @@ 'GET', + 'callback' => __NAMESPACE__ . '\\rest_list_providers', + 'permission_callback' => __NAMESPACE__ . '\\can_manage', + ] + ); + + // ----------------------------------------------------------------- + // Per-provider routes (1.2.0+). + // ----------------------------------------------------------------- + $provider_arg = [ + 'provider' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + 'enum' => PoolRegistry::supportedIds(), + ], + ]; + + register_rest_route( + $namespace, + '/(?P[a-z0-9_-]+)/accounts', [ 'methods' => 'GET', 'callback' => __NAMESPACE__ . '\\rest_list_accounts', 'permission_callback' => __NAMESPACE__ . '\\can_manage', + 'args' => $provider_arg, ] ); - // Start OAuth flow (returns authorize URL). register_rest_route( $namespace, - '/authorize', + '/(?P[a-z0-9_-]+)/authorize', [ 'methods' => 'GET', 'callback' => __NAMESPACE__ . '\\rest_start_oauth', 'permission_callback' => __NAMESPACE__ . '\\can_manage', + 'args' => array_merge( + $provider_arg, + [ + 'login_hint' => [ + 'required' => false, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_email', + ], + 'login_method' => [ + 'required' => false, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'enum' => [ 'sso', 'magic_link', 'google' ], + ], + ] + ), + ] + ); + + register_rest_route( + $namespace, + '/(?P[a-z0-9_-]+)/exchange', + [ + 'methods' => 'POST', + 'callback' => __NAMESPACE__ . '\\rest_exchange_code', + 'permission_callback' => __NAMESPACE__ . '\\can_manage', + 'args' => array_merge( + $provider_arg, + [ + 'code' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'state' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'email' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_email', + ], + ] + ), + ] + ); + + register_rest_route( + $namespace, + '/(?P[a-z0-9_-]+)/manual', + [ + 'methods' => 'POST', + 'callback' => __NAMESPACE__ . '\\rest_add_manual', + 'permission_callback' => __NAMESPACE__ . '\\can_manage', + 'args' => array_merge( + $provider_arg, + [ + 'access_token' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'refresh_token' => [ + 'required' => false, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'email' => [ + 'required' => false, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'expires_in' => [ + 'required' => false, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ], + ] + ), + ] + ); + + register_rest_route( + $namespace, + '/(?P[a-z0-9_-]+)/accounts/remove', + [ + 'methods' => 'POST', + 'callback' => __NAMESPACE__ . '\\rest_remove_account', + 'permission_callback' => __NAMESPACE__ . '\\can_manage', + 'args' => array_merge( + $provider_arg, + [ + 'email' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ], + ] + ), + ] + ); + + register_rest_route( + $namespace, + '/(?P[a-z0-9_-]+)/accounts/refresh', + [ + 'methods' => 'POST', + 'callback' => __NAMESPACE__ . '\\rest_refresh_account', + 'permission_callback' => __NAMESPACE__ . '\\can_manage', + 'args' => array_merge( + $provider_arg, + [ + 'email' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ], + ] + ), + ] + ); + + register_rest_route( + $namespace, + '/(?P[a-z0-9_-]+)/health', + [ + 'methods' => 'GET', + 'callback' => __NAMESPACE__ . '\\rest_health_check', + 'permission_callback' => __NAMESPACE__ . '\\can_manage', + 'args' => $provider_arg, + ] + ); + + // ----------------------------------------------------------------- + // Legacy routes (1.0/1.1) — Anthropic only. + // + // These delegate to the per-provider handlers with provider=anthropic + // so existing clients keep working unchanged. + // ----------------------------------------------------------------- + register_rest_route( + $namespace, + '/accounts', + [ + 'methods' => 'GET', + 'callback' => __NAMESPACE__ . '\\rest_legacy_list_accounts', + 'permission_callback' => __NAMESPACE__ . '\\can_manage', + ] + ); + + register_rest_route( + $namespace, + '/authorize', + [ + 'methods' => 'GET', + 'callback' => __NAMESPACE__ . '\\rest_legacy_start_oauth', + 'permission_callback' => __NAMESPACE__ . '\\can_manage', 'args' => [ 'login_hint' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_email', - 'description' => 'Pre-populate the email field on the Anthropic login page.', ], 'login_method' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'enum' => [ 'sso', 'magic_link', 'google' ], - 'description' => 'Request a specific login method.', ], 'org_uuid' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', - 'description' => 'Pre-select an org UUID for team/enterprise logins.', ], ], ] ); - // Exchange authorization code for tokens. register_rest_route( $namespace, '/exchange', [ 'methods' => 'POST', - 'callback' => __NAMESPACE__ . '\\rest_exchange_code', + 'callback' => __NAMESPACE__ . '\\rest_legacy_exchange_code', 'permission_callback' => __NAMESPACE__ . '\\can_manage', 'args' => [ 'code' => [ @@ -95,17 +297,12 @@ function register_routes(): void { ] ); - // Remove an account. - // Uses POST instead of DELETE because many server configurations (Apache - // mod_security, reverse proxies, multisite rewrites) silently convert or - // block DELETE requests, causing a 404 at the routing level. - // The email is passed in the JSON body to avoid encoding @ in the URL path. register_rest_route( $namespace, '/accounts/remove', [ 'methods' => 'POST', - 'callback' => __NAMESPACE__ . '\\rest_remove_account', + 'callback' => __NAMESPACE__ . '\\rest_legacy_remove_account', 'permission_callback' => __NAMESPACE__ . '\\can_manage', 'args' => [ 'email' => [ @@ -117,14 +314,12 @@ function register_routes(): void { ] ); - // Refresh a specific account's token. - // Email in the JSON body (not the URL path) to avoid encoding @ in the URL. register_rest_route( $namespace, '/accounts/refresh', [ 'methods' => 'POST', - 'callback' => __NAMESPACE__ . '\\rest_refresh_account', + 'callback' => __NAMESPACE__ . '\\rest_legacy_refresh_account', 'permission_callback' => __NAMESPACE__ . '\\can_manage', 'args' => [ 'email' => [ @@ -136,58 +331,384 @@ function register_routes(): void { ] ); - // Health check all accounts. register_rest_route( $namespace, '/health', [ 'methods' => 'GET', - 'callback' => __NAMESPACE__ . '\\rest_health_check', + 'callback' => __NAMESPACE__ . '\\rest_legacy_health_check', + 'permission_callback' => __NAMESPACE__ . '\\can_manage', + ] + ); + + // ----------------------------------------------------------------- + // Device-code flow routes (per-provider, currently OpenAI only). + // ----------------------------------------------------------------- + + register_rest_route( + $namespace, + '/(?P[a-z0-9_-]+)/device-start', + [ + 'methods' => 'POST', + 'callback' => __NAMESPACE__ . '\\rest_device_start', + 'permission_callback' => __NAMESPACE__ . '\\can_manage', + 'args' => $provider_arg, + ] + ); + + register_rest_route( + $namespace, + '/(?P[a-z0-9_-]+)/device-poll', + [ + 'methods' => 'POST', + 'callback' => __NAMESPACE__ . '\\rest_device_poll', 'permission_callback' => __NAMESPACE__ . '\\can_manage', + 'args' => array_merge( + $provider_arg, + [ + 'session_key' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'email' => [ + 'required' => false, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_email', + ], + ] + ), ] ); } /** * Permission callback: requires manage_options capability. - * - * @return bool */ function can_manage(): bool { return current_user_can( 'manage_options' ); } /** - * Lists all accounts in the pool (no tokens exposed). + * Resolves the pool for the {provider} URL parameter, or returns a WP_Error. * - * @return \WP_REST_Response + * @param \WP_REST_Request $request + * @return ProviderPool|\WP_Error + */ +function resolve_pool( \WP_REST_Request $request ) { + $id = (string) $request->get_param( 'provider' ); + if ( ! in_array( $id, PoolRegistry::supportedIds(), true ) ) { + return new \WP_Error( + 'unknown_provider', + sprintf( + /* translators: %s: provider id */ + __( 'Unknown provider "%s".', 'ai-provider-for-anthropic-max' ), + $id + ), + [ 'status' => 404 ] + ); + } + return PoolRegistry::pool( $id ); +} + +// --------------------------------------------------------------------------- +// Per-provider handlers +// --------------------------------------------------------------------------- + +/** + * Lists all known providers along with their account counts. */ -function rest_list_accounts(): \WP_REST_Response { - $pool = PoolManager::getInstance(); - $accounts = $pool->listAccounts(); - return rest_ensure_response( $accounts ); +function rest_list_providers(): \WP_REST_Response { + $providers = []; + foreach ( PoolRegistry::supportedIds() as $id ) { + $pool = PoolRegistry::pool( $id ); + $cfg = $pool->getConfig(); + $providers[ $id ] = [ + 'id' => $cfg->id, + 'label' => $cfg->label, + 'description' => $cfg->description, + 'count' => $pool->count(), + 'supportsOAuth' => $cfg->supportsOAuth, + ]; + } + return rest_ensure_response( $providers ); } /** - * Starts the OAuth PKCE flow and returns the authorize URL. + * Returns the list of accounts in the requested provider pool. * - * Accepts optional query params: - * - login_hint: pre-populate the email on the Anthropic login page. - * - login_method: request a specific login method ('sso', 'magic_link', 'google'). - * - org_uuid: pre-select an org for team/enterprise logins. + * @param \WP_REST_Request $request REST request; must contain a valid `provider` URL param. + * @return \WP_REST_Response|\WP_Error Account list on success, WP_Error on unknown provider. + */ +function rest_list_accounts( \WP_REST_Request $request ) { + $pool = resolve_pool( $request ); + if ( $pool instanceof \WP_Error ) { + return $pool; + } + return rest_ensure_response( $pool->listAccounts() ); +} + +/** + * Starts the OAuth PKCE flow for the requested provider. + * + * Returns an authorize URL, state token, and redirect URI. Rejects providers + * that do not support OAuth (e.g. Cursor Pro) with a 400 error. * - * @param \WP_REST_Request $request The request object. - * @return \WP_REST_Response + * @param \WP_REST_Request $request REST request; `login_hint` and `login_method` are optional body params. + * @return \WP_REST_Response|\WP_Error Auth URL data on success, WP_Error on unsupported provider or failure. */ -function rest_start_oauth( \WP_REST_Request $request ): \WP_REST_Response { - $pool = PoolManager::getInstance(); - $data = $pool->startOAuthFlow( +function rest_start_oauth( \WP_REST_Request $request ) { + $pool = resolve_pool( $request ); + if ( $pool instanceof \WP_Error ) { + return $pool; + } + if ( ! $pool->getConfig()->supportsOAuth ) { + return new \WP_Error( + 'oauth_unsupported', + sprintf( + /* translators: %s: provider label */ + __( '%s does not support OAuth — use the manual token form instead.', 'ai-provider-for-anthropic-max' ), + $pool->getConfig()->label + ), + [ 'status' => 400 ] + ); + } + + try { + $data = $pool->startOAuthFlow( + $request->get_param( 'login_hint' ) ?: null, + $request->get_param( 'login_method' ) ?: null + ); + } catch ( \Throwable $e ) { + return new \WP_Error( 'oauth_start_failed', $e->getMessage(), [ 'status' => 500 ] ); + } + + return rest_ensure_response( [ + 'authorize_url' => $data['authorize_url'], + 'state' => $data['state'], + 'redirect_uri' => $pool->getConfig()->redirectUri, + ] ); +} + +/** + * Exchanges an OAuth authorization code for tokens and adds the account to the pool. + * + * Accepts the raw authorization code, the state token returned by the provider, + * and the user email. On Google/OOB flows the code is also accepted as a full + * redirect URL (ProviderPool::exchangeCode() strips it internally). + * + * @param \WP_REST_Request $request Body params: code (string), state (string), email (string). + * @return \WP_REST_Response|\WP_Error Success response with count, or WP_Error on failure. + */ +function rest_exchange_code( \WP_REST_Request $request ) { + $pool = resolve_pool( $request ); + if ( $pool instanceof \WP_Error ) { + return $pool; + } + + $code = $request->get_param( 'code' ); + $state = $request->get_param( 'state' ); + $email = $request->get_param( 'email' ); + + if ( empty( $code ) || empty( $state ) || empty( $email ) ) { + return new \WP_Error( + 'missing_params', + __( 'Code, state, and email are required.', 'ai-provider-for-anthropic-max' ), + [ 'status' => 400 ] + ); + } + + $result = $pool->exchangeCode( $code, $state, $email ); + if ( $result === null ) { + $detail = $pool->getLastError(); + return new \WP_Error( + 'exchange_failed', + $detail !== null ? $detail : __( 'Failed to exchange authorization code. The code may be expired or the state is invalid.', 'ai-provider-for-anthropic-max' ), + [ 'status' => 400 ] + ); + } + + if ( ! empty( $result['scope_error'] ) ) { + $granted = implode( ' ', (array) ( $result['granted_scopes'] ?? [] ) ); + return new \WP_Error( + 'insufficient_scope', + sprintf( + /* translators: %s: granted scope list */ + __( 'Authorization succeeded but the token is missing a required scope. Granted: %s', 'ai-provider-for-anthropic-max' ), + $granted ?: __( '(none)', 'ai-provider-for-anthropic-max' ) + ), + [ 'status' => 403 ] + ); + } + + return rest_ensure_response( [ + 'success' => true, + 'message' => sprintf( + /* translators: 1: email, 2: provider label */ + __( 'Account %1$s added to %2$s pool.', 'ai-provider-for-anthropic-max' ), + $email, + $pool->getConfig()->label + ), + 'count' => $pool->count(), + ] ); +} + +/** + * Adds an account to a pool using a manually pasted access token (and optional refresh token). + * + * Intended for providers without a public OAuth client (e.g. Cursor Pro). The email + * address may be derived automatically from a JWT `sub` claim when not supplied. + * + * @param \WP_REST_Request $request Body params: access_token (string, required), refresh_token (string), + * email (string), expires_in (int). + * @return \WP_REST_Response|\WP_Error Success response with email and count, or WP_Error on missing token. + */ +function rest_add_manual( \WP_REST_Request $request ) { + $pool = resolve_pool( $request ); + if ( $pool instanceof \WP_Error ) { + return $pool; + } + + $access = (string) $request->get_param( 'access_token' ); + $refresh = (string) ( $request->get_param( 'refresh_token' ) ?? '' ); + $email = (string) ( $request->get_param( 'email' ) ?? '' ); + $exp = $request->get_param( 'expires_in' ); + $exp_in = is_numeric( $exp ) ? (int) $exp : null; + + if ( $access === '' ) { + return new \WP_Error( + 'missing_params', + __( 'access_token is required.', 'ai-provider-for-anthropic-max' ), + [ 'status' => 400 ] + ); + } + + $res = $pool->addAccountManual( $email, $access, $refresh, $exp_in ); + + return rest_ensure_response( [ + 'success' => true, + 'message' => sprintf( + /* translators: 1: email, 2: provider label */ + __( 'Account %1$s added to %2$s pool.', 'ai-provider-for-anthropic-max' ), + $res['email'], + $pool->getConfig()->label + ), + 'email' => $res['email'], + 'count' => $res['count'], + ] ); +} + +/** + * Removes an account from the requested provider pool by email address. + * + * @param \WP_REST_Request $request Body param: email (string, required). + * @return \WP_REST_Response|\WP_Error Success with updated count, or WP_Error (404 not found, 500 save error). + */ +function rest_remove_account( \WP_REST_Request $request ) { + $pool = resolve_pool( $request ); + if ( $pool instanceof \WP_Error ) { + return $pool; + } + + $email = (string) $request->get_param( 'email' ); + $result = $pool->removeAccount( $email ); + + if ( $result === ProviderPool::REMOVE_NOT_FOUND ) { + return new \WP_Error( + 'not_found', + __( 'Account not found in pool.', 'ai-provider-for-anthropic-max' ), + [ 'status' => 404 ] + ); + } + if ( $result === ProviderPool::REMOVE_SAVE_ERROR ) { + return new \WP_Error( + 'save_failed', + __( 'Account removed but could not be saved.', 'ai-provider-for-anthropic-max' ), + [ 'status' => 500 ] + ); + } + + return rest_ensure_response( [ 'success' => true, 'count' => $pool->count() ] ); +} + +/** + * Refreshes the OAuth token for an account in the requested provider pool. + * + * Requires a refresh token to have been stored when the account was added. + * Returns 500 if no refresh token is available or the provider rejects it. + * + * @param \WP_REST_Request $request Body param: email (string, required). + * @return \WP_REST_Response|\WP_Error Success message, or WP_Error on failure. + */ +function rest_refresh_account( \WP_REST_Request $request ) { + $pool = resolve_pool( $request ); + if ( $pool instanceof \WP_Error ) { + return $pool; + } + + $email = (string) $request->get_param( 'email' ); + if ( ! $pool->refreshAccount( $email ) ) { + return new \WP_Error( + 'refresh_failed', + __( 'Token refresh failed. The account may need to be re-authorized.', 'ai-provider-for-anthropic-max' ), + [ 'status' => 500 ] + ); + } + + return rest_ensure_response( [ + 'success' => true, + 'message' => sprintf( + /* translators: %s: email address */ + __( 'Token refreshed for %s.', 'ai-provider-for-anthropic-max' ), + $email + ), + ] ); +} + +/** + * Returns health check data for all accounts in the requested provider pool. + * + * @param \WP_REST_Request $request REST request with valid `provider` URL param. + * @return \WP_REST_Response|\WP_Error Health data array, or WP_Error on unknown provider. + */ +function rest_health_check( \WP_REST_Request $request ) { + $pool = resolve_pool( $request ); + if ( $pool instanceof \WP_Error ) { + return $pool; + } + return rest_ensure_response( $pool->healthCheck() ); +} + +// --------------------------------------------------------------------------- +// Legacy handlers (Anthropic only) +// --------------------------------------------------------------------------- + +/** + * Legacy handler: lists accounts in the Anthropic Max pool. + * + * Preserved for backward compatibility with 1.0/1.1 callers on the + * `/anthropic-max-pool/v1/accounts` route. + * + * @return \WP_REST_Response Serialized account list. + */ +function rest_legacy_list_accounts(): \WP_REST_Response { + return rest_ensure_response( PoolManager::getInstance()->listAccounts() ); +} + +/** + * Legacy handler: starts the Anthropic OAuth PKCE flow. + * + * Preserved for backward compatibility on `/anthropic-max-pool/v1/authorize`. + * + * @param \WP_REST_Request $request Optional body params: login_hint, login_method, org_uuid. + * @return \WP_REST_Response Authorize URL and state token. + */ +function rest_legacy_start_oauth( \WP_REST_Request $request ): \WP_REST_Response { + $data = PoolManager::getInstance()->startOAuthFlow( $request->get_param( 'login_hint' ) ?: null, $request->get_param( 'login_method' ) ?: null, $request->get_param( 'org_uuid' ) ?: null ); - - // Only return the authorize URL and state (never the verifier). return rest_ensure_response( [ 'authorize_url' => $data['authorize_url'], 'state' => $data['state'], @@ -195,12 +716,14 @@ function rest_start_oauth( \WP_REST_Request $request ): \WP_REST_Response { } /** - * Exchanges an authorization code for tokens and adds the account to the pool. + * Legacy handler: exchanges an authorization code for the Anthropic pool. * - * @param \WP_REST_Request $request The request object. - * @return \WP_REST_Response|\WP_Error + * Preserved for backward compatibility on `/anthropic-max-pool/v1/exchange`. + * + * @param \WP_REST_Request $request Body params: code, state, email (all required strings). + * @return \WP_REST_Response|\WP_Error Success response with count, or WP_Error on failure. */ -function rest_exchange_code( \WP_REST_Request $request ) { +function rest_legacy_exchange_code( \WP_REST_Request $request ) { $code = $request->get_param( 'code' ); $state = $request->get_param( 'state' ); $email = $request->get_param( 'email' ); @@ -217,16 +740,14 @@ function rest_exchange_code( \WP_REST_Request $request ) { $result = $pool->exchangeCode( $code, $state, $email ); if ( $result === null ) { + $detail = $pool->getLastError(); return new \WP_Error( 'exchange_failed', - __( 'Failed to exchange authorization code. The code may be expired or the state is invalid.', 'ai-provider-for-anthropic-max' ), + $detail !== null ? $detail : __( 'Failed to exchange authorization code. The code may be expired or the state is invalid.', 'ai-provider-for-anthropic-max' ), [ 'status' => 400 ] ); } - // Scope validation failure: the token was issued but is missing user:inference. - // The account was NOT added to the pool. Return a clear error so the UI can - // prompt the user to re-authorize with the correct scopes. if ( ! empty( $result['scope_error'] ) ) { $granted = implode( ' ', (array) ( $result['granted_scopes'] ?? [] ) ); return new \WP_Error( @@ -252,17 +773,15 @@ function rest_exchange_code( \WP_REST_Request $request ) { } /** - * Removes an account from the pool. + * Legacy handler: removes an account from the Anthropic pool. * - * Maps PoolManager::REMOVE_NOT_FOUND → 404, REMOVE_SAVE_ERROR → 500, - * and REMOVE_OK → 200 so callers can distinguish "account did not exist" - * from "account was removed but the database write failed". + * Preserved for backward compatibility on `/anthropic-max-pool/v1/accounts/remove`. * - * @param \WP_REST_Request $request The request object. - * @return \WP_REST_Response|\WP_Error + * @param \WP_REST_Request $request Body param: email (string, required). + * @return \WP_REST_Response|\WP_Error Success with count, or WP_Error (404/500). */ -function rest_remove_account( \WP_REST_Request $request ) { - $email = $request->get_param( 'email' ); +function rest_legacy_remove_account( \WP_REST_Request $request ) { + $email = (string) $request->get_param( 'email' ); $pool = PoolManager::getInstance(); $result = $pool->removeAccount( $email ); @@ -273,7 +792,6 @@ function rest_remove_account( \WP_REST_Request $request ) { [ 'status' => 404 ] ); } - if ( $result === PoolManager::REMOVE_SAVE_ERROR ) { return new \WP_Error( 'save_failed', @@ -282,22 +800,20 @@ function rest_remove_account( \WP_REST_Request $request ) { ); } - return rest_ensure_response( [ - 'success' => true, - 'count' => $pool->count(), - ] ); + return rest_ensure_response( [ 'success' => true, 'count' => $pool->count() ] ); } /** - * Refreshes a specific account's OAuth token. + * Legacy handler: refreshes an Anthropic account token. * - * @param \WP_REST_Request $request The request object. - * @return \WP_REST_Response|\WP_Error + * Preserved for backward compatibility on `/anthropic-max-pool/v1/accounts/refresh`. + * + * @param \WP_REST_Request $request Body param: email (string, required). + * @return \WP_REST_Response|\WP_Error Success message, or WP_Error on failure. */ -function rest_refresh_account( \WP_REST_Request $request ) { - $email = $request->get_param( 'email' ); +function rest_legacy_refresh_account( \WP_REST_Request $request ) { + $email = (string) $request->get_param( 'email' ); $pool = PoolManager::getInstance(); - if ( ! $pool->refreshAccount( $email ) ) { return new \WP_Error( 'refresh_failed', @@ -305,7 +821,6 @@ function rest_refresh_account( \WP_REST_Request $request ) { [ 'status' => 500 ] ); } - return rest_ensure_response( [ 'success' => true, 'message' => sprintf( @@ -317,12 +832,74 @@ function rest_refresh_account( \WP_REST_Request $request ) { } /** - * Health checks all accounts in the pool. + * Legacy handler: returns health check data for the Anthropic pool. + * + * Preserved for backward compatibility on `/anthropic-max-pool/v1/health`. + * + * @return \WP_REST_Response Serialized health data array. + */ +function rest_legacy_health_check(): \WP_REST_Response { + return rest_ensure_response( PoolManager::getInstance()->healthCheck() ); +} + +/** + * Starts a device-code OAuth flow for the given provider. * - * @return \WP_REST_Response + * Returns the user code, verification URL, session key, poll interval, + * and expiry the JS needs to run the polling loop. + * + * @param \WP_REST_Request $request URL param: provider (string). + * @return \WP_REST_Response|\WP_Error */ -function rest_health_check(): \WP_REST_Response { - $pool = PoolManager::getInstance(); - $results = $pool->healthCheck(); - return rest_ensure_response( $results ); +function rest_device_start( \WP_REST_Request $request ) { + $pool = resolve_pool( $request ); + if ( is_wp_error( $pool ) ) { + return $pool; + } + + $result = $pool->startDeviceCode(); + if ( $result === null ) { + return new \WP_Error( + 'device_start_failed', + $pool->getLastError() ?? __( 'Failed to start device-code flow.', 'ai-provider-for-anthropic-max' ), + [ 'status' => 502 ] + ); + } + + return rest_ensure_response( $result ); +} + +/** + * Polls once for device-code authorization, exchanges and saves the account + * on success. + * + * The JS side calls this on each interval tick. Returns: + * {status: 'pending'} — still waiting for user + * {status: 'complete', email} — authorized, account saved + * {status: 'expired', message} — session timed out + * WP_Error — hard error (network, bad exchange, etc.) + * + * @param \WP_REST_Request $request URL param: provider; body params: session_key, email (optional). + * @return \WP_REST_Response|\WP_Error + */ +function rest_device_poll( \WP_REST_Request $request ) { + $pool = resolve_pool( $request ); + if ( is_wp_error( $pool ) ) { + return $pool; + } + + $session_key = (string) $request->get_param( 'session_key' ); + $email = (string) ( $request->get_param( 'email' ) ?? '' ); + + $result = $pool->pollDeviceCode( $session_key, $email ); + + if ( $result['status'] === 'error' ) { + return new \WP_Error( + 'device_poll_failed', + $result['message'] ?? __( 'Device code poll failed.', 'ai-provider-for-anthropic-max' ), + [ 'status' => 502 ] + ); + } + + return rest_ensure_response( $result ); } diff --git a/js/connector.jsx b/js/connector.jsx index 1b5e523..bba8d6b 100644 --- a/js/connector.jsx +++ b/js/connector.jsx @@ -1,9 +1,16 @@ /** * AI Provider for Anthropic Max -- Connectors page integration. * - * Registers a card on Settings > Connectors that lets admins manage - * the Anthropic Max OAuth account pool: add, remove, refresh, and - * health-check accounts used for Claude Max subscriptions. + * Registers connector cards on Settings > Connectors for each supported + * subscription plan provider: + * + * - Anthropic Max (OAuth PKCE + paste-code) + * - OpenAI ChatGPT/Codex (OAuth PKCE + paste-code from localhost callback) + * - Cursor Pro (manual token paste — Cursor has no public OAuth) + * - Google AI Pro (OAuth PKCE OOB + paste-code) + * + * Each card uses the same generic add/list/remove/refresh UI, configured + * by a per-provider config object (mode, label, icon, REST namespace). * * @package AnthropicMaxAiProvider */ @@ -17,6 +24,7 @@ const { createElement, useState, useEffect, useCallback, Fragment } = wp.element const { Button, TextControl, + TextareaControl, Spinner, Notice, __experimentalHStack: HStack, @@ -27,9 +35,182 @@ const { __ } = wp.i18n; const apiFetch = wp.apiFetch; /** - * Anthropic logo icon. + * Lazily resolves the @wordpress/notices store dispatch. + * + * Returns no-op functions if the notices store is not yet loaded when a + * dispatch fires (e.g. during early module boot), so callers never crash. + * + * @return {{createSuccessNotice:Function, createErrorNotice:Function}} */ -function Logo() { +function resolveNoticeDispatchers() { + const store = wp.notices && wp.notices.store; + const dispatch = wp.data && wp.data.dispatch; + if ( ! store || ! dispatch ) { + return { + createSuccessNotice: () => {}, + createErrorNotice: () => {}, + }; + } + const actions = dispatch( store ); + return { + createSuccessNotice: actions.createSuccessNotice.bind( actions ), + createErrorNotice: actions.createErrorNotice.bind( actions ), + }; +} + +const SNACKBAR_OPTS = { type: 'snackbar' }; + +const REST_NS = '/anthropic-max-pool/v1'; + +/** + * Extracts a human-readable message from an apiFetch rejection. + * + * WordPress `wp.apiFetch` rejects with a plain object shaped like + * `{ code, message, data }` (the WP_Error JSON envelope), not an Error + * instance. Using `err instanceof Error` therefore drops the real + * server-side message; this helper unwraps it correctly. + * + * @param {unknown} err The rejection value from apiFetch. + * @param {string} fallback Localised fallback when no message is present. + * @return {string} The best available error message. + */ +function apiFetchErrorMessage( err, fallback ) { + let raw = fallback; + if ( err && typeof err.message === 'string' && err.message ) { + raw = err.message; + } else if ( err instanceof Error && err.message ) { + raw = err.message; + } + return sanitizeErrorMessage( raw, fallback ); +} + +/** + * Defensive guard so the UI never renders a raw HTML/Cloudflare body even if + * a backend forgets to wrap it. If the message begins with markup, replaces + * it with a generic operator-friendly notice; otherwise truncates very long + * strings to keep the admin layout sane. + * + * @param {string} msg Raw message to clean. + * @param {string} fallback Localised fallback used when msg is empty. + * @return {string} + */ +function sanitizeErrorMessage( msg, fallback ) { + if ( typeof msg !== 'string' || msg === '' ) { + return fallback; + } + const head = msg.trimStart().slice( 0, 256 ).toLowerCase(); + if ( + head.startsWith( 'just a moment' ) + ) { + return __( + 'The upstream provider returned an HTML challenge page (likely Cloudflare bot protection). Please wait a minute and try again.' + ); + } + return msg.length > 300 ? `${ msg.slice( 0, 300 ) }…` : msg; +} + +// --------------------------------------------------------------------------- +// Per-provider client config. +// Mirrors src/OAuthPool/ProviderConfig.php. +// --------------------------------------------------------------------------- +const PROVIDERS = { + anthropic: { + id: 'anthropic', + slug: 'ultimate-ai-connector-anthropic-max', + label: __( 'Anthropic Max' ), + description: __( + 'Use Claude with your Max subscription via OAuth. Supports account pool rotation for reliability.' + ), + mode: 'oauth-paste', + emailRequired: true, + instructions: __( + 'A new window opened for Claude authorization. Log in, then copy the authorization code shown and paste it below.' + ), + iconText: 'MAX', + }, + openai: { + id: 'openai', + slug: 'ultimate-ai-connector-chatgpt-codex', + label: __( 'OpenAI ChatGPT (Codex)' ), + description: __( + 'ChatGPT Plus/Pro/Team via OAuth — unlocks GPT-5.4 mini, GPT-5.4, GPT-5.5, GPT-5.2 through the Codex backend. No localhost server required.' + ), + mode: 'device-code', + iconText: 'GPT', + }, + cursor: { + id: 'cursor', + slug: 'ultimate-ai-connector-cursor-pro', + label: __( 'Cursor Pro' ), + description: __( + 'Use Cursor Pro tokens. Cursor has no public OAuth — paste your access/refresh tokens from the IDE.' + ), + mode: 'manual-token', + emailRequired: false, + instructions: __( + 'Find your tokens in Cursor IDE: ~/.cursor/auth.json (Linux/macOS) or %APPDATA%/Cursor/auth.json (Windows). Email is auto-derived from the token.' + ), + iconText: 'CUR', + }, + google: { + id: 'google', + slug: 'ultimate-ai-connector-google-ai-pro', + label: __( 'Google AI Pro' ), + description: __( + 'Use Google AI Pro/Ultra or Workspace Gemini via OAuth. Paste the OOB code shown by Google.' + ), + mode: 'oauth-paste', + emailRequired: true, + instructions: __( + 'After signing in, Google shows a code in the browser. Copy and paste it below.' + ), + iconText: 'GAI', + }, +}; + +// --------------------------------------------------------------------------- +// Shared UI primitives. +// --------------------------------------------------------------------------- + +/** + * Renders a transient error banner without using @wordpress/components Notice. + * + * Inline Notice instances that mount/unmount during state transitions can + * trip a deps-array race inside Notice.useSpokenMessage's useEffect on + * re-render, surfacing as "Cannot read properties of undefined (reading + * 'length')" inside React reconciler. Using a plain styled
with + * role="alert" avoids that hook entirely and matches the WP-core pattern + * of preferring the @wordpress/notices store for transient feedback. + * + * @param {{message: string}} props + * @return {JSX.Element|null} + */ +function InlineErrorBanner( { message } ) { + if ( ! message ) { + return null; + } + return ( +
+ { message } +
+ ); +} + +function ProviderLogo( { text } ) { + const safeText = typeof text === 'string' ? text : ''; return ( 3 ? 11 : 14 } fontWeight="800" letterSpacing="0.5" fill="currentColor" > - MAX + { safeText } ); } -/** - * Green "Connected" badge. - */ function ConnectedBadge( { count } ) { return ( { account.email } - + { account.tokenExpired ? __( 'expired' ) @@ -140,14 +307,16 @@ function AccountRow( { account, onRemove, onRefresh, isBusy } ) { __( 'remaining' ) : '' } - + { providerSupportsRefresh && ( + + ) } - - - ) : ( - - - { __( - 'A new window opened for Claude authorization. Log in, then copy the authorization code shown and paste it below.' - ) } - - { ! showManualLink && authorizeUrl && ( - ) } - - ) } + + @@ -427,23 +545,394 @@ function AddAccountForm( { onComplete, onCancel } ) { ); } +// --------------------------------------------------------------------------- +// Manual-token add form (cursor, or any provider as fallback). +// --------------------------------------------------------------------------- + +function ManualTokenAddForm( { providerCfg, onComplete, onCancel } ) { + const { id, label, instructions } = providerCfg; + const [ accessToken, setAccessToken ] = useState( '' ); + const [ refreshToken, setRefreshToken ] = useState( '' ); + const [ email, setEmail ] = useState( '' ); + const [ isBusy, setIsBusy ] = useState( false ); + const [ inlineError, setInlineError ] = useState( '' ); + + const handleSubmit = async () => { + if ( ! accessToken.trim() ) { + setInlineError( __( 'Paste the access token.' ) ); + return; + } + setInlineError( '' ); + setIsBusy( true ); + try { + await apiFetch( { + method: 'POST', + path: `${ REST_NS }/${ id }/manual`, + data: { + access_token: accessToken.trim(), + refresh_token: refreshToken.trim(), + email: email.trim(), + }, + } ); + const { createSuccessNotice } = resolveNoticeDispatchers(); + createSuccessNotice( + sprintf__( '%s account connected.', label ), + { ...SNACKBAR_OPTS, id: `${ id }-add-success` } + ); + onComplete(); + } catch ( err ) { + setInlineError( apiFetchErrorMessage( err, __( 'Failed to add account.' ) ) ); + } finally { + setIsBusy( false ); + } + }; + + const instructionsText = + typeof instructions === 'string' ? instructions : ''; + + return ( + + { inlineError && ( + + ) } + + { instructionsText } + + + + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Device-code add form (OpenAI — no localhost binding needed). +// --------------------------------------------------------------------------- + /** - * Main connector card component. + * Formats milliseconds as M:SS for the countdown display. + * + * @param {number} ms Remaining milliseconds. + * @return {string} */ -function AnthropicMaxConnectorCard( { slug, label, description, logo } ) { +function formatCountdown( ms ) { + const totalSecs = Math.max( 0, Math.floor( ms / 1000 ) ); + const m = Math.floor( totalSecs / 60 ); + const s = String( totalSecs % 60 ).padStart( 2, '0' ); + return `${ m }:${ s }`; +} + +function DeviceCodeForm( { providerCfg, onComplete, onCancel } ) { + const { id } = providerCfg; + + // phase: 'loading' | 'waiting' | 'success' | 'expired' | 'error' + const [ phase, setPhase ] = useState( 'loading' ); + const [ userCode, setUserCode ] = useState( '' ); + const [ sessionKey, setSessionKey ] = useState( '' ); + const [ intervalMs, setIntervalMs ] = useState( 5000 ); + const [ expiresAt, setExpiresAt ] = useState( 0 ); + const [ timeLeft, setTimeLeft ] = useState( 0 ); + const [ errorMsg, setErrorMsg ] = useState( '' ); + const [ completedEmail, setCompletedEmail ] = useState( '' ); + const [ copied, setCopied ] = useState( false ); + + const VERIFICATION_URL = 'https://auth.openai.com/codex/device'; + + // Start device-code session on mount. + useEffect( () => { + let cancelled = false; + apiFetch( { + method: 'POST', + path: `${ REST_NS }/${ id }/device-start`, + } ) + .then( ( data ) => { + if ( cancelled ) return; + setUserCode( data.user_code ); + setSessionKey( data.session_key ); + setIntervalMs( data.interval_ms || 5000 ); + const exp = Date.now() + ( data.expires_in_ms || 900000 ); + setExpiresAt( exp ); + setTimeLeft( data.expires_in_ms || 900000 ); + setPhase( 'waiting' ); + } ) + .catch( ( err ) => { + if ( cancelled ) return; + setErrorMsg( apiFetchErrorMessage( err, __( 'Failed to start device-code flow.' ) ) ); + setPhase( 'error' ); + } ); + return () => { cancelled = true; }; + }, [ id ] ); + + // Countdown ticker (1-second interval while waiting). + useEffect( () => { + if ( phase !== 'waiting' || expiresAt === 0 ) return; + const tick = setInterval( () => { + const remaining = expiresAt - Date.now(); + if ( remaining <= 0 ) { + setTimeLeft( 0 ); + setPhase( 'expired' ); + clearInterval( tick ); + } else { + setTimeLeft( remaining ); + } + }, 1000 ); + return () => clearInterval( tick ); + }, [ phase, expiresAt ] ); + + // Polling loop — fires every intervalMs while waiting. + useEffect( () => { + if ( phase !== 'waiting' || ! sessionKey ) return; + const poll = setInterval( () => { + apiFetch( { + method: 'POST', + path: `${ REST_NS }/${ id }/device-poll`, + data: { session_key: sessionKey }, + } ) + .then( ( res ) => { + if ( res.status === 'complete' ) { + setCompletedEmail( res.email || '' ); + setPhase( 'success' ); + } else if ( res.status === 'expired' ) { + setPhase( 'expired' ); + } + // 'pending' → do nothing, keep polling. + } ) + .catch( ( err ) => { + setErrorMsg( apiFetchErrorMessage( err, __( 'Polling failed.' ) ) ); + setPhase( 'error' ); + } ); + }, intervalMs ); + return () => clearInterval( poll ); + }, [ phase, sessionKey, intervalMs, id ] ); + + // On success, dispatch a snackbar via the global notices store and call + // onComplete after a short delay so the account list refreshes. The + // snackbar renders outside this component's tree (via wp.notices's + // SnackbarNotices), avoiding any inline Notice that would otherwise + // mount during the unmount cascade and trip Notice.useSpokenMessage. + useEffect( () => { + if ( phase !== 'success' ) return; + const { createSuccessNotice } = resolveNoticeDispatchers(); + const msg = completedEmail + ? sprintf__( 'Connected: %s', completedEmail ) + : __( 'Account connected successfully.' ); + createSuccessNotice( msg, { + ...SNACKBAR_OPTS, + id: `${ id }-device-code-success`, + } ); + const t = setTimeout( onComplete, 1500 ); + return () => clearTimeout( t ); + }, [ phase, onComplete, completedEmail, id ] ); + + const handleCopyCode = () => { + navigator.clipboard.writeText( userCode ).then( () => { + setCopied( true ); + setTimeout( () => setCopied( false ), 2000 ); + } ); + }; + + if ( phase === 'loading' ) { + return ( + + + { __( 'Requesting device code…' ) } + + ); + } + + if ( phase === 'error' ) { + return ( + + + + + + + + ); + } + + if ( phase === 'expired' ) { + const expiredMsg = __( + 'Device code expired. Click "Start again" to get a new one.' + ); + return ( + + + { expiredMsg } + + + + + + + ); + } + + if ( phase === 'success' ) { + // Success feedback is dispatched via the global notices store + // (see useEffect above) — render nothing inline to avoid mounting + // a Notice during the unmount cascade triggered by onComplete(). + return null; + } + + // phase === 'waiting' + const waitingMsg = __( + 'Open the link below in your browser, enter the code shown, then sign in with your ChatGPT Plus/Pro/Team account. This page will update automatically once authorised.' + ); + return ( + + + + { waitingMsg } + + { VERIFICATION_URL } + + + + + { /* User code — large, monospace, easy to read */ } + + + { __( 'Your device code' ) } + + + + { userCode } + + + + + { __( 'Expires in' ) + ' ' + formatCountdown( timeLeft ) } + + + + + + + { __( 'Waiting for authorisation…' ) } + + + + + ); +} + +// Tiny sprintf shim — avoids pulling in @wordpress/i18n.sprintf. +function sprintf__( fmt, value ) { + return fmt.replace( '%s', value ); +} + +// --------------------------------------------------------------------------- +// Generic provider connector card. +// --------------------------------------------------------------------------- + +function ProviderConnectorCard( { providerCfg } ) { + const { id, label, description, mode, iconText } = providerCfg; const [ accounts, setAccounts ] = useState( [] ); const [ isExpanded, setIsExpanded ] = useState( false ); const [ isAdding, setIsAdding ] = useState( false ); const [ isLoading, setIsLoading ] = useState( true ); const [ isBusy, setIsBusy ] = useState( false ); - const [ notice, setNotice ] = useState( null ); const isConnected = accounts.length > 0; + const supportsRefresh = mode === 'oauth-paste' || mode === 'device-code'; // Manual-token providers can't refresh. const fetchAccounts = useCallback( async () => { try { const data = await apiFetch( { - path: '/anthropic-max-pool/v1/accounts', + path: `${ REST_NS }/${ id }/accounts`, } ); setAccounts( Array.isArray( data ) ? data : [] ); } catch { @@ -451,7 +940,7 @@ function AnthropicMaxConnectorCard( { slug, label, description, logo } ) { } finally { setIsLoading( false ); } - }, [] ); + }, [ id ] ); useEffect( () => { fetchAccounts(); @@ -459,22 +948,23 @@ function AnthropicMaxConnectorCard( { slug, label, description, logo } ) { const handleRemove = async ( email ) => { setIsBusy( true ); - setNotice( null ); + const { createSuccessNotice, createErrorNotice } = resolveNoticeDispatchers(); try { await apiFetch( { method: 'POST', - path: '/anthropic-max-pool/v1/accounts/remove', + path: `${ REST_NS }/${ id }/accounts/remove`, data: { email }, } ); + createSuccessNotice( + sprintf__( '%s account removed.', label ), + { ...SNACKBAR_OPTS, id: `${ id }-remove-success` } + ); await fetchAccounts(); } catch ( err ) { - setNotice( { - status: 'error', - message: - err instanceof Error - ? err.message - : __( 'Failed to remove account.' ), - } ); + createErrorNotice( + apiFetchErrorMessage( err, __( 'Failed to remove account.' ) ), + { ...SNACKBAR_OPTS, id: `${ id }-remove-error` } + ); } finally { setIsBusy( false ); } @@ -482,26 +972,23 @@ function AnthropicMaxConnectorCard( { slug, label, description, logo } ) { const handleRefresh = async ( email ) => { setIsBusy( true ); - setNotice( null ); + const { createSuccessNotice, createErrorNotice } = resolveNoticeDispatchers(); try { await apiFetch( { method: 'POST', - path: '/anthropic-max-pool/v1/accounts/refresh', + path: `${ REST_NS }/${ id }/accounts/refresh`, data: { email }, } ); - setNotice( { - status: 'success', - message: __( 'Token refreshed.' ), + createSuccessNotice( __( 'Token refreshed.' ), { + ...SNACKBAR_OPTS, + id: `${ id }-refresh-success`, } ); await fetchAccounts(); } catch ( err ) { - setNotice( { - status: 'error', - message: - err instanceof Error - ? err.message - : __( 'Refresh failed.' ), - } ); + createErrorNotice( + apiFetchErrorMessage( err, __( 'Refresh failed.' ) ), + { ...SNACKBAR_OPTS, id: `${ id }-refresh-error` } + ); } finally { setIsBusy( false ); } @@ -509,26 +996,32 @@ function AnthropicMaxConnectorCard( { slug, label, description, logo } ) { const handleHealthCheck = async () => { setIsBusy( true ); - setNotice( null ); + const { createSuccessNotice, createErrorNotice } = resolveNoticeDispatchers(); try { - const results = await apiFetch( { - path: '/anthropic-max-pool/v1/health', - } ); - const ok = results.filter( ( r ) => r.validity === 'ok' ).length; - setNotice( { - status: ok === results.length ? 'success' : 'warning', - message: - ok + - '/' + - results.length + - ' ' + - __( 'accounts healthy' ), + const raw = await apiFetch( { + path: `${ REST_NS }/${ id }/health`, } ); + const results = Array.isArray( raw ) ? raw : []; + const total = results.length; + const ok = results.filter( ( r ) => r && r.validity === 'ok' ).length; + const allHealthy = total > 0 && ok === total; + const msg = ok + '/' + total + ' ' + __( 'accounts healthy' ); + if ( allHealthy ) { + createSuccessNotice( msg, { + ...SNACKBAR_OPTS, + id: `${ id }-health-success`, + } ); + } else { + createErrorNotice( msg, { + ...SNACKBAR_OPTS, + id: `${ id }-health-warning`, + } ); + } await fetchAccounts(); } catch { - setNotice( { - status: 'error', - message: __( 'Health check failed.' ), + createErrorNotice( __( 'Health check failed.' ), { + ...SNACKBAR_OPTS, + id: `${ id }-health-error`, } ); } finally { setIsBusy( false ); @@ -538,7 +1031,6 @@ function AnthropicMaxConnectorCard( { slug, label, description, logo } ) { const handleButtonClick = () => { setIsExpanded( ! isExpanded ); setIsAdding( false ); - setNotice( null ); }; const getButtonLabel = () => { @@ -557,12 +1049,8 @@ function AnthropicMaxConnectorCard( { slug, label, description, logo } ) { ) }