From 7a95a8e914f6b6a153ed6b94544588d94a0431a1 Mon Sep 17 00:00:00 2001 From: Toph Allen Date: Wed, 21 May 2025 19:05:07 -0400 Subject: [PATCH 1/5] implement better first deploy flow for publisher command center --- extensions/publisher-command-center/app.py | 46 ++++++++- .../src/components/UnauthorizedView.js | 94 +++++++++++++++++++ .../publisher-command-center/src/index.js | 34 ++----- 3 files changed, 142 insertions(+), 32 deletions(-) create mode 100644 extensions/publisher-command-center/src/components/UnauthorizedView.js diff --git a/extensions/publisher-command-center/app.py b/extensions/publisher-command-center/app.py index d78c05a7..c60ff3a3 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,44 @@ 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(): + print("get_integration()") + 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 +109,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 +120,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 +139,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/src/components/UnauthorizedView.js b/extensions/publisher-command-center/src/components/UnauthorizedView.js new file mode 100644 index 00000000..f5fe7a06 --- /dev/null +++ b/extensions/publisher-command-center/src/components/UnauthorizedView.js @@ -0,0 +1,94 @@ +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: "800px" } }, [ + m("div", { style: { marginBottom: "1rem" } }, [ + m.trust("This content uses a Visitor API Key " + + "integration to show users the content they have access to. " + + "A compatible integration is displayed below." + + "

" + + "For more information, see " + + "documentation on Visitor API Key integrations.") + ]), + m("button.btn.btn-primary", { + onclick: () => this.addIntegration(vnode) + }, [ + m("i.fas.fa-plus.me-2"), + m.trust("Add the '" + (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: "800px" } }, [ + m("div", { style: { marginBottom: "1rem" } }, [ + m.trust("This content needs permission to show users the content they have access to." + + "

" + + "To allow this, an Administrator must configure a " + + "Connect API integration on the " + + "Integration Settings page. " + + "

" + + "On that page, select '+ Add Integration'. " + + "In the 'Select Integration' dropdown, choose 'Connect API'. " + + "The 'Max Role' field must be set to 'Administrator' " + + "or 'Publisher'; 'Viewer' will not work. " + + "

" + + "See the 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); }); From 0d1faa8f7ed2d2d455d8ab5c1994c1c931549d79 Mon Sep 17 00:00:00 2001 From: Toph Allen Date: Wed, 21 May 2025 19:06:12 -0400 Subject: [PATCH 2/5] update manifest --- extensions/publisher-command-center/manifest.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/publisher-command-center/manifest.json b/extensions/publisher-command-center/manifest.json index d4a88b1b..0ab7f874 100644 --- a/extensions/publisher-command-center/manifest.json +++ b/extensions/publisher-command-center/manifest.json @@ -27,14 +27,14 @@ "API Publishing", "OAuth Integrations" ], - "version": "0.0.4" + "version": "0.0.5" }, "files": { "requirements.txt": { "checksum": "a162a98758867a701ce693948ffdfa67" }, "app.py": { - "checksum": "55729c283443e02bcfc4233ccf0c2b3d" + "checksum": "aaadcea0fdb7bfa396bd94ba800edf30" }, "dist/assets/fa-brands-400.808443ae.ttf": { "checksum": "15d54d142da2f2d6f2e90ed1d55121af" @@ -60,14 +60,14 @@ "dist/assets/fa-v4compatibility.30f6abf6.ttf": { "checksum": "4ed293ceaca9b5b2d9cd74a477963fae" }, + "dist/assets/index.d89621ca.js": { + "checksum": "13b6fe53962f89359121b4c77ff77805" + }, "dist/assets/index.eba02280.css": { "checksum": "cb5a34d5ea88d4969288161bd3123ee4" }, - "dist/assets/index.f987f996.js": { - "checksum": "f8a777db2d3d8e211a31f70bbca57234" - }, "dist/index.html": { - "checksum": "c61715903d067cc4ece8190fbdddb516" + "checksum": "1743a09f8314278ee4393b80806f901e" } } } From 72c0dfdd9e7edecddbe04c6f1350c3263da32c69 Mon Sep 17 00:00:00 2001 From: Toph Allen Date: Thu, 22 May 2025 10:44:51 -0400 Subject: [PATCH 3/5] respond to feedback --- extensions/publisher-command-center/app.py | 1 - .../publisher-command-center/manifest.json | 2 +- .../src/components/UnauthorizedView.js | 71 ++++++++++++------- 3 files changed, 48 insertions(+), 26 deletions(-) diff --git a/extensions/publisher-command-center/app.py b/extensions/publisher-command-center/app.py index c60ff3a3..3cad5572 100644 --- a/extensions/publisher-command-center/app.py +++ b/extensions/publisher-command-center/app.py @@ -53,7 +53,6 @@ async def set_integration(integration_guid: str = Body(..., embed=True)): @app.get("/api/integrations") async def get_integrations(): - print("get_integration()") integrations = client.oauth.integrations.find() admin_integrations = [ i diff --git a/extensions/publisher-command-center/manifest.json b/extensions/publisher-command-center/manifest.json index 0ab7f874..d7f25be3 100644 --- a/extensions/publisher-command-center/manifest.json +++ b/extensions/publisher-command-center/manifest.json @@ -27,7 +27,7 @@ "API Publishing", "OAuth Integrations" ], - "version": "0.0.5" + "version": "0.0.4" }, "files": { "requirements.txt": { diff --git a/extensions/publisher-command-center/src/components/UnauthorizedView.js b/extensions/publisher-command-center/src/components/UnauthorizedView.js index f5fe7a06..e63b02ca 100644 --- a/extensions/publisher-command-center/src/components/UnauthorizedView.js +++ b/extensions/publisher-command-center/src/components/UnauthorizedView.js @@ -1,7 +1,7 @@ import m from "mithril"; const UnauthorizedView = { - oninit: function(vnode) { + oninit: function (vnode) { vnode.state.integration = null; vnode.state.loading = true; @@ -19,7 +19,7 @@ const UnauthorizedView = { }); }, - addIntegration: function(vnode) { + addIntegration: function (vnode) { if (!vnode.state.integration) return; m.request({ @@ -36,7 +36,7 @@ const UnauthorizedView = { }); }, - view: function(vnode) { + view: function (vnode) { // Show loading state if (vnode.state.loading) { return m("div.d-flex.justify-content-center", { style: { margin: "3rem" } }, [ @@ -50,19 +50,26 @@ const UnauthorizedView = { if (vnode.state.integration) { return m("div.alert.alert-info", { style: { margin: "1rem auto", maxWidth: "800px" } }, [ m("div", { style: { marginBottom: "1rem" } }, [ - m.trust("This content uses a Visitor API Key " + - "integration to show users the content they have access to. " + - "A compatible integration is displayed below." + - "

" + - "For more information, see " + - "documentation on Visitor API Key integrations.") + 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"), - m.trust("Add the '" + (vnode.state.integration.title || vnode.state.integration.name || "Connect API") + "' Integration") + "Add the ", + m("strong", vnode.state.integration.title || vnode.state.integration.name || "Connect API"), + " Integration" ]) ]); } @@ -73,19 +80,35 @@ const UnauthorizedView = { return m("div.alert.alert-warning", { style: { margin: "1rem auto", maxWidth: "800px" } }, [ m("div", { style: { marginBottom: "1rem" } }, [ - m.trust("This content needs permission to show users the content they have access to." + - "

" + - "To allow this, an Administrator must configure a " + - "Connect API integration on the " + - "Integration Settings page. " + - "

" + - "On that page, select '+ Add Integration'. " + - "In the 'Select Integration' dropdown, choose 'Connect API'. " + - "The 'Max Role' field must be set to 'Administrator' " + - "or 'Publisher'; 'Viewer' will not work. " + - "

" + - "See the Connect API section of the Admin Guide for more detailed setup instructions.") + 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." + ]) ]) ]); } From 35916d9f0233f15a203cf30fe319ed3f7c3fdc9d Mon Sep 17 00:00:00 2001 From: Toph Allen Date: Thu, 22 May 2025 10:58:42 -0400 Subject: [PATCH 4/5] improve view layout --- .../src/components/UnauthorizedView.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/extensions/publisher-command-center/src/components/UnauthorizedView.js b/extensions/publisher-command-center/src/components/UnauthorizedView.js index e63b02ca..e3ad4e6c 100644 --- a/extensions/publisher-command-center/src/components/UnauthorizedView.js +++ b/extensions/publisher-command-center/src/components/UnauthorizedView.js @@ -48,7 +48,13 @@ const UnauthorizedView = { // We have an integration ready to add if (vnode.state.integration) { - return m("div.alert.alert-info", { style: { margin: "1rem auto", maxWidth: "800px" } }, [ + 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 ", @@ -78,7 +84,13 @@ const UnauthorizedView = { const baseUrl = window.location.origin; const integrationSettingsUrl = `${baseUrl}/connect/#/system/integrations`; - return m("div.alert.alert-warning", { style: { margin: "1rem auto", maxWidth: "800px" } }, [ + 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", [ From 2011edec891e9f8c99fd63a6317092ca386bbffd Mon Sep 17 00:00:00 2001 From: Toph Allen Date: Thu, 22 May 2025 10:59:55 -0400 Subject: [PATCH 5/5] update manifest --- extensions/publisher-command-center/manifest.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/publisher-command-center/manifest.json b/extensions/publisher-command-center/manifest.json index d7f25be3..5c6c5893 100644 --- a/extensions/publisher-command-center/manifest.json +++ b/extensions/publisher-command-center/manifest.json @@ -34,7 +34,7 @@ "checksum": "a162a98758867a701ce693948ffdfa67" }, "app.py": { - "checksum": "aaadcea0fdb7bfa396bd94ba800edf30" + "checksum": "22c6a2e23c3e9986f1c80c3ef0f3d8d3" }, "dist/assets/fa-brands-400.808443ae.ttf": { "checksum": "15d54d142da2f2d6f2e90ed1d55121af" @@ -60,14 +60,14 @@ "dist/assets/fa-v4compatibility.30f6abf6.ttf": { "checksum": "4ed293ceaca9b5b2d9cd74a477963fae" }, - "dist/assets/index.d89621ca.js": { - "checksum": "13b6fe53962f89359121b4c77ff77805" + "dist/assets/index.8955cd2c.js": { + "checksum": "7292e20ff7cd87db06da4e78e4da2924" }, "dist/assets/index.eba02280.css": { "checksum": "cb5a34d5ea88d4969288161bd3123ee4" }, "dist/index.html": { - "checksum": "1743a09f8314278ee4393b80806f901e" + "checksum": "fc757dd431941551d9ea5a3046447549" } } }