Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions extensions/publisher-command-center/app.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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"""
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion extensions/publisher-command-center/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"checksum": "a162a98758867a701ce693948ffdfa67"
},
"app.py": {
"checksum": "55729c283443e02bcfc4233ccf0c2b3d"
"checksum": "22c6a2e23c3e9986f1c80c3ef0f3d8d3"
},
"dist/assets/fa-brands-400.808443ae.ttf": {
"checksum": "15d54d142da2f2d6f2e90ed1d55121af"
Expand Down
129 changes: 129 additions & 0 deletions extensions/publisher-command-center/src/components/UnauthorizedView.js
Original file line number Diff line number Diff line change
@@ -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;
34 changes: 6 additions & 28 deletions extensions/publisher-command-center/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand All @@ -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);
});