2026-08-09 19:35:39 -07:00
|
|
|
import { urlOrigin } from "../../lib/urlRelay";
|
|
|
|
|
|
2026-03-01 08:29:43 -08:00
|
|
|
interface Props {
|
2026-08-09 19:35:39 -07:00
|
|
|
/** Already validated by `sanitizeRelayUrl` — this component never opens it. */
|
2026-03-01 08:29:43 -08:00
|
|
|
url: string;
|
2026-08-09 16:55:28 -07:00
|
|
|
/** Heading above the URL. Says why the toast appeared. */
|
|
|
|
|
label?: string;
|
2026-03-01 08:29:43 -08:00
|
|
|
onOpen: () => void;
|
2026-08-11 09:15:12 -07:00
|
|
|
/** Open it in the container's own browser instead of the host's. Omitted when
|
|
|
|
|
* the project has no browser to open it in. */
|
|
|
|
|
onOpenInContainer?: () => void;
|
2026-03-01 08:29:43 -08:00
|
|
|
onDismiss: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 19:35:39 -07:00
|
|
|
/**
|
|
|
|
|
* Confirmation prompt for a URL something inside the container wants opened in
|
|
|
|
|
* the host browser.
|
|
|
|
|
*
|
|
|
|
|
* The origin is rendered separately from the rest of the URL and is never
|
|
|
|
|
* truncated. A single `nowrap`/`ellipsis` line looks tidy but is a spoofing
|
|
|
|
|
* primitive: `https://accounts.example.com/....(600 chars)....@evil.tld/` shows
|
|
|
|
|
* the reassuring half and hides the half that decides where the request goes.
|
|
|
|
|
* `sanitizeRelayUrl` already rejects the userinfo form; showing the origin in
|
|
|
|
|
* full is the belt to that braces, and it also covers the plainer case of a
|
|
|
|
|
* long path pushing the host out of view.
|
|
|
|
|
*
|
|
|
|
|
* Render this with a `key` that changes whenever the URL does. The prompt slot
|
|
|
|
|
* is shared and long-lived, so without one React mutates the node in place: the
|
|
|
|
|
* text swaps with no animation, and a user reading URL A can click Open on URL
|
|
|
|
|
* B that arrived a second later.
|
|
|
|
|
*/
|
2026-08-09 16:55:28 -07:00
|
|
|
export default function UrlToast({
|
|
|
|
|
url,
|
|
|
|
|
label = "Long URL detected",
|
|
|
|
|
onOpen,
|
2026-08-11 09:15:12 -07:00
|
|
|
onOpenInContainer,
|
2026-08-09 16:55:28 -07:00
|
|
|
onDismiss,
|
|
|
|
|
}: Props) {
|
2026-08-09 19:35:39 -07:00
|
|
|
const origin = urlOrigin(url);
|
|
|
|
|
const rest = origin && url.startsWith(origin) ? url.slice(origin.length) : url;
|
|
|
|
|
|
2026-03-01 08:29:43 -08:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className="animate-slide-down"
|
2026-08-09 19:35:39 -07:00
|
|
|
role="status"
|
2026-03-01 08:29:43 -08:00
|
|
|
style={{
|
|
|
|
|
position: "absolute",
|
|
|
|
|
top: 12,
|
|
|
|
|
left: "50%",
|
|
|
|
|
transform: "translateX(-50%)",
|
|
|
|
|
zIndex: 40,
|
|
|
|
|
display: "flex",
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
gap: 10,
|
|
|
|
|
padding: "8px 12px",
|
|
|
|
|
background: "var(--bg-secondary)",
|
|
|
|
|
border: "1px solid var(--border-color)",
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
|
|
|
|
maxWidth: "min(90%, 600px)",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
color: "var(--text-secondary)",
|
|
|
|
|
marginBottom: 2,
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-08-09 16:55:28 -07:00
|
|
|
{label}
|
2026-03-01 08:29:43 -08:00
|
|
|
</div>
|
|
|
|
|
<div
|
2026-08-09 19:35:39 -07:00
|
|
|
data-testid="url-toast-url"
|
|
|
|
|
title={url}
|
2026-03-01 08:29:43 -08:00
|
|
|
style={{
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
fontFamily: "monospace",
|
|
|
|
|
color: "var(--text-primary)",
|
2026-08-09 19:35:39 -07:00
|
|
|
display: "flex",
|
|
|
|
|
alignItems: "baseline",
|
|
|
|
|
minWidth: 0,
|
2026-03-01 08:29:43 -08:00
|
|
|
}}
|
|
|
|
|
>
|
2026-08-09 19:35:39 -07:00
|
|
|
{origin && (
|
|
|
|
|
<span
|
|
|
|
|
data-testid="url-toast-origin"
|
|
|
|
|
style={{
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
// The part that decides where the credentials go. It wraps
|
|
|
|
|
// rather than truncates, whatever else has to give.
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
overflowWrap: "anywhere",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{origin}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<span
|
|
|
|
|
data-testid="url-toast-rest"
|
|
|
|
|
style={{
|
|
|
|
|
color: "var(--text-secondary)",
|
|
|
|
|
overflow: "hidden",
|
|
|
|
|
textOverflow: "ellipsis",
|
|
|
|
|
whiteSpace: "nowrap",
|
|
|
|
|
minWidth: 0,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{rest}
|
|
|
|
|
</span>
|
2026-03-01 08:29:43 -08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={onOpen}
|
|
|
|
|
style={{
|
|
|
|
|
padding: "4px 12px",
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
color: "#fff",
|
|
|
|
|
background: "var(--accent)",
|
|
|
|
|
border: "none",
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
cursor: "pointer",
|
|
|
|
|
whiteSpace: "nowrap",
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
}}
|
|
|
|
|
onMouseEnter={(e) =>
|
|
|
|
|
(e.currentTarget.style.background = "var(--accent-hover)")
|
|
|
|
|
}
|
|
|
|
|
onMouseLeave={(e) =>
|
|
|
|
|
(e.currentTarget.style.background = "var(--accent)")
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
Open
|
|
|
|
|
</button>
|
|
|
|
|
|
2026-08-11 09:15:12 -07:00
|
|
|
{onOpenInContainer && (
|
|
|
|
|
// A sign-in completed in the *container's* browser lands its callback
|
|
|
|
|
// on the container's own loopback, which is where the tool waiting for
|
|
|
|
|
// it is listening — no host round trip, no auth bridge.
|
|
|
|
|
<button
|
|
|
|
|
onClick={onOpenInContainer}
|
|
|
|
|
title="Open in a browser inside the container, and watch it in the Browser tab"
|
|
|
|
|
style={{
|
|
|
|
|
padding: "4px 10px",
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
color: "var(--text-primary)",
|
|
|
|
|
background: "transparent",
|
|
|
|
|
border: "1px solid var(--border-color)",
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
cursor: "pointer",
|
|
|
|
|
whiteSpace: "nowrap",
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
In container
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-03-01 08:29:43 -08:00
|
|
|
<button
|
|
|
|
|
onClick={onDismiss}
|
|
|
|
|
style={{
|
|
|
|
|
padding: "2px 6px",
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
lineHeight: 1,
|
|
|
|
|
color: "var(--text-secondary)",
|
|
|
|
|
background: "transparent",
|
|
|
|
|
border: "none",
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
cursor: "pointer",
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
}}
|
|
|
|
|
onMouseEnter={(e) =>
|
|
|
|
|
(e.currentTarget.style.color = "var(--text-primary)")
|
|
|
|
|
}
|
|
|
|
|
onMouseLeave={(e) =>
|
|
|
|
|
(e.currentTarget.style.color = "var(--text-secondary)")
|
|
|
|
|
}
|
|
|
|
|
aria-label="Dismiss"
|
|
|
|
|
>
|
|
|
|
|
✕
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|