Skip to content

Commit 276560e

Browse files
authored
Publisher Command Center: One-click add integration for first-deploy workflow (#146)
* implement better first deploy flow for publisher command center * update manifest * respond to feedback * improve view layout * update manifest
1 parent 0b7ec12 commit 276560e

File tree

4 files changed

+177
-33
lines changed

4 files changed

+177
-33
lines changed

extensions/publisher-command-center/app.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from http import client
22
import asyncio
3-
from fastapi import FastAPI, Header
3+
from fastapi import FastAPI, Header, Body
44
from fastapi.staticfiles import StaticFiles
55
from posit import connect
66
from posit.connect.errors import ClientError
@@ -15,8 +15,9 @@
1515
# Create cache with TTL=1hour and unlimited size
1616
client_cache = TTLCache(maxsize=float("inf"), ttl=3600)
1717

18-
@app.get("/api/auth-status")
19-
async def auth_status(posit_connect_user_session_token: str = Header(None)):
18+
19+
@app.get("/api/visitor-auth")
20+
async def integration_status(posit_connect_user_session_token: str = Header(None)):
2021
"""
2122
If running on Connect, attempt to build a visitor client.
2223
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)):
3031
except ClientError as err:
3132
if err.error_code == 212:
3233
return {"authorized": False}
33-
raise # Other errors bubble up
34+
raise
3435

3536
return {"authorized": True}
3637

38+
39+
@app.put("/api/visitor-auth")
40+
async def set_integration(integration_guid: str = Body(..., embed=True)):
41+
if os.getenv("RSTUDIO_PRODUCT") == "CONNECT":
42+
content_guid = os.getenv("CONNECT_CONTENT_GUID")
43+
content = client.content.get(content_guid)
44+
content.oauth.associations.update(integration_guid)
45+
else:
46+
# Raise an error if not running on Connect
47+
raise ClientError(
48+
error_code=400,
49+
message="This endpoint is only available when running on Posit Connect.",
50+
)
51+
return {"status": "success"}
52+
53+
54+
@app.get("/api/integrations")
55+
async def get_integrations():
56+
integrations = client.oauth.integrations.find()
57+
admin_integrations = [
58+
i
59+
for i in integrations
60+
if i["template"] == "connect" and i["config"]["max_role"] == "Admin"
61+
]
62+
publisher_integrations = [
63+
i
64+
for i in integrations
65+
if i["template"] == "connect" and i["config"]["max_role"] == "Publisher"
66+
]
67+
eligible_integrations = admin_integrations + publisher_integrations
68+
return eligible_integrations[0] if eligible_integrations else None
69+
70+
3771
@cached(client_cache)
3872
def get_visitor_client(token: str | None) -> connect.Client:
3973
"""Create and cache API client per token with 1 hour TTL"""
@@ -74,6 +108,7 @@ async def get_content_processes(
74108
active_jobs = [job for job in content.jobs if job["status"] == 0]
75109
return active_jobs
76110

111+
77112
@app.delete("/api/contents/{content_id}")
78113
async def delete_content(
79114
content_id: str,
@@ -84,6 +119,7 @@ async def delete_content(
84119
content = visitor.content.get(content_id)
85120
content.delete()
86121

122+
87123
@app.delete("/api/contents/{content_id}/processes/{process_id}")
88124
async def destroy_process(
89125
content_id: str,
@@ -102,6 +138,7 @@ async def destroy_process(
102138
return
103139
await asyncio.sleep(1)
104140

141+
105142
@app.get("/api/contents/{content_id}/author")
106143
async def get_author(
107144
content_id,

extensions/publisher-command-center/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"checksum": "a162a98758867a701ce693948ffdfa67"
3535
},
3636
"app.py": {
37-
"checksum": "55729c283443e02bcfc4233ccf0c2b3d"
37+
"checksum": "22c6a2e23c3e9986f1c80c3ef0f3d8d3"
3838
},
3939
"dist/assets/fa-brands-400.808443ae.ttf": {
4040
"checksum": "15d54d142da2f2d6f2e90ed1d55121af"
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import m from "mithril";
2+
3+
const UnauthorizedView = {
4+
oninit: function (vnode) {
5+
vnode.state.integration = null;
6+
vnode.state.loading = true;
7+
8+
// Check for available integration
9+
m.request({ method: "GET", url: "api/integrations" })
10+
.then(response => {
11+
vnode.state.integration = response;
12+
vnode.state.loading = false;
13+
m.redraw();
14+
})
15+
.catch(err => {
16+
console.error("Failed to fetch integrations", err);
17+
vnode.state.loading = false;
18+
m.redraw();
19+
});
20+
},
21+
22+
addIntegration: function (vnode) {
23+
if (!vnode.state.integration) return;
24+
25+
m.request({
26+
method: "PUT",
27+
url: "api/visitor-auth",
28+
body: { integration_guid: vnode.state.integration.guid }
29+
})
30+
.then(() => {
31+
// Reload the top-most window to check authorization again
32+
window.top.location.reload();
33+
})
34+
.catch(err => {
35+
console.error("Failed to add integration", err);
36+
});
37+
},
38+
39+
view: function (vnode) {
40+
// Show loading state
41+
if (vnode.state.loading) {
42+
return m("div.d-flex.justify-content-center", { style: { margin: "3rem" } }, [
43+
m("div.spinner-border.text-primary", { role: "status" },
44+
m("span.visually-hidden", "Loading...")
45+
)
46+
]);
47+
}
48+
49+
// We have an integration ready to add
50+
if (vnode.state.integration) {
51+
return m("div.alert.alert-info", {
52+
style: {
53+
margin: "1rem auto",
54+
maxWidth: "640px",
55+
width: "calc(100% - 2rem)"
56+
}
57+
}, [
58+
m("div", { style: { marginBottom: "1rem" } }, [
59+
m("p", [
60+
"This content uses a ",
61+
m("strong", "Visitor API Key"),
62+
" integration to show users the content they have access to. A compatible integration is displayed below."
63+
]),
64+
m("p", [
65+
"For more information, see ",
66+
m("a", {
67+
href: "https://docs.posit.co/connect/user/oauth-integrations/#obtaining-a-visitor-api-key",
68+
target: "_blank"
69+
}, "documentation on Visitor API Key integrations")
70+
])
71+
]),
72+
m("button.btn.btn-primary", {
73+
onclick: () => this.addIntegration(vnode)
74+
}, [
75+
m("i.fas.fa-plus.me-2"),
76+
"Add the ",
77+
m("strong", vnode.state.integration.title || vnode.state.integration.name || "Connect API"),
78+
" Integration"
79+
])
80+
]);
81+
}
82+
83+
// No integration available
84+
const baseUrl = window.location.origin;
85+
const integrationSettingsUrl = `${baseUrl}/connect/#/system/integrations`;
86+
87+
return m("div.alert.alert-warning", {
88+
style: {
89+
margin: "1rem auto",
90+
maxWidth: "640px",
91+
width: "calc(100% - 2rem)"
92+
}
93+
}, [
94+
m("div", { style: { marginBottom: "1rem" } }, [
95+
m("p", "This content needs permission to show users the content they have access to."),
96+
m("p", [
97+
"To allow this, an Administrator must configure a ",
98+
m("strong", "Connect API"),
99+
" integration on the ",
100+
m("strong", [
101+
m("a", { href: integrationSettingsUrl, target: "_blank" }, "Integration Settings")
102+
]),
103+
" page."
104+
]),
105+
m("p", [
106+
"On that page, select ",
107+
m("strong", "+ Add Integration"),
108+
". In the 'Select Integration' dropdown, choose ",
109+
m("strong", "Connect API"),
110+
". The 'Max Role' field must be set to ",
111+
m("strong", "Administrator"),
112+
" or ",
113+
m("strong", "Publisher"),
114+
"; 'Viewer' will not work."
115+
]),
116+
m("p", [
117+
"See the ",
118+
m("a", {
119+
href: "https://docs.posit.co/connect/admin/integrations/oauth-integrations/connect/",
120+
target: "_blank"
121+
}, "Connect API section of the Admin Guide"),
122+
" for more detailed setup instructions."
123+
])
124+
])
125+
]);
126+
}
127+
};
128+
129+
export default UnauthorizedView;

extensions/publisher-command-center/src/index.js

Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,38 +8,17 @@ import "../scss/index.scss";
88

99
import Home from "./views/Home";
1010
import Edit from "./views/Edit";
11-
import Layout from './views/Layout'
11+
import Layout from './views/Layout';
12+
import UnauthorizedView from './components/UnauthorizedView';
1213

1314
const root = document.getElementById("app");
1415

1516
// First ask the server “are we authorized?”
16-
m.request({ method: "GET", url: "api/auth-status" })
17+
m.request({ method: "GET", url: "api/visitor-auth" })
1718
.then((res) => {
1819
if (!res.authorized) {
19-
// Unauthorized → just show the banner, never mount the router
20-
m.mount(root, {
21-
view: () =>
22-
m(
23-
"div.alert.alert-info",
24-
{ style: { margin: "1rem" } },
25-
[
26-
m("p", [
27-
"To finish setting up this content, you must add a Visitor API Key ",
28-
"integration with the Publisher scope."
29-
]),
30-
m("p", [
31-
'Select "+ Add integration" in the Access settings panel to the ',
32-
'right, and find an entry with "Authentication type: Visitor API Key".'
33-
]),
34-
m("p", [
35-
"If no such integration exists, an Administrator must configure one. ",
36-
"Go to Connect's System page, select the Integrations tab, then ",
37-
'click "+ Add Integration", choose "Connect API", pick Publisher or ',
38-
"Administrator under Max Role, and give it a descriptive title."
39-
])
40-
]
41-
),
42-
});
20+
// Unauthorized → mount our UnauthorizedView component
21+
m.mount(root, UnauthorizedView);
4322
} else {
4423
// Authorized → wire up routes
4524
m.route(root, "/contents", {
@@ -53,6 +32,5 @@ m.request({ method: "GET", url: "api/auth-status" })
5332
}
5433
})
5534
.catch((err) => {
56-
console.error("failed to fetch auth-status", err);
57-
// you might also render a generic “uh-oh” banner here
35+
console.error("failed to fetch visitor-auth", err);
5836
});

0 commit comments

Comments
 (0)