双层鉴权架构:中间件终端门控 + 落地页按钮直连登录
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:
@@ -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*",
|
||||
|
||||
Reference in New Issue
Block a user