diff --git a/.env.example b/.env.example index 6216c409..c9e25631 100644 --- a/.env.example +++ b/.env.example @@ -94,7 +94,6 @@ SUPABASE_HTTP_TIMEOUT_SEC=8 SUPABASE_AUTH_CACHE_TTL_SEC=30 SUPABASE_SUB_CACHE_TTL_SEC=60 POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= -POLYWEATHER_SIGNUP_TRIAL_ENABLED=false POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION=decline ######################################## diff --git a/frontend/.env.example b/frontend/.env.example index 449d8f7f..36c74808 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -30,10 +30,6 @@ POLYWEATHER_AUTH_ENABLED=false # false: 登录可选,访客可浏览 POLYWEATHER_AUTH_REQUIRED=false -# 可选:分享式看板访问令牌 -# 设置后,可通过 /?access_token= 打开受保护看板 -POLYWEATHER_DASHBOARD_ACCESS_TOKEN= - # 可选:前端 API Route 转发到后端时附带的共享令牌 # 仅当后端启用了 entitlement / 订阅校验时需要 POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= diff --git a/frontend/app/account/error.tsx b/frontend/app/account/error.tsx index ce9a3026..3e34c86d 100644 --- a/frontend/app/account/error.tsx +++ b/frontend/app/account/error.tsx @@ -2,14 +2,18 @@ import { RefreshCw } from "lucide-react"; import { useEffect } from "react"; +import { I18nProvider, useI18n } from "@/hooks/useI18n"; -export default function AccountErrorPage({ +function AccountErrorContent({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { + const { locale } = useI18n(); + const isEn = locale === "en-US"; + useEffect(() => { console.error("Account page error:", error); }, [error]); @@ -38,7 +42,7 @@ export default function AccountErrorPage({ color: "var(--color-accent-primary, #4DA3FF)", }} > - 账户页面出错 + {isEn ? "Account Page Error" : "账户页面出错"}

- 如果是在支付或绑定钱包时出现此问题,常见原因是钱包插件冲突(例如同时开启了 MetaMask 和 - Rabby)。请尝试关闭其他钱包插件后刷新页面重试。 -

-

