双层鉴权架构:中间件终端门控 + 落地页按钮直连登录

LandingPage "进入产品"按钮改为 /auth/login?next=/terminal,消除未登录直达终端
再跳登录的视觉卡顿。Middleware 新增 handleTerminalGate(Layer 1),
在 /terminal 路由独立拦截未认证用户并 302 到登录页。
ScanTerminalDashboard 内 ProductAccessRequired 重构为 SubscriptionGate(Layer 2),
专注展示升级订阅提示。

Constraint: 双层顺序为认证优先 → 订阅检查在后
Tested: tsc --noEmit 通过, npm run build 通过
This commit is contained in:
2569718930@qq.com
2026-05-25 01:21:44 +08:00
parent af97173eed
commit 0574b0cc20
3 changed files with 215 additions and 48 deletions
@@ -643,12 +643,111 @@ function KoyfinWeatherTerminal({
);
}
function ProductAccessRequired({
isAuthenticated,
// ─── Layer 2: Authenticated but no active subscription ───────────────────────
// Shown inside the terminal shell after middleware has confirmed the user is
// logged in (Layer 1). Presents a targeted upgrade prompt.
function SubscriptionGate({ isEn }: { isEn: boolean }) {
const features = isEn
? [
"Real-time METAR observations across 500+ stations",
"DEB forecast blends with 0240h horizon",
"AI decision cards with Poly-score ranking",
"Historical backtesting & weather market signals",
]
: [
"500+ 气象站实时 METAR 实况",
"DEB 智能融合预测(0240 小时)",
"AI 决策卡片 + Poly-score 排名",
"历史回测与天气市场交易信号",
];
return (
<div className="flex flex-1 flex-col items-center justify-center bg-[#e9edf3] p-6">
<div className="w-full max-w-lg">
{/* Header badge */}
<div className="mb-6 flex items-center justify-center">
<span className="inline-flex items-center gap-2 rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-bold uppercase tracking-wider text-amber-700">
<LockKeyhole size={12} />
{isEn ? "Pro Access Required" : "需要付费订阅"}
</span>
</div>
{/* Main card */}
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg">
{/* Card top band */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 px-8 py-6 text-white">
<h1 className="text-xl font-black tracking-tight">
{isEn
? "Unlock the Weather Terminal"
: "解锁天气交易决策台"}
</h1>
<p className="mt-1 text-sm text-blue-100">
{isEn
? "Your account is verified. One step away from full access."
: "账号已验证,只差一步即可获得完整访问权限。"}
</p>
</div>
<div className="p-8">
{/* Price */}
<div className="mb-6 flex items-baseline gap-1">
<span className="text-4xl font-black text-slate-900">$10</span>
<span className="text-base text-slate-500">
{isEn ? "/ month" : "/ 月"}
</span>
</div>
{/* Feature list */}
<ul className="mb-8 space-y-3">
{features.map((f) => (
<li key={f} className="flex items-start gap-3 text-sm text-slate-700">
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-blue-600 text-white">
<svg width="8" height="6" viewBox="0 0 8 6" fill="none">
<path d="M1 3L3 5L7 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
{f}
</li>
))}
</ul>
{/* CTA */}
<Link
href="/account"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 py-3.5 text-sm font-black text-white shadow-sm transition hover:bg-blue-700"
>
<CreditCard size={16} />
{isEn ? "Subscribe Now — $10/mo" : "立即订阅 — $10/月"}
</Link>
<p className="mt-4 text-center text-[11px] text-slate-400">
{isEn
? "Cancel anytime · No hidden fees · Instant access after payment"
: "随时取消 · 无隐藏费用 · 付款后立即解锁"}
</p>
</div>
</div>
{/* Back link */}
<div className="mt-5 text-center">
<Link
href="/"
className="text-xs text-slate-500 hover:text-slate-800 transition-colors"
>
{isEn ? "Back to product overview" : "返回产品介绍页"}
</Link>
</div>
</div>
</div>
);
}
// ─── Layer 1 fallback: Should not normally appear (middleware handles it) ─────
// Shown only when middleware is not configured (no Supabase env).
function UnauthenticatedGate({
isEn,
userLocalTime,
}: {
isAuthenticated: boolean;
isEn: boolean;
userLocalTime: string;
}) {
@@ -664,53 +763,37 @@ function ProductAccessRequired({
<div className="font-mono text-sm text-slate-300">{userLocalTime}</div>
</header>
<section className="grid flex-1 place-items-center p-6">
<div className="w-full max-w-xl rounded border border-slate-300 bg-white p-8 text-center shadow-sm">
<div className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded bg-blue-50 text-blue-700">
<LockKeyhole size={28} />
<div className="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-lg">
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-slate-100 text-slate-600">
<LogIn size={24} />
</div>
<h1 className="text-2xl font-black">
{isAuthenticated
? isEn
? "Subscription required"
: "需要开通订阅"
: isEn
? "Sign in and subscribe to enter"
: "登录并付费后进入产品"}
<h1 className="text-xl font-black text-slate-900">
{isEn ? "Sign in to continue" : "请先登录"}
</h1>
<p className="mt-3 text-sm leading-6 text-slate-600">
{isAuthenticated
? isEn
? "Your account is signed in, but the Koyfin-style weather terminal is locked until payment is active."
: "你已登录,但 Koyfin 风格天气交易终端会在付款生效后解锁。"
: isEn
? "The landing page is public. The decision terminal is paid-only."
: "落地页公开展示;天气交易决策台仅向付费用户开放。"}
<p className="mt-2 text-sm text-slate-500">
{isEn
? "The weather terminal is for verified subscribers only."
: "天气决策台仅对已验证的付费用户开放。"}
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
{isAuthenticated ? (
<Link
href="/account"
className="inline-flex min-h-10 items-center gap-2 rounded border border-blue-700 bg-blue-600 px-4 py-2 text-sm font-black text-white hover:bg-blue-700"
>
<CreditCard size={16} />
{isEn ? "Subscribe in Account" : "去账户中心付费"}
</Link>
) : (
<Link
href="/auth/login?next=%2Fterminal"
className="inline-flex min-h-10 items-center gap-2 rounded border border-blue-700 bg-blue-600 px-4 py-2 text-sm font-black text-white hover:bg-blue-700"
>
<LogIn size={16} />
{isEn ? "Log in to continue" : "登录后继续"}
</Link>
)}
<Link
href="/"
className="inline-flex min-h-10 items-center rounded border border-slate-300 bg-white px-4 py-2 text-sm font-black text-slate-700 hover:bg-slate-50"
>
{isEn ? "Product overview" : "产品介绍"}
</Link>
</div>
<Link
href="/auth/login?next=%2Fterminal"
className="mt-6 flex items-center justify-center gap-2 rounded-xl bg-slate-900 py-3 text-sm font-black text-white transition hover:bg-slate-700"
>
<LogIn size={15} />
{isEn ? "Log in" : "登录"}
</Link>
<Link
href="/auth/login?next=%2Fterminal&mode=signup"
className="mt-3 flex items-center justify-center gap-2 rounded-xl border border-slate-300 py-3 text-sm font-black text-slate-700 transition hover:bg-slate-50"
>
{isEn ? "Create an account" : "注册账号"}
</Link>
<Link
href="/"
className="mt-4 block text-xs text-slate-400 hover:text-slate-700 transition-colors"
>
{isEn ? "← Learn about PolyWeather" : "← 了解 PolyWeather"}
</Link>
</div>
</section>
</main>
@@ -718,6 +801,38 @@ function ProductAccessRequired({
);
}
/** Unified access gate — routes to the correct layer based on auth state */
function ProductAccessRequired({
isAuthenticated,
isEn,
userLocalTime,
}: {
isAuthenticated: boolean;
isEn: boolean;
userLocalTime: string;
}) {
// Layer 1 fallback (middleware should catch this first in production)
if (!isAuthenticated) {
return <UnauthenticatedGate isEn={isEn} userLocalTime={userLocalTime} />;
}
// Layer 2: logged in, no subscription
return (
<div className="flex h-screen w-full bg-[#e9edf3] text-slate-950">
<aside className="w-[52px] bg-[#171d24]" />
<main className="flex flex-1 flex-col">
<header className="flex h-[64px] items-center justify-between bg-[#171d24] px-4 text-white">
<Link href="/" className="flex items-center gap-2 hover:opacity-90">
<img src="/logo.png" alt="PolyWeather" className="h-7 w-auto object-contain" />
<span className="text-sm font-semibold tracking-tight text-white/90">Terminal</span>
</Link>
<div className="font-mono text-sm text-slate-300">{userLocalTime}</div>
</header>
<SubscriptionGate isEn={isEn} />
</main>
</div>
);
}
function ScanTerminalScreen() {
const [proAccess, setProAccess] = useState<ProAccessState>(() =>
createEmptyAccess(true),
@@ -157,7 +157,7 @@ function InstitutionalLandingScreen() {
{isEn ? "Sign Up" : "注册"}
</Link>
<Link
href="/terminal"
href="/auth/login?next=%2Fterminal"
className="inline-flex items-center gap-2 rounded-lg border border-blue-700 bg-blue-600 px-3 py-2 text-sm font-bold text-white shadow-sm transition hover:bg-blue-700"
>
{isEn ? "Enter Product" : "进入产品"}
+52
View File
@@ -55,6 +55,44 @@ function shouldRefreshOptionalSupabaseSession(pathname: string) {
);
}
// ─── Layer 1: Unauthenticated redirect for /terminal ─────────────────────────
// Runs for every /terminal request when Supabase is configured.
// Does NOT check subscription — that's handled client-side (Layer 2).
// This mirrors Koyfin: unauthenticated visitors are sent to /auth/login first.
async function handleTerminalGate(request: NextRequest): Promise<NextResponse> {
const { pathname } = request.nextUrl;
// Only gate /terminal routes
if (!pathname.startsWith("/terminal")) {
return NextResponse.next();
}
// No Supabase env → fall through to legacy token gate
if (!hasSupabaseServerEnv()) {
return NextResponse.next();
}
const response = NextResponse.next({
request: { headers: request.headers },
});
const supabase = createSupabaseMiddlewareClient(request, response);
const {
data: { user },
} = await supabase.auth.getUser();
if (user) {
// Authenticated — pass through. Terminal client handles subscription gate.
return response;
}
// Layer 1: Not logged in → redirect to /auth/login?next=/terminal
const loginUrl = request.nextUrl.clone();
loginUrl.pathname = "/auth/login";
loginUrl.search = "";
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
function handleLegacyTokenGate(request: NextRequest) {
const requiredToken = process.env.POLYWEATHER_DASHBOARD_ACCESS_TOKEN?.trim();
if (!requiredToken) {
@@ -160,6 +198,8 @@ export async function middleware(request: NextRequest) {
request.headers.get("x-forwarded-host") ||
request.headers.get("host") ||
request.nextUrl.host;
// Local development: bypass all gates
if (
isLocalFullAccessHost(requestHost) ||
isLocalFullAccessHost(request.nextUrl.hostname)
@@ -167,6 +207,17 @@ export async function middleware(request: NextRequest) {
return NextResponse.next();
}
const { pathname } = request.nextUrl;
// ── Terminal gate runs first, independently of global auth mode ──────────
// This is the Koyfin-style Layer 1: send unauthenticated users to /auth/login
// before they ever reach the terminal, eliminating the jarring "enter product
// then see a paywall" experience.
if (pathname.startsWith("/terminal") && hasSupabaseServerEnv()) {
return handleTerminalGate(request);
}
// ── Global auth modes ─────────────────────────────────────────────────────
if (SUPABASE_AUTH_ENABLED && hasSupabaseServerEnv()) {
if (SUPABASE_AUTH_REQUIRED) {
return handleSupabaseAuthGate(request);
@@ -180,6 +231,7 @@ export const config = {
matcher: [
"/account/:path*",
"/terminal/:path*",
"/terminal",
"/ops/:path*",
"/api/auth/:path*",
"/api/ops/:path*",