mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-02-26 16:05:27 +08:00
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:
parent
12f1cc0c3f
commit
bf09441555
6 changed files with 331 additions and 9 deletions
|
@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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];
|
||||
|
||||
|
|
224
iframe/priv/static/iframe/v4.html
Normal file
224
iframe/priv/static/iframe/v4.html
Normal 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>
|
|
@ -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)}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue