diff --git a/extensions/publisher-command-center/app.py b/extensions/publisher-command-center/app.py index d78c05a7..3cad5572 100644 --- a/extensions/publisher-command-center/app.py +++ b/extensions/publisher-command-center/app.py @@ -1,6 +1,6 @@ from http import client import asyncio -from fastapi import FastAPI, Header +from fastapi import FastAPI, Header, Body from fastapi.staticfiles import StaticFiles from posit import connect from posit.connect.errors import ClientError @@ -15,8 +15,9 @@ # Create cache with TTL=1hour and unlimited size client_cache = TTLCache(maxsize=float("inf"), ttl=3600) -@app.get("/api/auth-status") -async def auth_status(posit_connect_user_session_token: str = Header(None)): + +@app.get("/api/visitor-auth") +async def integration_status(posit_connect_user_session_token: str = Header(None)): """ If running on Connect, attempt to build a visitor client. If that raises the 212 error (no OAuth integration), return authorized=False. @@ -30,10 +31,43 @@ async def auth_status(posit_connect_user_session_token: str = Header(None)): except ClientError as err: if err.error_code == 212: return {"authorized": False} - raise # Other errors bubble up + raise return {"authorized": True} + +@app.put("/api/visitor-auth") +async def set_integration(integration_guid: str = Body(..., embed=True)): + if os.getenv("RSTUDIO_PRODUCT") == "CONNECT": + content_guid = os.getenv("CONNECT_CONTENT_GUID") + content = client.content.get(content_guid) + content.oauth.associations.update(integration_guid) + else: + # Raise an error if not running on Connect + raise ClientError( + error_code=400, + message="This endpoint is only available when running on Posit Connect.", + ) + return {"status": "success"} + + +@app.get("/api/integrations") +async def get_integrations(): + integrations = client.oauth.integrations.find() + admin_integrations = [ + i + for i in integrations + if i["template"] == "connect" and i["config"]["max_role"] == "Admin" + ] + publisher_integrations = [ + i + for i in integrations + if i["template"] == "connect" and i["config"]["max_role"] == "Publisher" + ] + eligible_integrations = admin_integrations + publisher_integrations + return eligible_integrations[0] if eligible_integrations else None + + @cached(client_cache) def get_visitor_client(token: str | None) -> connect.Client: """Create and cache API client per token with 1 hour TTL""" @@ -74,6 +108,7 @@ async def get_content_processes( active_jobs = [job for job in content.jobs if job["status"] == 0] return active_jobs + @app.delete("/api/contents/{content_id}") async def delete_content( content_id: str, @@ -84,6 +119,7 @@ async def delete_content( content = visitor.content.get(content_id) content.delete() + @app.delete("/api/contents/{content_id}/processes/{process_id}") async def destroy_process( content_id: str, @@ -102,6 +138,7 @@ async def destroy_process( return await asyncio.sleep(1) + @app.get("/api/contents/{content_id}/author") async def get_author( content_id, diff --git a/extensions/publisher-command-center/manifest.json b/extensions/publisher-command-center/manifest.json index 35d28b79..d4073ce1 100644 --- a/extensions/publisher-command-center/manifest.json +++ b/extensions/publisher-command-center/manifest.json @@ -34,7 +34,7 @@ "checksum": "a162a98758867a701ce693948ffdfa67" }, "app.py": { - "checksum": "55729c283443e02bcfc4233ccf0c2b3d" + "checksum": "22c6a2e23c3e9986f1c80c3ef0f3d8d3" }, "dist/assets/fa-brands-400.808443ae.ttf": { "checksum": "15d54d142da2f2d6f2e90ed1d55121af" diff --git a/extensions/publisher-command-center/src/components/UnauthorizedView.js b/extensions/publisher-command-center/src/components/UnauthorizedView.js new file mode 100644 index 00000000..e3ad4e6c --- /dev/null +++ b/extensions/publisher-command-center/src/components/UnauthorizedView.js @@ -0,0 +1,129 @@ +import m from "mithril"; + +const UnauthorizedView = { + oninit: function (vnode) { + vnode.state.integration = null; + vnode.state.loading = true; + + // Check for available integration + m.request({ method: "GET", url: "api/integrations" }) + .then(response => { + vnode.state.integration = response; + vnode.state.loading = false; + m.redraw(); + }) + .catch(err => { + console.error("Failed to fetch integrations", err); + vnode.state.loading = false; + m.redraw(); + }); + }, + + addIntegration: function (vnode) { + if (!vnode.state.integration) return; + + m.request({ + method: "PUT", + url: "api/visitor-auth", + body: { integration_guid: vnode.state.integration.guid } + }) + .then(() => { + // Reload the top-most window to check authorization again + window.top.location.reload(); + }) + .catch(err => { + console.error("Failed to add integration", err); + }); + }, + + view: function (vnode) { + // Show loading state + if (vnode.state.loading) { + return m("div.d-flex.justify-content-center", { style: { margin: "3rem" } }, [ + m("div.spinner-border.text-primary", { role: "status" }, + m("span.visually-hidden", "Loading...") + ) + ]); + } + + // We have an integration ready to add + if (vnode.state.integration) { + return m("div.alert.alert-info", { + style: { + margin: "1rem auto", + maxWidth: "640px", + width: "calc(100% - 2rem)" + } + }, [ + m("div", { style: { marginBottom: "1rem" } }, [ + m("p", [ + "This content uses a ", + m("strong", "Visitor API Key"), + " integration to show users the content they have access to. A compatible integration is displayed below." + ]), + m("p", [ + "For more information, see ", + m("a", { + href: "https://docs.posit.co/connect/user/oauth-integrations/#obtaining-a-visitor-api-key", + target: "_blank" + }, "documentation on Visitor API Key integrations") + ]) + ]), + m("button.btn.btn-primary", { + onclick: () => this.addIntegration(vnode) + }, [ + m("i.fas.fa-plus.me-2"), + "Add the ", + m("strong", vnode.state.integration.title || vnode.state.integration.name || "Connect API"), + " Integration" + ]) + ]); + } + + // No integration available + const baseUrl = window.location.origin; + const integrationSettingsUrl = `${baseUrl}/connect/#/system/integrations`; + + return m("div.alert.alert-warning", { + style: { + margin: "1rem auto", + maxWidth: "640px", + width: "calc(100% - 2rem)" + } + }, [ + m("div", { style: { marginBottom: "1rem" } }, [ + m("p", "This content needs permission to show users the content they have access to."), + m("p", [ + "To allow this, an Administrator must configure a ", + m("strong", "Connect API"), + " integration on the ", + m("strong", [ + m("a", { href: integrationSettingsUrl, target: "_blank" }, "Integration Settings") + ]), + " page." + ]), + m("p", [ + "On that page, select ", + m("strong", "+ Add Integration"), + ". In the 'Select Integration' dropdown, choose ", + m("strong", "Connect API"), + ". The 'Max Role' field must be set to ", + m("strong", "Administrator"), + " or ", + m("strong", "Publisher"), + "; 'Viewer' will not work." + ]), + m("p", [ + "See the ", + m("a", { + href: "https://docs.posit.co/connect/admin/integrations/oauth-integrations/connect/", + target: "_blank" + }, "Connect API section of the Admin Guide"), + " for more detailed setup instructions." + ]) + ]) + ]); + } +}; + +export default UnauthorizedView; diff --git a/extensions/publisher-command-center/src/index.js b/extensions/publisher-command-center/src/index.js index f8fc06af..8d419948 100644 --- a/extensions/publisher-command-center/src/index.js +++ b/extensions/publisher-command-center/src/index.js @@ -8,38 +8,17 @@ import "../scss/index.scss"; import Home from "./views/Home"; import Edit from "./views/Edit"; -import Layout from './views/Layout' +import Layout from './views/Layout'; +import UnauthorizedView from './components/UnauthorizedView'; const root = document.getElementById("app"); // First ask the server “are we authorized?” -m.request({ method: "GET", url: "api/auth-status" }) +m.request({ method: "GET", url: "api/visitor-auth" }) .then((res) => { if (!res.authorized) { - // Unauthorized → just show the banner, never mount the router - m.mount(root, { - view: () => - m( - "div.alert.alert-info", - { style: { margin: "1rem" } }, - [ - m("p", [ - "To finish setting up this content, you must add a Visitor API Key ", - "integration with the Publisher scope." - ]), - m("p", [ - 'Select "+ Add integration" in the Access settings panel to the ', - 'right, and find an entry with "Authentication type: Visitor API Key".' - ]), - m("p", [ - "If no such integration exists, an Administrator must configure one. ", - "Go to Connect's System page, select the Integrations tab, then ", - 'click "+ Add Integration", choose "Connect API", pick Publisher or ', - "Administrator under Max Role, and give it a descriptive title." - ]) - ] - ), - }); + // Unauthorized → mount our UnauthorizedView component + m.mount(root, UnauthorizedView); } else { // Authorized → wire up routes m.route(root, "/contents", { @@ -53,6 +32,5 @@ m.request({ method: "GET", url: "api/auth-status" }) } }) .catch((err) => { - console.error("failed to fetch auth-status", err); - // you might also render a generic “uh-oh” banner here + console.error("failed to fetch visitor-auth", err); });