- If this happened during payment or wallet binding, the most common cause is - conflicting wallet extensions. Try disabling other wallet extensions (e.g. - MetaMask + Rabby) and refresh. + {isEn + ? "If this happened during payment or wallet binding, the most common cause is conflicting wallet extensions (e.g. MetaMask and Rabby open at the same time). Try disabling other wallet extensions and refresh." + : "如果是在支付或绑定钱包时出现此问题,常见原因是钱包插件冲突(例如同时开启了 MetaMask 和 Rabby)。请尝试关闭其他钱包插件后刷新页面重试。"}

); } + +export default function AccountErrorPage({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + + + ); +} diff --git a/frontend/app/entitlement-required/EntitlementRequiredClient.tsx b/frontend/app/entitlement-required/EntitlementRequiredClient.tsx new file mode 100644 index 00000000..5c132602 --- /dev/null +++ b/frontend/app/entitlement-required/EntitlementRequiredClient.tsx @@ -0,0 +1,131 @@ +"use client"; + +import Link from "next/link"; +import { useI18n } from "@/hooks/useI18n"; + +export function EntitlementRequiredClient({ nextPath }: { nextPath: string }) { + const { locale } = useI18n(); + const isEn = locale === "en-US"; + + const copy = { + title: isEn ? "Sign in required" : "需要登录方可访问此页面", + desc: isEn + ? "This page requires you to sign in. Please sign in and try again." + : "本页面需要登录权限。请登录后重试。", + signIn: isEn ? "Sign in" : "去登录", + backHome: isEn ? "Back to Home" : "返回首页", + legacyNote: isEn ? "Legacy token mode still supported" : "传统令牌模式仍支持", + requestedPath: isEn ? "Requested path" : "请求路径", + }; + + return ( +
+
+

+ {copy.title} +

+

+ {copy.desc} +

+
+ + {copy.signIn} + + + {copy.backHome} + +
+

+ {copy.legacyNote}{" "} + + ?access_token=<your-token> + +
+ + {copy.requestedPath}: {nextPath} + +

+
+
+ ); +} diff --git a/frontend/app/entitlement-required/page.tsx b/frontend/app/entitlement-required/page.tsx index b856ec44..a2f3a6ec 100644 --- a/frontend/app/entitlement-required/page.tsx +++ b/frontend/app/entitlement-required/page.tsx @@ -1,4 +1,5 @@ -import Link from "next/link"; +import { EntitlementRequiredClient } from "./EntitlementRequiredClient"; +import { I18nProvider } from "@/hooks/useI18n"; type Props = { searchParams?: Promise<{ next?: string }>; @@ -9,98 +10,8 @@ export default async function EntitlementRequiredPage({ searchParams }: Props) { const nextPath = params.next || "/"; return ( -
-
-

- 需要登录方可访问此页面 -

-

- 本页面需要登录权限。请登录后重试。 -
- Sign in required to access this page. -

-
- - 去登录 / Sign in - - - 返回首页 / Back to Home - -
-

- 传统令牌模式仍支持 ?access_token=<your-token> -
- - 请求路径 / Requested path: {nextPath} - -

-
-
+ + + ); } diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index e62b64a5..3b4754c8 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -888,7 +888,6 @@ export function AccountCenter() { const joinedAt = formatTime(user?.created_at, locale); const isSubscribed = Boolean(backend?.subscription_active); const planCode = String(backend?.subscription_plan_code || "").trim(); - const isTrialPlan = /trial/i.test(planCode); const currentExpiryRaw = String( backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "", ).trim(); @@ -904,7 +903,7 @@ export function AccountCenter() { ); const hasQueuedExtension = Boolean(isSubscribed && queuedExtensionDays > 0); const canAccessPaidTelegramGroup = Boolean( - isSubscribed && (!isTrialPlan || hasQueuedExtension), + isSubscribed, ); const telegramBound = Number(backend?.telegram_pricing?.telegram_id || 0) > 0; const displayExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw; @@ -933,7 +932,7 @@ export function AccountCenter() { const paymentFeatureReady = paymentReadyForRecovery; const canOpenCheckoutOverlay = Boolean( paymentFeatureReady && - (!isSubscribed || isTrialPlan || showExpiringSoon || showExpiredReminder), + (!isSubscribed || showExpiringSoon || showExpiredReminder), ); const subscriptionStatusTitle = showExpiredReminder ? copy.proExpiredTitle @@ -2237,7 +2236,7 @@ export function AccountCenter() { > {isSubscribed ? copy.proMember - : copy.freeTier} + : isEn ? "UNSUBSCRIBED" : "未订阅"}

diff --git a/frontend/components/account/account-copy.ts b/frontend/components/account/account-copy.ts index a6a1c01d..91f951f2 100644 --- a/frontend/components/account/account-copy.ts +++ b/frontend/components/account/account-copy.ts @@ -129,7 +129,6 @@ export function createAccountCopy(isEn: boolean): Record { ? "Wallet bound. Creating order and sending payment..." : "钱包已绑定,正在创建订单并发起支付...", proMember: "PRO MEMBER", - freeTier: isEn ? "UNSUBSCRIBED" : "未订阅", proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)", noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅", proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期", diff --git a/frontend/components/dashboard/ScanTerminalContinent.module.css b/frontend/components/dashboard/ScanTerminalContinent.module.css new file mode 100644 index 00000000..7c8b11ab --- /dev/null +++ b/frontend/components/dashboard/ScanTerminalContinent.module.css @@ -0,0 +1,65 @@ +/* ScanTerminalContinent — continent group headers and mobile cards */ + +.root { + --gap-green: #16a34a; + --gap-orange: #ea580c; + --gap-slate: #475569; + --gap-gray: #94a3b8; + --gap-red: #dc2626; +} + +/* Group header row */ +.groupHeader { + background: #eef2f6; + border-bottom: 1px solid #cbd5e1; +} +.groupHeader:hover { + background: #e2e8f0; +} + +/* Mobile: hide scrollbar on tab bar */ +.mobileTabs { + scrollbar-width: none; + -ms-overflow-style: none; +} +.mobileTabs::-webkit-scrollbar { + display: none; +} + +/* Mobile card */ +.mobileCard { + border: 1px solid #e2e8f0; + background: #ffffff; + border-radius: 8px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); +} +.mobileCard:hover { + background: #f0f7ff; +} + +/* Signal badge colors */ +.signalActive { + color: #059669; +} +.signalWatch { + color: #d97706; +} +.signalClosed { + color: #94a3b8; +} +.signalData { + color: #dc2626; +} + +/* Light theme overrides — match scan-terminal's .scan-terminal.light scope */ +:global(.scan-terminal.light) .groupHeader { + background: #f8fafc; + border-bottom-color: #e2e8f0; +} +:global(.scan-terminal.light) .groupHeader:hover { + background: #f1f5f9; +} +:global(.scan-terminal.light) .mobileCard { + background: #ffffff; + border-color: #e2e8f0; +} diff --git a/frontend/components/dashboard/scan-root-styles.ts b/frontend/components/dashboard/scan-root-styles.ts index 919b3a69..7a1241ad 100644 --- a/frontend/components/dashboard/scan-root-styles.ts +++ b/frontend/components/dashboard/scan-root-styles.ts @@ -22,6 +22,7 @@ import scanTerminalListStyles from "./ScanTerminalList.module.css"; import scanTerminalMobileStyles from "./ScanTerminalMobile.module.css"; import scanTerminalOpportunityStyles from "./ScanTerminalOpportunity.module.css"; import scanTerminalShellStyles from "./ScanTerminalShell.module.css"; +import scanTerminalContinentStyles from "./ScanTerminalContinent.module.css"; import scanTerminalStateStyles from "./ScanTerminalState.module.css"; export const scanRootClass = clsx( @@ -38,6 +39,7 @@ export const scanRootClass = clsx( scanTerminalStateStyles.root, scanTerminalOpportunityStyles.root, scanTerminalCardStyles.root, + scanTerminalContinentStyles.root, scanTerminalMobileStyles.root, detailChromeStyles.root, detailContentStyles.root, diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index 2dfdfc91..e2e79ffb 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -1260,18 +1260,6 @@ export function DashboardStoreProvider({ }); } - const isTrialPlan = /trial/i.test( - String(proAccess.subscriptionPlanCode || ""), - ); - if ( - isTrialPlan && - markAnalyticsOnce(`signup-completed:${proAccess.userId}`, "local") - ) { - trackAppEvent("signup_completed", { - source: "auth_me_trial", - subscription_plan_code: proAccess.subscriptionPlanCode, - }); - } }, [ proAccess.authenticated, proAccess.loading, diff --git a/frontend/middleware.ts b/frontend/middleware.ts index ba705d21..50eb02ed 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -5,8 +5,6 @@ import { } from "@/lib/supabase/server"; import { isLocalFullAccessHost } from "@/lib/local-dev-access"; -const SESSION_COOKIE = "polyweather_entitlement"; - function readEnvBool(name: string, fallback: boolean) { const raw = process.env[name]; if (raw == null) return fallback; @@ -25,7 +23,6 @@ function isPublicPage(pathname: string) { pathname === "/" || pathname.startsWith("/docs") || pathname.startsWith("/subscription-help") || - pathname === "/entitlement-required" || pathname.startsWith("/auth/login") || pathname.startsWith("/auth/callback") ); @@ -93,52 +90,6 @@ async function handleTerminalGate(request: NextRequest): Promise { return NextResponse.redirect(loginUrl); } -function handleLegacyTokenGate(request: NextRequest) { - const requiredToken = process.env.POLYWEATHER_DASHBOARD_ACCESS_TOKEN?.trim(); - if (!requiredToken) { - return NextResponse.next(); - } - - const { pathname, searchParams } = request.nextUrl; - if (isPublicPage(pathname) || isPublicApi(pathname)) { - return NextResponse.next(); - } - - const cookieToken = request.cookies.get(SESSION_COOKIE)?.value; - if (cookieToken && cookieToken === requiredToken) { - return NextResponse.next(); - } - - const queryToken = searchParams.get("access_token"); - if (queryToken && queryToken === requiredToken) { - const cleanUrl = request.nextUrl.clone(); - cleanUrl.searchParams.delete("access_token"); - - const response = NextResponse.redirect(cleanUrl); - response.cookies.set(SESSION_COOKIE, requiredToken, { - httpOnly: true, - sameSite: "lax", - secure: cleanUrl.protocol === "https:", - path: "/", - maxAge: 60 * 60 * 12, - }); - return response; - } - - if (pathname.startsWith("/api/")) { - return NextResponse.json( - { error: "Unauthorized", detail: "Entitlement token required" }, - { status: 401 }, - ); - } - - const deniedUrl = request.nextUrl.clone(); - deniedUrl.pathname = "/entitlement-required"; - deniedUrl.search = ""; - deniedUrl.searchParams.set("next", pathname); - return NextResponse.redirect(deniedUrl); -} - async function handleSupabaseAuthGate(request: NextRequest) { const { pathname } = request.nextUrl; if (isPublicPage(pathname) || isPublicApi(pathname)) { @@ -224,7 +175,7 @@ export async function middleware(request: NextRequest) { } return handleSupabaseOptionalSession(request); } - return handleLegacyTokenGate(request); + return NextResponse.next(); } export const config = { diff --git a/src/auth/supabase_entitlement.py b/src/auth/supabase_entitlement.py index 0b0d9a87..5bdf82ea 100644 --- a/src/auth/supabase_entitlement.py +++ b/src/auth/supabase_entitlement.py @@ -4,7 +4,7 @@ import os import threading import time from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from typing import Dict, List, Optional import requests @@ -65,27 +65,10 @@ class SupabaseEntitlementService: self.timeout_sec = max(3, _env_int("SUPABASE_HTTP_TIMEOUT_SEC", 8)) self.cache_ttl_sec = max(5, _env_int("SUPABASE_AUTH_CACHE_TTL_SEC", 30)) self.sub_cache_ttl_sec = max(5, _env_int("SUPABASE_SUB_CACHE_TTL_SEC", 60)) - self.signup_trial_enabled = _env_bool( - "POLYWEATHER_SIGNUP_TRIAL_ENABLED", - True, - ) - self.signup_trial_days = max( - 0, - _env_int("POLYWEATHER_SIGNUP_TRIAL_DAYS", 3), - ) - self.signup_trial_plan_code = str( - os.getenv("POLYWEATHER_SIGNUP_TRIAL_PLAN_CODE") or "signup_trial_3d" - ).strip() or "signup_trial_3d" - self.signup_trial_source = str( - os.getenv("POLYWEATHER_SIGNUP_TRIAL_SOURCE") or "signup_trial" - ).strip() or "signup_trial" - self._identity_cache: Dict[str, Dict[str, object]] = {} self._identity_cache_lock = threading.Lock() self._sub_cache: Dict[str, Dict[str, object]] = {} self._sub_cache_lock = threading.Lock() - self._trial_locks: Dict[str, threading.Lock] = {} - self._trial_locks_guard = threading.Lock() def invalidate_subscription_cache(self, user_id: str) -> None: key = str(user_id or "").strip() @@ -375,134 +358,6 @@ class SupabaseEntitlementService: return row return None - def _get_trial_lock(self, user_id: str) -> threading.Lock: - key = str(user_id or "").strip() - with self._trial_locks_guard: - lock = self._trial_locks.get(key) - if lock is None: - lock = threading.Lock() - self._trial_locks[key] = lock - return lock - - def _emit_signup_trial_event( - self, - *, - user_id: str, - starts_at: datetime, - expires_at: datetime, - ) -> None: - if not self.service_role_key: - return - try: - now_iso = datetime.now(timezone.utc).isoformat() - requests.post( - self._entitlement_events_endpoint(), - headers={ - **self._request_headers_for_service_role(), - "Content-Type": "application/json", - "Prefer": "return=minimal", - }, - json={ - "user_id": user_id, - "action": "subscription_granted", - "reason": "signup_trial", - "detail": f"{self.signup_trial_days}d signup trial granted", - "payload": { - "plan_code": self.signup_trial_plan_code, - "source": self.signup_trial_source, - "starts_at": starts_at.isoformat(), - "expires_at": expires_at.isoformat(), - }, - "created_at": now_iso, - }, - timeout=self.timeout_sec, - ) - except Exception as exc: - logger.warning(f"supabase signup trial event insert failed user_id={user_id}: {exc}") - - def ensure_signup_trial( - self, - user_id: str, - *, - created_at: Optional[str] = None, - ) -> Optional[Dict[str, object]]: - normalized_user_id = str(user_id or "").strip() - if not normalized_user_id: - return None - if ( - not self.signup_trial_enabled - or self.signup_trial_days <= 0 - or not self.service_role_key - ): - return None - - lock = self._get_trial_lock(normalized_user_id) - with lock: - existing_active = self._query_latest_active_subscription(normalized_user_id) - if isinstance(existing_active, dict): - return existing_active - - existing_any = self._query_latest_subscription_any_status(normalized_user_id) - if isinstance(existing_any, dict): - return None - - starts_at = self._parse_iso_datetime(created_at) - if starts_at is None: - admin_users = self.get_auth_users([normalized_user_id]) - starts_at = self._parse_iso_datetime( - str((admin_users.get(normalized_user_id) or {}).get("created_at") or "") - ) - if starts_at is None: - return None - - expires_at = starts_at + timedelta(days=self.signup_trial_days) - now = datetime.now(timezone.utc) - if expires_at <= now: - return None - - payload = { - "user_id": normalized_user_id, - "plan_code": self.signup_trial_plan_code, - "status": "active", - "starts_at": starts_at.isoformat(), - "expires_at": expires_at.isoformat(), - "source": self.signup_trial_source, - "created_at": now.isoformat(), - "updated_at": now.isoformat(), - } - try: - response = requests.post( - self._subscription_endpoint(), - headers={ - **self._request_headers_for_service_role(), - "Content-Type": "application/json", - "Prefer": "return=representation", - }, - json=payload, - timeout=self.timeout_sec, - ) - if response.status_code not in (200, 201): - logger.warning( - "supabase signup trial insert failed user_id={} status={}", - normalized_user_id, - response.status_code, - ) - return self._query_latest_active_subscription(normalized_user_id) - rows = response.json() if response.content else [] - row = rows[0] if isinstance(rows, list) and rows else None - self.invalidate_subscription_cache(normalized_user_id) - self._emit_signup_trial_event( - user_id=normalized_user_id, - starts_at=starts_at, - expires_at=expires_at, - ) - if isinstance(row, dict): - return row - return self._query_latest_active_subscription(normalized_user_id) - except Exception as exc: - logger.warning(f"supabase signup trial insert error user_id={normalized_user_id}: {exc}") - return self._query_latest_active_subscription(normalized_user_id) - def _query_active_subscription(self, user_id: str) -> bool: return self._query_latest_active_subscription(user_id) is not None diff --git a/tests/test_supabase_entitlement.py b/tests/test_supabase_entitlement.py index 1aff5597..ed84f44b 100644 --- a/tests/test_supabase_entitlement.py +++ b/tests/test_supabase_entitlement.py @@ -14,58 +14,6 @@ class _Response: return self._payload -def test_ensure_signup_trial_grants_three_day_subscription(monkeypatch): - monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") - monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") - monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role") - monkeypatch.setenv("POLYWEATHER_SIGNUP_TRIAL_ENABLED", "true") - monkeypatch.setenv("POLYWEATHER_SIGNUP_TRIAL_DAYS", "3") - - service = SupabaseEntitlementService() - monkeypatch.setattr(service, "_query_latest_active_subscription", lambda user_id: None) - monkeypatch.setattr(service, "_query_latest_subscription_any_status", lambda user_id: None) - - captured_posts = [] - - def _fake_post(url, headers=None, json=None, timeout=None): - captured_posts.append({"url": url, "headers": headers, "json": json, "timeout": timeout}) - if url.endswith("/rest/v1/subscriptions"): - return _Response( - 201, - [ - { - "user_id": json["user_id"], - "plan_code": json["plan_code"], - "status": json["status"], - "starts_at": json["starts_at"], - "expires_at": json["expires_at"], - "source": json["source"], - } - ], - ) - return _Response(201, {}) - - monkeypatch.setattr(entitlement_module.requests, "post", _fake_post) - - starts_at = datetime.now(timezone.utc) - timedelta(hours=1) - result = service.ensure_signup_trial( - "user-1", - created_at=starts_at.isoformat(), - ) - - assert result is not None - assert result["plan_code"] == "signup_trial_3d" - assert result["status"] == "active" - assert result["starts_at"] == starts_at.isoformat() - assert result["expires_at"] == (starts_at + timedelta(days=3)).isoformat() - - subscription_insert = next( - item for item in captured_posts if item["url"].endswith("/rest/v1/subscriptions") - ) - assert subscription_insert["json"]["user_id"] == "user-1" - assert subscription_insert["json"]["source"] == "signup_trial" - - def test_latest_active_subscription_ignores_future_start(monkeypatch): monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") diff --git a/web/services/auth_api.py b/web/services/auth_api.py index b73bc20a..1d75bacc 100644 --- a/web/services/auth_api.py +++ b/web/services/auth_api.py @@ -31,17 +31,12 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]: if legacy_routes.SUPABASE_ENTITLEMENT.enabled and user_id: try: - latest_subscription = legacy_routes.SUPABASE_ENTITLEMENT.ensure_signup_trial( - user_id, - created_at=getattr(request.state, "auth_created_at", None), - ) - if not latest_subscription: - latest_subscription = ( - legacy_routes.SUPABASE_ENTITLEMENT.get_latest_active_subscription( - user_id, - respect_requirement=False, - ) + latest_subscription = ( + legacy_routes.SUPABASE_ENTITLEMENT.get_latest_active_subscription( + user_id, + respect_requirement=False, ) + ) latest_known_subscription = latest_subscription if not latest_known_subscription: