Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 112 additions & 94 deletions app/sign-in/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Sign-in mode only (no RAWTREE_QUERY_KEY / RAWTREE_INGEST_KEY). Layout follows
// jev-pr-quality's sign-in page: connect with a RawTree account through Vercel Connect (when
// RAWTREE_CONNECTOR is set) or with an API key, then a preview and how it works. `?method=key`
// picks the API key tab without client JavaScript. Env mode redirects to the dashboard.
// picks the API key tab without client JavaScript. Once connected, the page becomes step 2 of the
// Connect flow: only the workspace picker and Disconnect. Env mode redirects to the dashboard.

import { IconChartBar, IconChevronRight, IconCode, IconDatabase, IconExternalLink, IconSend, IconShieldCheck } from "@tabler/icons-react";
import { IconChartBar, IconCheck, IconChevronRight, IconCode, IconDatabase, IconExternalLink, IconSend, IconShieldCheck } from "@tabler/icons-react";
import Image from "next/image";
import Link from "next/link";
import { cookies } from "next/headers";
Expand Down Expand Up @@ -108,8 +109,9 @@ export default async function SignInPage({ searchParams }: { searchParams: Promi
const connector = connectorInSignInMode();
const session = (await cookies()).get(SESSION_COOKIE)?.value;
const picker = await loadPicker(connector, session);
const useKey = !connector || method === "key";
const connected = Boolean(picker.workspaces);
// Once connected, the API key form is out of reach until the visitor disconnects.
const useKey = !connected && (!connector || method === "key");

return (
<div className="flex min-h-dvh flex-col bg-background-extra">
Expand All @@ -134,17 +136,32 @@ export default async function SignInPage({ searchParams }: { searchParams: Promi
<BrandLines />
<div className="relative z-10 flex w-full max-w-[500px] flex-col gap-4">
<div className="grid gap-2 px-6 pb-2">
<h1 className="m-0 text-2xl font-semibold tracking-tight">Web analytics on RawTree</h1>
<h1 className="m-0 text-2xl font-semibold tracking-tight">{connected ? "Choose your database" : "Web analytics on RawTree"}</h1>
<p className="m-0 text-sm text-muted-foreground">
Send your own events and session recordings with one SDK, keep every row in your RawTree database, and see traffic, acquisition,
engagement, and bots without counting retries twice.
{connected
? "Pick the RawTree organization, cluster, and database where your events are stored."
: "Send your own events and session recordings with one SDK, keep every row in your RawTree database, and see traffic, acquisition, engagement, and bots without counting retries twice."}
</p>
</div>

<section className={cn(panel, "grid gap-5")}>
<div className="grid gap-3">
<h2 className="m-0 text-lg font-semibold">Connect to RawTree</h2>
{connector ? (
{connected ? (
<div className="flex items-center justify-between gap-3">
<p className="m-0 flex items-center gap-2 text-sm font-semibold">
<span className="flex size-5 items-center justify-center rounded-full bg-success/15 text-success">
<IconCheck aria-hidden className="size-3.5" />
</span>
Connected to RawTree
</p>
<button className={cn(link, "text-sm")} form="disconnect" type="submit">
Disconnect
</button>
</div>
) : (
<h2 className="m-0 text-lg font-semibold">Connect to RawTree</h2>
)}
{connector && !connected ? (
<nav aria-label="Connection method" className="grid grid-cols-2 gap-1 rounded-full bg-muted p-1">
{[
{ label: "RawTree account", href: "/sign-in", active: !useKey },
Expand All @@ -162,23 +179,18 @@ export default async function SignInPage({ searchParams }: { searchParams: Promi
))}
</nav>
) : null}
<p className="m-0 text-sm">
{useKey ? (
<>
Enter an API key that can <strong>read and write</strong> your analytics database: the dashboard reads it, and the console sends test
events to it.
</>
) : connected ? (
<>
Connected to RawTree. Choose where your events are stored.{" "}
<button className={link} form="disconnect" type="submit">
Disconnect
</button>
</>
) : (
"Sign in with your RawTree account to see your traffic, acquisition, engagement, bots, and session replays."
)}
</p>
{connected ? null : (
<p className="m-0 text-sm">
{useKey ? (
<>
Enter an API key that can <strong>read and write</strong> your analytics database: the dashboard reads it, and the console sends
test events to it.
</>
) : (
"Sign in with your RawTree account to see your traffic, acquisition, engagement, bots, and session replays."
)}
</p>
)}
{expired ? <p className="m-0 rounded-lg bg-muted px-3 py-2 text-sm">RawTree rejected your credentials. Sign in again.</p> : null}
{picker.error && !useKey ? <p className="m-0 text-sm text-muted-foreground">{picker.error}</p> : null}
</div>
Expand All @@ -196,80 +208,86 @@ export default async function SignInPage({ searchParams }: { searchParams: Promi
</div>
)}
</section>
{/* Target of the Disconnect button, which sits inside a paragraph. */}
{/* Target of the Disconnect button, which sits outside the picker's form. */}
<form action={signOut} className="hidden" id="disconnect" />

<aside className={cn(panel, "grid gap-2 text-sm")}>
{useKey ? (
<>
<p className="m-0 font-semibold">Your key stays on the server side of this app</p>
<p className="m-0 text-muted-foreground">
It's kept in an HTTP-only cookie that page scripts can't read, used only to run the dashboard's own queries and the console's test
events, never logged, and cleared when you sign out or close the browser. Create one with{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">rtree key create --permission read_write --database web_analytics</code>.
</p>
</>
) : (
<>
<p className="m-0 font-semibold">No keys to copy or paste</p>
<p className="m-0 text-muted-foreground">
<span className="text-foreground">Vercel Connect</span> holds your RawTree grant; this browser only keeps a session cookie. The dashboard
runs its own fixed queries and never accepts SQL from the browser. Disconnect revokes access.
</p>
</>
)}
</aside>
{connected ? null : (
<aside className={cn(panel, "grid gap-2 text-sm")}>
{useKey ? (
<>
<p className="m-0 font-semibold">Your key stays on the server side of this app</p>
<p className="m-0 text-muted-foreground">
It's kept in an HTTP-only cookie that page scripts can't read, used only to run the dashboard's own queries and the console's test
events, never logged, and cleared when you sign out or close the browser. Create one with{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">rtree key create --permission read_write --database web_analytics</code>.
</p>
</>
) : (
<>
<p className="m-0 font-semibold">No keys to copy or paste</p>
<p className="m-0 text-muted-foreground">
<span className="text-foreground">Vercel Connect</span> holds your RawTree grant; this browser only keeps a session cookie. The dashboard
runs its own fixed queries and never accepts SQL from the browser. Disconnect revokes access.
</p>
</>
)}
</aside>
)}
</div>

<figure className={cn(panel, "relative z-10 m-0 mt-12 w-full max-w-[1120px] p-3 sm:p-6")}>
<figcaption className="mb-4 grid gap-1 px-2 pt-2 text-center sm:mb-6 sm:pt-0">
<span className="text-lg font-semibold">What you'll see</span>
<span className="text-sm text-muted-foreground">Traffic, acquisition, engagement, bots, and session replays. Preview uses sample data.</span>
</figcaption>
<Image
alt="Dashboard preview with headline numbers, the traffic trend, and the dashboard sections"
className="h-auto w-full rounded-xl border"
placeholder="blur"
sizes="(min-width: 1152px) 1072px, calc(100vw - 56px)"
src={overview}
/>
</figure>
{connected ? null : (
<>
<figure className={cn(panel, "relative z-10 m-0 mt-12 w-full max-w-[1120px] p-3 sm:p-6")}>
<figcaption className="mb-4 grid gap-1 px-2 pt-2 text-center sm:mb-6 sm:pt-0">
<span className="text-lg font-semibold">What you'll see</span>
<span className="text-sm text-muted-foreground">Traffic, acquisition, engagement, bots, and session replays. Preview uses sample data.</span>
</figcaption>
<Image
alt="Dashboard preview with headline numbers, the traffic trend, and the dashboard sections"
className="h-auto w-full rounded-xl border"
placeholder="blur"
sizes="(min-width: 1152px) 1072px, calc(100vw - 56px)"
src={overview}
/>
</figure>

<section aria-labelledby="how-it-works" className={cn(panel, "relative z-10 mt-6 w-full max-w-[1120px]")}>
<div className="mb-6 grid gap-1 text-center">
<h2 className="m-0 text-lg font-semibold" id="how-it-works">
How it works
</h2>
<p className="m-0 text-sm text-muted-foreground">From your app to your dashboard.</p>
</div>
<ol className="m-0 grid list-none gap-3 p-0 md:grid-cols-4">
{HOW_IT_WORKS.map(({ icon: Icon, title, body }, index) => (
<li className="grid content-start gap-3 rounded-xl border bg-muted/50 p-4" key={title}>
<div className="flex items-center justify-between">
<span className="flex size-9 items-center justify-center rounded-full bg-primary/10 text-primary">
<Icon aria-hidden className="size-4" />
</span>
<span className="numeric text-xs text-muted-foreground">0{index + 1}</span>
</div>
<div className="grid gap-1">
<h3 className="m-0 text-sm font-semibold">{title}</h3>
<p className="m-0 text-sm text-muted-foreground">{body}</p>
</div>
</li>
))}
</ol>
<div className="mt-4 flex flex-col gap-3 rounded-xl border border-primary/15 bg-primary/5 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<p className="m-0 flex items-start gap-2">
<IconShieldCheck aria-hidden className="mt-0.5 size-4 shrink-0 text-primary" />
{useKey
? "Your site sends events with an insert-only key. Your dashboard key stays in an HTTP-only cookie."
: "Your site sends events with an insert-only key. Viewers sign in with their own RawTree account."}
</p>
<External className={cn(link, "shrink-0")} href={`${LINKS.github}#-how-it-works`}>
Technical details
</External>
</div>
</section>
<section aria-labelledby="how-it-works" className={cn(panel, "relative z-10 mt-6 w-full max-w-[1120px]")}>
<div className="mb-6 grid gap-1 text-center">
<h2 className="m-0 text-lg font-semibold" id="how-it-works">
How it works
</h2>
<p className="m-0 text-sm text-muted-foreground">From your app to your dashboard.</p>
</div>
<ol className="m-0 grid list-none gap-3 p-0 md:grid-cols-4">
{HOW_IT_WORKS.map(({ icon: Icon, title, body }, index) => (
<li className="grid content-start gap-3 rounded-xl border bg-muted/50 p-4" key={title}>
<div className="flex items-center justify-between">
<span className="flex size-9 items-center justify-center rounded-full bg-primary/10 text-primary">
<Icon aria-hidden className="size-4" />
</span>
<span className="numeric text-xs text-muted-foreground">0{index + 1}</span>
</div>
<div className="grid gap-1">
<h3 className="m-0 text-sm font-semibold">{title}</h3>
<p className="m-0 text-sm text-muted-foreground">{body}</p>
</div>
</li>
))}
</ol>
<div className="mt-4 flex flex-col gap-3 rounded-xl border border-primary/15 bg-primary/5 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<p className="m-0 flex items-start gap-2">
<IconShieldCheck aria-hidden className="mt-0.5 size-4 shrink-0 text-primary" />
{useKey
? "Your site sends events with an insert-only key. Your dashboard key stays in an HTTP-only cookie."
: "Your site sends events with an insert-only key. Viewers sign in with their own RawTree account."}
</p>
<External className={cn(link, "shrink-0")} href={`${LINKS.github}#-how-it-works`}>
Technical details
</External>
</div>
</section>
</>
)}
</main>

<footer className="border-t">
Expand Down
20 changes: 11 additions & 9 deletions components/console/console-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ export function ConsoleApp({ database }: { database: string }) {
<div className="flex min-h-0 flex-1 flex-col gap-4">
<ConsentBar database={database} />
<div className="grid min-h-0 flex-1 gap-4 @5xl:grid-cols-[400px_minmax(0,1fr)]">
<div className="grid min-h-0 auto-rows-max content-start gap-4 @5xl:-mr-2 @5xl:overflow-y-auto @5xl:overscroll-contain @5xl:pr-2">
{/* The scroll container clips what's outside its box; the padding (offset by the negative margin) keeps the cards' ring and shadow visible. */}
<div className="grid min-h-0 auto-rows-max content-start gap-4 @5xl:-m-2 @5xl:overflow-y-auto @5xl:overscroll-contain @5xl:p-2">
<BrowserEventsCard />
<ServerEventsCard />
<TrafficCard />
Expand Down Expand Up @@ -189,7 +190,7 @@ function ConsentBar({ database }: { database: string }) {
return (
<section aria-label="Consent" className="flex shrink-0 flex-wrap items-center gap-x-8 gap-y-3 rounded-2xl bg-card px-5 py-3 text-sm shadow-soft ring-1 ring-foreground/10">
<div className="flex items-center gap-2 font-semibold">
<IconShieldCheck className="size-4 text-primary" /> Consent
<IconShieldCheck className="size-4 -translate-y-px text-muted-foreground" /> Consent
<span className="text-xs font-normal text-muted-foreground">Nothing is sent until analytics is allowed. Stored in localStorage.</span>
</div>
<label className="flex items-center gap-3">
Expand All @@ -208,7 +209,7 @@ function ConsentBar({ database }: { database: string }) {
)}
</label>
<div className="flex w-full flex-wrap items-center gap-x-4 gap-y-1.5 border-t pt-2.5 text-xs text-muted-foreground">
<span>
<span className="w-full">
Collector <Mono>{COLLECT_ENDPOINT}</Mono> · Database <Mono>{database}</Mono>
</span>
<IdField label="anonymous_id" value={identity?.anonymousId} testId="anonymous-id" variant="info" />
Expand All @@ -221,7 +222,8 @@ function ConsentBar({ database }: { database: string }) {

const cardTitle = (icon: ReactNode, text: string) => (
<span className="flex items-center gap-2">
{icon}
{/* Centered on the line box, the icon reads low next to Geist's capitals; nudge it up. */}
<span className="-translate-y-px text-muted-foreground">{icon}</span>
{text}
</span>
);
Expand All @@ -247,7 +249,7 @@ function BrowserEventsCard() {

return (
<DashboardCard
title={cardTitle(<IconBrowser className="size-4 text-primary" />, "Browser events")}
title={cardTitle(<IconBrowser className="size-4" />, "Browser events")}
description={consent.analytics ? "Recipe-shaped events with example properties." : "Turn on analytics consent to send events."}
>
<div className="flex flex-col gap-4">
Expand Down Expand Up @@ -286,7 +288,7 @@ function ServerEventsCard() {
const { lastServerEvent } = useConsole();
return (
<DashboardCard
title={cardTitle(<IconServer className="size-4 text-primary" />, "Server events")}
title={cardTitle(<IconServer className="size-4" />, "Server events")}
description="Sent by this app's server with createServerAnalytics and a stable event ID."
info="Server events need no consent: they record outcomes your backend already knows. Resending with the same ID still counts once."
>
Expand Down Expand Up @@ -314,7 +316,7 @@ function TrafficCard() {
const { consent } = useConsole();
return (
<DashboardCard
title={cardTitle(<IconRobot className="size-4 text-primary" />, "Simulated traffic")}
title={cardTitle(<IconRobot className="size-4" />, "Simulated traffic")}
info="Each click is a new visitor (own anonymous_id and session_id) sending one page_view. Your own IDs are untouched."
description={consent.analytics ? "Crawler visits and campaign visits for the dashboard." : "Turn on analytics consent to send events."}
>
Expand Down Expand Up @@ -389,7 +391,7 @@ function PlaygroundCard() {
const panelSeen = open || recordingBytesSent > 0 ? found[SECRETS.panel] : undefined;
return (
<DashboardCard
title={cardTitle(<IconPlayerRecord className="size-4 text-primary" />, "Recording playground")}
title={cardTitle(<IconPlayerRecord className="size-4" />, "Recording playground")}
info="Session recording captures the page structure and every change to it (plus clicks, scrolls, and typing), not video. Replay rebuilds the page from that data."
description={recording === "off" ? "Turn on session recording in the consent row first." : "Change the page and check what stays private."}
action={
Expand Down Expand Up @@ -608,7 +610,7 @@ function EventViewer() {
return (
<DashboardCard
className="max-@5xl:max-h-[80vh]"
title={cardTitle(<IconCloudUpload className="size-4 text-primary" />, "Event viewer")}
title={cardTitle(<IconCloudUpload className="size-4" />, "Event viewer")}
description="Newest first. Queued → Sent (collector 200) → Stored (found in RawTree). Click a row for the JSON as sent."
action={<Badge>{entries.length} rows</Badge>}
>
Expand Down
Loading
Loading