-
+
+
+
-
- {isAuthenticated
- ? isEn
- ? "Subscription required"
- : "需要开通订阅"
- : isEn
- ? "Sign in and subscribe to enter"
- : "登录并付费后进入产品"}
+
+ {isEn ? "Sign in to continue" : "请先登录"}
-
- {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."
- : "落地页公开展示;天气交易决策台仅向付费用户开放。"}
+
+ {isEn
+ ? "The weather terminal is for verified subscribers only."
+ : "天气决策台仅对已验证的付费用户开放。"}
-
- {isAuthenticated ? (
-
-
- {isEn ? "Subscribe in Account" : "去账户中心付费"}
-
- ) : (
-
-
- {isEn ? "Log in to continue" : "登录后继续"}
-
- )}
-
- {isEn ? "Product overview" : "产品介绍"}
-
-
+
+
+ {isEn ? "Log in" : "登录"}
+
+
+ {isEn ? "Create an account" : "注册账号"}
+
+
+ {isEn ? "← Learn about PolyWeather" : "← 了解 PolyWeather"}
+
@@ -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
;
+ }
+ // Layer 2: logged in, no subscription
+ return (
+
+
+
+
+
+
+ Terminal
+
+ {userLocalTime}
+
+
+
+
+ );
+}
+
function ScanTerminalScreen() {
const [proAccess, setProAccess] = useState
(() =>
createEmptyAccess(true),
diff --git a/frontend/components/landing/InstitutionalLandingPage.tsx b/frontend/components/landing/InstitutionalLandingPage.tsx
index de751d92..0ea546e7 100644
--- a/frontend/components/landing/InstitutionalLandingPage.tsx
+++ b/frontend/components/landing/InstitutionalLandingPage.tsx
@@ -157,7 +157,7 @@ function InstitutionalLandingScreen() {
{isEn ? "Sign Up" : "注册"}
{isEn ? "Enter Product" : "进入产品"}
diff --git a/frontend/middleware.ts b/frontend/middleware.ts
index 4f9b9ffc..ba705d21 100644
--- a/frontend/middleware.ts
+++ b/frontend/middleware.ts
@@ -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 {
+ 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*",