mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
fix clickmap wildcard origin launch
This commit is contained in:
parent
e93b7520c4
commit
4288fa4716
@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getClickmapOriginOptions, normalizeClickmapOrigin } from "./clickmap-origins";
|
||||
|
||||
describe("clickmap origin options", () => {
|
||||
it("keeps wildcard domains out of launchable origins", () => {
|
||||
const options = getClickmapOriginOptions({
|
||||
wildcard: { baseUrl: "https://**.stack-auth.com" },
|
||||
concrete: { baseUrl: "https://app.stack-auth.com/path?x=1" },
|
||||
duplicate: { baseUrl: "https://app.stack-auth.com/other" },
|
||||
});
|
||||
|
||||
expect(options).toMatchInlineSnapshot(`
|
||||
{
|
||||
"origins": [
|
||||
{
|
||||
"id": "duplicate",
|
||||
"origin": "https://app.stack-auth.com",
|
||||
},
|
||||
],
|
||||
"wildcardDomains": [
|
||||
{
|
||||
"baseUrl": "https://**.stack-auth.com",
|
||||
"id": "wildcard",
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("normalizes only HTTP(S) origins", () => {
|
||||
expect(normalizeClickmapOrigin("https://app.dev.stack-auth.com/dashboard")).toMatchInlineSnapshot(`"https://app.dev.stack-auth.com"`);
|
||||
expect(normalizeClickmapOrigin("javascript:alert(1)")).toMatchInlineSnapshot(`null`);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,69 @@
|
||||
export type ClickmapOrigin = {
|
||||
id: string,
|
||||
origin: string,
|
||||
};
|
||||
|
||||
export type ClickmapWildcardDomain = {
|
||||
id: string,
|
||||
baseUrl: string,
|
||||
};
|
||||
|
||||
export function normalizeClickmapOrigin(baseUrl: string): string | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(baseUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
function isWildcardDomain(baseUrl: string): boolean {
|
||||
return baseUrl.includes("*");
|
||||
}
|
||||
|
||||
function compareStrings(a: string, b: string): number {
|
||||
if (a < b) {
|
||||
return -1;
|
||||
}
|
||||
if (a > b) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getClickmapOriginOptions(trustedDomains: Record<string, { baseUrl?: string | null }>): {
|
||||
origins: ClickmapOrigin[],
|
||||
wildcardDomains: ClickmapWildcardDomain[],
|
||||
} {
|
||||
const byOrigin = new Map<string, ClickmapOrigin>();
|
||||
const wildcardDomains: ClickmapWildcardDomain[] = [];
|
||||
|
||||
for (const id in trustedDomains) {
|
||||
const domain = trustedDomains[id];
|
||||
if (domain.baseUrl == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isWildcardDomain(domain.baseUrl)) {
|
||||
wildcardDomains.push({ id, baseUrl: domain.baseUrl });
|
||||
continue;
|
||||
}
|
||||
|
||||
const origin = normalizeClickmapOrigin(domain.baseUrl);
|
||||
if (origin == null) {
|
||||
continue;
|
||||
}
|
||||
byOrigin.set(origin, { id, origin });
|
||||
}
|
||||
|
||||
return {
|
||||
origins: Array.from(byOrigin.values()).sort((a, b) => compareStrings(a.origin, b.origin)),
|
||||
wildcardDomains: wildcardDomains.sort((a, b) => compareStrings(a.baseUrl, b.baseUrl)),
|
||||
};
|
||||
}
|
||||
@ -18,29 +18,16 @@ import {
|
||||
Typography,
|
||||
} from "@/components/ui";
|
||||
import { DesignAnalyticsCard } from "@/components/design-components/analytics-card";
|
||||
import { DesignAlert } from "@/components/design-components/alert";
|
||||
import { useRouter } from "@/components/router";
|
||||
import type { AnalyticsClickmapTokenResponse } from "@hexclave/shared/dist/interface/admin-metrics";
|
||||
import {
|
||||
CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY,
|
||||
CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT,
|
||||
} from "@hexclave/shared/dist/utils/analytics-clickmap-overlay";
|
||||
import { typedEntries } from "@hexclave/shared/dist/utils/objects";
|
||||
import { stringCompare } from "@hexclave/shared/dist/utils/strings";
|
||||
import { ArrowRight, GlobeHemisphereWest } from "@phosphor-icons/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type ClickmapOrigin = {
|
||||
id: string,
|
||||
origin: string,
|
||||
};
|
||||
|
||||
function normalizeOrigin(baseUrl: string): string | null {
|
||||
try {
|
||||
return new URL(baseUrl).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
import { getClickmapOriginOptions, normalizeClickmapOrigin, type ClickmapOrigin } from "./clickmap-origins";
|
||||
|
||||
// The clickmap token is a self-describing JWT (its payload carries the project
|
||||
// and origin it was minted for), so the snippet only has to hand over the token
|
||||
@ -118,19 +105,8 @@ export default function PageClient() {
|
||||
setCustomOrigin(window.location.origin);
|
||||
}, []);
|
||||
|
||||
const origins = useMemo(() => {
|
||||
const byOrigin = new Map<string, ClickmapOrigin>();
|
||||
for (const [id, domain] of typedEntries(config.domains.trustedDomains)) {
|
||||
if (domain.baseUrl == null) {
|
||||
continue;
|
||||
}
|
||||
const origin = normalizeOrigin(domain.baseUrl);
|
||||
if (origin == null) {
|
||||
continue;
|
||||
}
|
||||
byOrigin.set(origin, { id, origin });
|
||||
}
|
||||
return Array.from(byOrigin.values()).sort((a, b) => stringCompare(a.origin, b.origin));
|
||||
const { origins, wildcardDomains } = useMemo(() => {
|
||||
return getClickmapOriginOptions(config.domains.trustedDomains);
|
||||
}, [config.domains.trustedDomains]);
|
||||
|
||||
async function showClickmap(origin: ClickmapOrigin) {
|
||||
@ -166,39 +142,41 @@ export default function PageClient() {
|
||||
description="Launch the clickmap toolbar on a trusted domain."
|
||||
fillWidth
|
||||
>
|
||||
{config.domains.allowLocalhost && (
|
||||
<DesignAnalyticsCard gradient="slate" className="p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Typography className="font-medium">Localhost origin</Typography>
|
||||
<Typography type="p" variant="secondary" className="text-xs">
|
||||
Use the exact origin shown in the browser address bar for your local site.
|
||||
</Typography>
|
||||
<Input value={customOrigin} onChange={(event) => setCustomOrigin(event.target.value)} placeholder="http://localhost:3000" />
|
||||
</div>
|
||||
<Button
|
||||
className="gap-1.5"
|
||||
disabled={customOrigin.trim() === ""}
|
||||
onClick={async () => {
|
||||
const origin = normalizeOrigin(customOrigin);
|
||||
if (origin == null) {
|
||||
window.alert("Enter a valid HTTP(S) origin, for example http://localhost:3000.");
|
||||
return;
|
||||
}
|
||||
await showClickmap({ id: "localhost", origin });
|
||||
}}
|
||||
>
|
||||
Show clickmap
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<DesignAnalyticsCard gradient="slate" className="p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Typography className="font-medium">Exact page origin</Typography>
|
||||
<Typography type="p" variant="secondary" className="text-xs">
|
||||
Use the exact origin shown in the browser address bar, including for domains matched by a wildcard.
|
||||
</Typography>
|
||||
<Input value={customOrigin} onChange={(event) => setCustomOrigin(event.target.value)} placeholder="https://app.example.com" />
|
||||
</div>
|
||||
</DesignAnalyticsCard>
|
||||
)}
|
||||
<Button
|
||||
className="gap-1.5"
|
||||
disabled={customOrigin.trim() === ""}
|
||||
onClick={async () => {
|
||||
const origin = normalizeClickmapOrigin(customOrigin);
|
||||
if (origin == null) {
|
||||
window.alert("Enter a valid HTTP(S) origin, for example https://app.example.com.");
|
||||
return;
|
||||
}
|
||||
await showClickmap({ id: "exact-origin", origin });
|
||||
}}
|
||||
>
|
||||
Show clickmap
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</DesignAnalyticsCard>
|
||||
|
||||
{origins.length === 0 ? (
|
||||
<Alert className="rounded-2xl">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>Add a trusted domain before launching a production clickmap.</span>
|
||||
<span>
|
||||
{wildcardDomains.length === 0
|
||||
? "Add a trusted domain before launching a production clickmap."
|
||||
: "Enter an exact origin that matches a wildcard domain, or add a concrete trusted domain."}
|
||||
</span>
|
||||
<Button
|
||||
className="shrink-0 gap-1.5"
|
||||
onClick={() => router.push(`/projects/${project.id}/domains`)}
|
||||
@ -232,6 +210,14 @@ export default function PageClient() {
|
||||
</DesignAnalyticsCard>
|
||||
)}
|
||||
|
||||
{wildcardDomains.length > 0 && (
|
||||
<DesignAlert
|
||||
variant="info"
|
||||
title="Wildcard domains need an exact origin"
|
||||
description={`Enter the concrete site origin above. ${wildcardDomains.map((domain) => domain.baseUrl).join(", ")} can match real pages, but cannot be opened directly as a clickmap target.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ClickmapTokenDialog
|
||||
origin={selectedOrigin}
|
||||
token={token}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user