Secrets from modal (#1406)

* Secrets from modal

* Format assets

* Applying suggestions

* Format assets

* Applying suggestions

* Unavailable secret

* Applying suggestions - select_secret_ref

* Applying suggestions

* Applying suggestions - iframe v4
This commit is contained in:
Cristine Guadelupe 2022-09-16 17:56:40 -03:00 committed by GitHub
parent 12f1cc0c3f
commit bf09441555
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 331 additions and 9 deletions

View file

@ -331,6 +331,11 @@ const JSView = {
} else if (message.type === "syncReply") {
this.pongCallbackQueue.push(this.syncCallbackQueue.shift());
this.channel.push("ping", { ref: this.props.ref });
} else if (message.type == "selectSecret") {
this.pushEvent("select_secret", {
js_view_ref: this.props.ref,
preselect_name: message.preselectName,
});
}
}
},
@ -409,6 +414,11 @@ const JSView = {
// do a ping to synchronize with the server
this.syncCallbackQueue.push(event.callback);
this.postMessage({ type: "sync" });
} else if (event.type == "secretSelected") {
this.postMessage({
type: "secretSelected",
secretLabel: event.secretLabel,
});
}
},
};

View file

@ -26,7 +26,7 @@ import { sha256Base64 } from "../../lib/utils";
// (2): https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox
// (3): https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
const IFRAME_SHA256 = "fA00WeO9LAvgpbMz9vKEU0WTr4Uk5bTt/BxKHdweEz8=";
const IFRAME_SHA256 = "6aUFRKEwZbbohB7OpFdKqnJJVRhccJxHGV+tArREQ+I=";
export function initializeIframeSource(iframe, iframePort, iframeUrl) {
const url = getIframeUrl(iframePort, iframeUrl);
@ -48,8 +48,8 @@ function getIframeUrl(iframePort, iframeUrl) {
}
return protocol === "https:"
? "https://livebook.space/iframe/v3.html"
: `http://${window.location.hostname}:${iframePort}/iframe/v3.html`;
? "https://livebook.space/iframe/v4.html"
: `http://${window.location.hostname}:${iframePort}/iframe/v4.html`;
}
let iframeVerificationPromise = null;

View file

