mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Both cookies were read straight into database columns after nothing more than a format check. httpOnly keeps JavaScript out of them and does nothing about curl, so the anonymous id was a string the caller picked: enough to write a first-touch row for a visitor who never existed, to file it under a channel of their choosing, and to claim that id's events at signup, since the backfill matches on the id alone. They are now signed with an HMAC over AUTH_SECRET, through Web Crypto rather than node:crypto because the proxy runs on the edge and the pages that read the cookies back run in Node. The first-touch body moved to base64url on the way: cookie values are percent-encoded and decoded by several layers that do not agree on how many times, and a payload carrying its own percent escapes comes back subtly different and takes the signature with it. Signing stops a caller choosing an id, not collecting one, since dropping the cookie and asking for the landing page again mints another. So the bot and prefetch filters moved to where the rows are written rather than only where the cookies are issued, which also fixes a returning visitor's prefetch of /register recording a signup start, and a per-client hourly ceiling now sits in front of the write. The ceiling is skipped when TRUSTED_PROXY_MODE is unset, where every caller resolves to 127.0.0.1 and the bucket would empty on real traffic long before it emptied on a flood. Four smaller things around it: - /api/events checked the flag and the origin after paying for a rate-limit write, so a host who never turned analytics on was still writing a row per anonymous POST. Both checks are free and now come first, and the limiter answers 204 rather than 429: a beacon has nobody to tell, and a flooder should not be handed the reset time. - /api/onboarding/source was keyed by IP on an authenticated route. Without TRUSTED_PROXY_MODE that is five answers an hour for the whole deployment, and with it a shared office address locks out everyone after one colleague answered. Keyed by account, like /api/onboarding/complete beside it. - The cookies took their Secure flag from request.nextUrl.protocol, which behind a TLS-terminating reverse proxy is the container-internal http address. It comes off the configured public origin now. - sanitizeLandingPath took anything that started with a slash, including from the cookie, so a hand-written one could put newlines and markup into a column an admin table may render one day. Also: the paid-account query had no LIMIT and returned every active account's name and email, the growth route answered 403 where it meant 401, and the schema claimed no free text is stored when self_reported_note holds 200 characters of it.
366 lines
15 KiB
TypeScript
366 lines
15 KiB
TypeScript
import { Metadata } from 'next';
|
|
import { redirect } from 'next/navigation';
|
|
import { auth } from '@/lib/auth';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import {
|
|
AT_RISK_SILENT_DAYS,
|
|
conversionRates,
|
|
getScoreboard,
|
|
type FunnelRates,
|
|
} from '@/lib/analytics/scoreboard';
|
|
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
|
import { AlertTriangle, CreditCard, TrendingUp, Users } from 'lucide-react';
|
|
|
|
export const metadata: Metadata = {
|
|
title: 'Growth | OpenFrame',
|
|
description: 'Acquisition funnel and retention scoreboard',
|
|
};
|
|
|
|
function formatMoney(cents: number | null, currency: string) {
|
|
if (cents === null) return '—';
|
|
const safeCurrency = /^[a-zA-Z]{3}$/.test(currency) ? currency.toUpperCase() : 'USD';
|
|
return new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: safeCurrency,
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0,
|
|
}).format(cents / 100);
|
|
}
|
|
|
|
function formatWeek(date: Date) {
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function formatDate(date: Date | null) {
|
|
return date ? date.toISOString().slice(0, 10) : 'never';
|
|
}
|
|
|
|
/** A percentage with the count it was computed from, because n matters here. */
|
|
function Rate({ rate, of }: { rate: number | null; of: number }) {
|
|
if (rate === null) return <span className="text-muted-foreground">—</span>;
|
|
return (
|
|
<span>
|
|
{Math.round(rate * 100)}%<span className="text-muted-foreground"> /{of}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const WEEK_COLUMNS: Array<{ key: string; label: string }> = [
|
|
{ key: 'visitors', label: 'Visitors' },
|
|
{ key: 'signups', label: 'Signup' },
|
|
{ key: 'firstVideo', label: 'Video' },
|
|
{ key: 'shareLinks', label: 'Share link' },
|
|
{ key: 'externalFeedback', label: 'Ext. feedback' },
|
|
{ key: 'trials', label: 'Trial' },
|
|
{ key: 'newPaid', label: 'New paid' },
|
|
{ key: 'canceled', label: 'Canceled' },
|
|
{ key: 'activePaid', label: 'Active paid' },
|
|
];
|
|
|
|
export default async function AdminGrowthPage() {
|
|
const session = await auth();
|
|
if (!session?.user?.isAdmin) {
|
|
redirect('/');
|
|
}
|
|
|
|
if (!isProductAnalyticsEnabled()) {
|
|
return (
|
|
<div className="flex-1 space-y-4 px-4 md:px-8">
|
|
<h2 className="text-3xl font-bold tracking-tight">Growth</h2>
|
|
<Card>
|
|
<CardContent className="pt-6 text-sm text-muted-foreground">
|
|
Acquisition tracking is off on this deployment. Set{' '}
|
|
<code className="font-mono">OPENFRAME_ENABLE_ANALYTICS=true</code> to start recording
|
|
the funnel. Nothing is collected until you do, and nothing is ever sent anywhere but
|
|
this instance's own database.
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const scoreboard = await getScoreboard();
|
|
const latest = scoreboard.weeks[scoreboard.weeks.length - 1];
|
|
const window = scoreboard.weeks.reduce(
|
|
(sum, week) => ({
|
|
visitors: sum.visitors + week.visitors,
|
|
signups: sum.signups + week.signups,
|
|
firstVideo: sum.firstVideo + week.firstVideo,
|
|
shareLinks: sum.shareLinks + week.shareLinks,
|
|
externalFeedback: sum.externalFeedback + week.externalFeedback,
|
|
trials: sum.trials + week.trials,
|
|
newPaid: sum.newPaid + week.newPaid,
|
|
}),
|
|
{
|
|
visitors: 0,
|
|
signups: 0,
|
|
firstVideo: 0,
|
|
shareLinks: 0,
|
|
externalFeedback: 0,
|
|
trials: 0,
|
|
newPaid: 0,
|
|
}
|
|
);
|
|
const overall: FunnelRates = conversionRates(window);
|
|
|
|
return (
|
|
<div className="flex-1 space-y-4 px-4 md:px-8">
|
|
<div className="flex items-center justify-between space-y-2">
|
|
<h2 className="text-3xl font-bold tracking-tight">Growth</h2>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Active paid</CardTitle>
|
|
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{scoreboard.currentActivePaid ?? '—'}</div>
|
|
<p className="text-xs text-muted-foreground">from Stripe, right now</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">MRR</CardTitle>
|
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{formatMoney(scoreboard.currentMrrCents, scoreboard.currency)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">from Stripe, right now</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Visitors this week</CardTitle>
|
|
<Users className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{latest?.visitors ?? 0}</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{latest ? `week of ${formatWeek(latest.weekStart)}` : 'no data yet'}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">At risk</CardTitle>
|
|
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{scoreboard.atRisk.length}</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
paid, silent for {AT_RISK_SILENT_DAYS}+ days
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Weekly funnel</CardTitle>
|
|
<p className="text-sm text-muted-foreground">
|
|
Weeks start Monday, UTC. Active paid is the running net of subscriptions started minus
|
|
canceled, so it can drift from the Stripe figure above; the difference is the drift.
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b text-left text-muted-foreground">
|
|
<th className="py-2 pr-4 font-medium">Week</th>
|
|
{WEEK_COLUMNS.map((column) => (
|
|
<th key={column.key} className="py-2 pr-4 text-right font-medium">
|
|
{column.label}
|
|
</th>
|
|
))}
|
|
<th className="py-2 pr-4 text-right font-medium">MRR</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{scoreboard.weeks.map((week) => (
|
|
<tr key={week.weekStart.toISOString()} className="border-b last:border-0">
|
|
<td className="py-2 pr-4 font-mono text-xs">{formatWeek(week.weekStart)}</td>
|
|
{WEEK_COLUMNS.map((column) => (
|
|
<td key={column.key} className="py-2 pr-4 text-right tabular-nums">
|
|
{week[column.key as keyof typeof week] as number}
|
|
</td>
|
|
))}
|
|
<td className="py-2 pr-4 text-right tabular-nums">
|
|
{formatMoney(week.mrrCents, scoreboard.currency)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Where it narrows</CardTitle>
|
|
<p className="text-sm text-muted-foreground">
|
|
Every step over the whole {scoreboard.weeks.length}-week window, with the denominator
|
|
beside it. The lowest rate is the step to work on.
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-4 sm:grid-cols-3 lg:grid-cols-5 text-sm">
|
|
<div>
|
|
<div className="text-muted-foreground">Visitor to signup</div>
|
|
<div className="text-lg font-semibold">
|
|
<Rate rate={overall.visitorToSignup} of={window.visitors} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground">Signup to first video</div>
|
|
<div className="text-lg font-semibold">
|
|
<Rate rate={overall.signupToFirstVideo} of={window.signups} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground">Video to share link</div>
|
|
<div className="text-lg font-semibold">
|
|
<Rate rate={overall.firstVideoToShare} of={window.firstVideo} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground">Share to outside feedback</div>
|
|
<div className="text-lg font-semibold">
|
|
<Rate rate={overall.shareToFeedback} of={window.shareLinks} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground">Trial to paid</div>
|
|
<div className="text-lg font-semibold">
|
|
<Rate rate={overall.trialToPaid} of={window.trials} />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">By source</CardTitle>
|
|
<p className="text-sm text-muted-foreground">
|
|
Rolling {scoreboard.channelWindowDays} days rather than one week: a weekly per-source
|
|
cell holds single digits at this volume, and a percentage computed from three visits
|
|
reads exactly as confidently as one computed from three hundred.
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b text-left text-muted-foreground">
|
|
<th className="py-2 pr-4 font-medium">Source</th>
|
|
<th className="py-2 pr-4 text-right font-medium">Visitors</th>
|
|
<th className="py-2 pr-4 text-right font-medium">Signup</th>
|
|
<th className="py-2 pr-4 text-right font-medium">Trial</th>
|
|
<th className="py-2 pr-4 text-right font-medium">Paid</th>
|
|
<th className="py-2 pr-4 text-right font-medium">Visitor to signup</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{scoreboard.channels.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="py-4 text-muted-foreground">
|
|
Nothing recorded in this window yet.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{scoreboard.channels.map((row) => (
|
|
<tr key={row.channel} className="border-b last:border-0">
|
|
<td className="py-2 pr-4">{row.channel.toLowerCase()}</td>
|
|
<td className="py-2 pr-4 text-right tabular-nums">{row.visitors}</td>
|
|
<td className="py-2 pr-4 text-right tabular-nums">{row.signups}</td>
|
|
<td className="py-2 pr-4 text-right tabular-nums">{row.trials}</td>
|
|
<td className="py-2 pr-4 text-right tabular-nums">{row.paid}</td>
|
|
<td className="py-2 pr-4 text-right tabular-nums">
|
|
<Rate
|
|
rate={row.visitors > 0 ? row.signups / row.visitors : null}
|
|
of={row.visitors}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Paid accounts</CardTitle>
|
|
<p className="text-sm text-muted-foreground">
|
|
Value events are videos, share links, outside feedback, approvals and projects. Rows
|
|
marked at risk have produced none for {AT_RISK_SILENT_DAYS} days.
|
|
{scoreboard.paidAccountsTruncated && (
|
|
<>
|
|
{' '}
|
|
Quietest {scoreboard.paidAccountLimit} only; there are more paid accounts than this
|
|
table shows.
|
|
</>
|
|
)}
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b text-left text-muted-foreground">
|
|
<th className="py-2 pr-4 font-medium">Account</th>
|
|
<th className="py-2 pr-4 font-medium">Status</th>
|
|
<th className="py-2 pr-4 font-medium">Source</th>
|
|
<th className="py-2 pr-4 text-right font-medium">7d</th>
|
|
<th className="py-2 pr-4 text-right font-medium">30d</th>
|
|
<th className="py-2 pr-4 font-medium">Last activity</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{scoreboard.paidAccounts.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="py-4 text-muted-foreground">
|
|
No active or trialing accounts.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{scoreboard.paidAccounts.map((account) => {
|
|
const atRisk = scoreboard.atRisk.some((row) => row.userId === account.userId);
|
|
return (
|
|
<tr key={account.userId} className="border-b last:border-0">
|
|
<td className="py-2 pr-4">
|
|
{account.name || account.email || account.userId}
|
|
{atRisk && (
|
|
<span className="ml-2 rounded bg-destructive/10 px-1.5 py-0.5 text-xs text-destructive">
|
|
at risk
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="py-2 pr-4 text-muted-foreground">
|
|
{account.status.toLowerCase()}
|
|
</td>
|
|
<td className="py-2 pr-4 text-muted-foreground">
|
|
{account.channel?.toLowerCase() ?? '—'}
|
|
{account.selfReported && account.selfReported !== account.channel && (
|
|
<span className="text-xs">
|
|
{' '}
|
|
(said {account.selfReported.toLowerCase()})
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="py-2 pr-4 text-right tabular-nums">{account.valueEvents7}</td>
|
|
<td className="py-2 pr-4 text-right tabular-nums">{account.valueEvents30}</td>
|
|
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
|
|
{formatDate(account.lastValueEventAt)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|