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

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
+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*",