@ -205,6 +205,13 @@ const Session = {
this.handleClientsUpdated(clients);
});
this.handleEvent(
"secret_selected",
({ select_secret_ref, secret_label }) => {
this.handleSecretSelected(select_secret_ref, secret_label);
}
);
this.handleEvent(
"location_report",
({ client_id, focusable_id, selection }) => {
@ -1034,6 +1041,13 @@ const Session = {
});
},
handleSecretSelected(select_secret_ref, secretLabel) {
globalPubSub.broadcast(`js_views:${select_secret_ref}`, {
type: "secretSelected",
secretLabel,
});
},
handleLocationReport(clientId, report) {
const client = this.clientsMap[clientId];

View file

@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Output</title>
<style>
html,
body {
margin: 0;
padding: 0;
font-family: sans-serif;
overflow-y: hidden;
}
</style>
</head>
<body>
<div id="root"></div>
<script>
"use strict";
// Invoke the init function in a separate context for better isolation
function applyInit(init, ctx, data) {
init(ctx, data);
}
(() => {
const state = {
token: null,
importPromise: null,
eventHandlers: {},
eventQueue: [],
syncHandler: null,
secretHandler: null,
};
function postMessage(message) {
window.parent.postMessage({ token: state.token, ...message }, "*");
}
const ctx = {
root: document.getElementById("root"),
handleEvent(event, callback) {
if (state.eventHandlers[event]) {
throw new Error(
`Handler has already been defined for event "${event}"`
);
}
state.eventHandlers[event] = callback;
while (
state.eventQueue.length > 0 &&
state.eventHandlers[state.eventQueue[0].event]
) {
const { event, payload } = state.eventQueue.shift();
const handler = state.eventHandlers[event];
handler(payload);
}
},
pushEvent(event, payload = null) {
postMessage({ type: "event", event, payload });
},
importCSS(url) {
return new Promise((resolve, reject) => {
const linkEl = document.createElement("link");
linkEl.addEventListener(
"load",
(event) => {
resolve();
},
{ once: true }
);
linkEl.rel = "stylesheet";
linkEl.href = url;
document.head.appendChild(linkEl);
});
},
handleSync(callback) {
state.syncHandler = callback;
},
selectSecret(callback, preselectName, options) {
state.secretHandler = callback;
postMessage({ type: "selectSecret", preselectName, options });
},
};
window.addEventListener("message", (event) => {
if (event.source === window.parent) {
handleParentMessage(event.data);
}
});
function handleParentMessage(message) {
if (message.type === "readyReply") {
state.token = message.token;
onReady();
// Set the base URL for relative URLs
const baseUrlEl = document.createElement("base");
baseUrlEl.href = message.baseUrl;
document.head.appendChild(baseUrlEl);
// We already entered the script and the base URL change
// doesn't impact this import call, so we use the absolute
// URL instead
state.importPromise = import(`${message.baseUrl}${message.jsPath}`);
} else if (message.type === "init") {
state.importPromise
.then((module) => {
const init = module.init;
if (!init) {
const fns = Object.keys(module);
throw new Error(
`Expected the module to export an init function, but found: ${fns.join(
", "
)}`
);
}
applyInit(init, ctx, message.data);
})
.catch((error) => {
renderErrorMessage(
`Failed to load the widget JS module, got the following error:\n\n ${error.message}\n\nSee the browser console for more details. If running behind an authentication proxy, make sure the /public/* routes are publicly accessible.`
);
throw error;
});
} else if (message.type === "event") {
const { event, payload } = message;
const handler = state.eventHandlers[event];
if (state.eventQueue.length === 0 && handler) {
handler(payload);
} else {
state.eventQueue.push({ event, payload });
}
} else if (message.type === "sync") {
Promise.resolve(state.syncHandler && state.syncHandler()).then(
() => {
postMessage({ type: "syncReply" });
}
);
} else if (message.type === "secretSelected") {
state.secretHandler && state.secretHandler(message.secretLabel);
}
}
postMessage({ type: "ready" });
function onReady() {
// Report height changes
const resizeObserver = new ResizeObserver((entries) => {
postMessage({ type: "resize", height: document.body.scrollHeight });
});
resizeObserver.observe(document.body);
// Forward relevant DOM events
window.addEventListener("mousedown", (event) => {
postMessage({ type: "domEvent", event: { type: "mousedown" } });
});
window.addEventListener("focus", (event) => {
postMessage({ type: "domEvent", event: { type: "focus" } });
});
window.addEventListener("keydown", (event) => {
postMessage({
type: "domEvent",
event: keyboardEventToPayload(event),
isTargetEditable: isEditableElement(event.target),
});
});
}
function isEditableElement(element) {
return element.matches("input, textarea, [contenteditable]");
}
function keyboardEventToPayload(event) {
const {
altKey,
code,
ctrlKey,
isComposing,
key,
location,
metaKey,
repeat,
shiftKey,
} = event;
return {
type: event.type,
props: {
altKey,
code,
ctrlKey,
isComposing,
key,
location,
metaKey,
repeat,
shiftKey,
},
};
}
function renderErrorMessage(message) {
ctx.root.innerHTML = `
<div style="color: #FF3E38; white-space: pre-wrap; word-break: break-word;">${message}</div>
`;
}
})();
</script>
</body>
</html>

View file

@ -404,7 +404,10 @@ defmodule LivebookWeb.SessionLive do
module={LivebookWeb.SessionLive.SecretsComponent}
id="secrets"
session={@session}
secrets={@data_view.secrets}
prefill_secret_label={@prefill_secret_label}
select_secret_ref={@select_secret_ref}
preselect_name={@preselect_name}
return_to={@self_path}
/>
</.modal>
@ -752,7 +755,12 @@ defmodule LivebookWeb.SessionLive do
def handle_params(params, _url, socket)
when socket.assigns.live_action == :secrets do
{:noreply, assign(socket, prefill_secret_label: params["secret_label"])}
{:noreply,
assign(socket,
prefill_secret_label: params["secret_label"],
preselect_name: params["preselect_name"],
select_secret_ref: if(params["preselect_name"], do: socket.assigns.select_secret_ref)
)}
end
def handle_params(_params, _url, socket) do
@ -1112,6 +1120,22 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event(
"select_secret",
%{"js_view_ref" => select_secret_ref, "preselect_name" => preselect_name},
socket
) do
socket = assign(socket, select_secret_ref: select_secret_ref)
{:noreply,
push_patch(socket,
to:
Routes.session_path(socket, :secrets, socket.assigns.session.id,
preselect_name: preselect_name
)
)}
end
@impl true
def handle_info({:operation, operation}, socket) do
{:noreply, handle_operation(socket, operation)}

View file

@ -9,7 +9,7 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
if socket.assigns[:data] do
socket
else
assign(socket, data: %{"label" => assigns.prefill_secret_label || "", "value" => ""})
assign(socket, data: %{"label" => prefill_secret_label(socket), "value" => ""})
end
{:ok, socket}
@ -22,7 +22,7 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
<h3 class="text-2xl font-semibold text-gray-800">
Add secret
</h3>
<p class="text-gray-700" id="import-from-url">
<p class="text-gray-700">
Enter the secret name and its value.
</p>
<.form
@ -51,7 +51,7 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
<%= text_input(f, :value,
value: @data["value"],
class: "input",
autofocus: !!@prefill_secret_label,
autofocus: !!@prefill_secret_label || unavailable_secret?(@preselect_name, @secrets),
spellcheck: "false"
) %>
</.input_wrapper>
@ -63,21 +63,44 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
</div>
</div>
</.form>
<%= if @select_secret_ref do %>
<h3 class="text-2xl font-semibold text-gray-800">
Select secret
</h3>
<.form
let={_}
for={:secrets}
phx-submit="save_secret"
phx-change="select_secret"
phx-target={@myself}
>
<.select name="secret" selected={@preselect_name} options={secret_options(@secrets)} />
</.form>
<% end %>
</div>
"""
end
@impl true
def handle_event("save", %{"data" => data}, socket) do
secret_label = String.upcase(data["label"])
if data_errors(data) == [] do
secret = %{label: String.upcase(data["label"]), value: data["value"]}
secret = %{label: secret_label, value: data["value"]}
Livebook.Session.put_secret(socket.assigns.session.pid, secret)
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
{:noreply,
socket |> push_patch(to: socket.assigns.return_to) |> push_secret_selected(secret_label)}
else
{:noreply, assign(socket, data: data)}
end
end
def handle_event("select_secret", %{"secret" => secret_label}, socket) do
{:noreply,
socket |> push_patch(to: socket.assigns.return_to) |> push_secret_selected(secret_label)}
end
def handle_event("validate", %{"data" => data}, socket) do
{:noreply, assign(socket, data: data)}
end
@ -102,4 +125,31 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
defp data_error("value", ""), do: "can't be blank"
defp data_error(_key, _value), do: nil
defp secret_options(secrets), do: [{"", ""} | Enum.map(secrets, &{&1.label, &1.label})]
defp push_secret_selected(%{assigns: %{select_secret_ref: nil}} = socket, _), do: socket
defp push_secret_selected(%{assigns: %{select_secret_ref: ref}} = socket, secret_label) do
push_event(socket, "secret_selected", %{select_secret_ref: ref, secret_label: secret_label})
end
defp prefill_secret_label(socket) do
case socket.assigns.prefill_secret_label do
nil ->
if unavailable_secret?(socket.assigns.preselect_name, socket.assigns.secrets),
do: socket.assigns.preselect_name,
else: ""
prefill ->
prefill
end
end
defp unavailable_secret?(nil, _), do: false
defp unavailable_secret?("", _), do: false
defp unavailable_secret?(preselect_name, secrets) do
preselect_name not in Enum.map(secrets, & &1.label)
end
end