From 0a869459c4e6248638cb954b87c3b5e830f1cfc8 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 13 Mar 2026 02:23:01 +0800 Subject: [PATCH] feat: Implement Supabase authentication, account management UI, and entitlement services. --- .env.example | 11 + docs/SUPABASE_SETUP_ZH.md | 75 ++++ frontend/.env.example | 4 + frontend/app/account/page.tsx | 17 + frontend/app/api/auth/me/route.ts | 41 ++ frontend/app/api/cities/route.ts | 20 +- frontend/app/api/city/[name]/detail/route.ts | 20 +- frontend/app/api/city/[name]/route.ts | 20 +- frontend/app/api/city/[name]/summary/route.ts | 23 +- frontend/app/api/history/[name]/route.ts | 20 +- frontend/app/auth/callback/route.ts | 32 ++ frontend/app/auth/login/page.tsx | 21 + frontend/app/entitlement-required/page.tsx | 14 +- .../account/AccountCenter.module.css | 418 ++++++++++++++++++ frontend/components/account/AccountCenter.tsx | 331 ++++++++++++++ frontend/components/auth/LoginClient.tsx | 272 ++++++++++++ .../components/dashboard/Dashboard.module.css | 22 + frontend/components/dashboard/HeaderBar.tsx | 10 + frontend/lib/backend-auth.ts | 69 ++- frontend/lib/i18n.ts | 88 ++++ frontend/lib/supabase/client.ts | 29 ++ frontend/lib/supabase/server.ts | 70 +++ frontend/middleware.ts | 62 ++- frontend/package-lock.json | 249 ++++++++++- frontend/package.json | 2 + scripts/supabase/schema.sql | 71 +++ src/auth/__init__.py | 2 + src/auth/supabase_entitlement.py | 193 ++++++++ src/bot/command_guard.py | 24 +- src/bot/handlers/basic.py | 47 ++ src/bot/io_layer.py | 1 + src/bot/services/entitlement_service.py | 14 +- src/database/db_manager.py | 19 + web/app.py | 75 +++- 34 files changed, 2318 insertions(+), 68 deletions(-) create mode 100644 docs/SUPABASE_SETUP_ZH.md create mode 100644 frontend/app/account/page.tsx create mode 100644 frontend/app/api/auth/me/route.ts create mode 100644 frontend/app/auth/callback/route.ts create mode 100644 frontend/app/auth/login/page.tsx create mode 100644 frontend/components/account/AccountCenter.module.css create mode 100644 frontend/components/account/AccountCenter.tsx create mode 100644 frontend/components/auth/LoginClient.tsx create mode 100644 frontend/lib/supabase/client.ts create mode 100644 frontend/lib/supabase/server.ts create mode 100644 scripts/supabase/schema.sql create mode 100644 src/auth/__init__.py create mode 100644 src/auth/supabase_entitlement.py diff --git a/.env.example b/.env.example index 29275f83..a79bb6ab 100644 --- a/.env.example +++ b/.env.example @@ -26,8 +26,19 @@ HTTP_PROXY=http://127.0.0.1:7890 LOG_LEVEL=INFO ENV=production POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/ +# Unified Auth (Supabase + Google/Email) +POLYWEATHER_AUTH_ENABLED=false +# If true, authenticated users must also have an active row in `subscriptions`. +POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false +SUPABASE_URL= +SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= +SUPABASE_HTTP_TIMEOUT_SEC=8 +SUPABASE_AUTH_CACHE_TTL_SEC=30 +SUPABASE_SUB_CACHE_TTL_SEC=60 # Bot command entitlement guard (/city, /deb). Off by default until payment API is wired. POLYWEATHER_BOT_REQUIRE_ENTITLEMENT=false +POLYWEATHER_BOT_USE_SUPABASE_ENTITLEMENT=false # Backend entitlement guard (for /api/cities, /api/city/*, /api/history/*) POLYWEATHER_REQUIRE_ENTITLEMENT=false POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= diff --git a/docs/SUPABASE_SETUP_ZH.md b/docs/SUPABASE_SETUP_ZH.md new file mode 100644 index 00000000..b146bcb1 --- /dev/null +++ b/docs/SUPABASE_SETUP_ZH.md @@ -0,0 +1,75 @@ +# Supabase + Google 登录接入说明(P0) + +## 1. 目标 + +- 前端支持 `Google 一键登录`(优先)与 `邮箱注册/登录`(并列)。 +- 后端 API 支持 Supabase JWT 鉴权。 +- entitlement 检查可选:仅登录放行,或要求有效订阅。 + +## 2. Supabase 控制台配置 + +1. Auth -> Providers -> 打开 `Google`。 +2. Auth -> Providers -> `Email` 保持开启。 +3. 在 Google Cloud Console 配置 OAuth 回调地址: + - `https://.supabase.co/auth/v1/callback` +4. 在 Auth -> URL Configuration 添加站点 URL(你的前端域名)。 + +## 3. 执行数据库脚本 + +在 Supabase SQL Editor 运行: + +- `scripts/supabase/schema.sql` + +该脚本会创建: + +- `profiles` +- `subscriptions` +- `payments` +- `entitlement_events` + +并建立 `auth.users -> profiles` 同步触发器。 + +## 4. 环境变量 + +### 4.1 前端(Vercel / frontend/.env.local) + +```env +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +POLYWEATHER_AUTH_ENABLED=true +POLYWEATHER_API_BASE_URL=http://:8000 +POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= +``` + +### 4.2 后端 / Bot(.env) + +```env +POLYWEATHER_AUTH_ENABLED=true +POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false +SUPABASE_URL= +SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= +SUPABASE_HTTP_TIMEOUT_SEC=8 +SUPABASE_AUTH_CACHE_TTL_SEC=30 +SUPABASE_SUB_CACHE_TTL_SEC=60 +``` + +可选(Bot 也走 Supabase 订阅): + +```env +POLYWEATHER_BOT_REQUIRE_ENTITLEMENT=true +POLYWEATHER_BOT_USE_SUPABASE_ENTITLEMENT=true +``` + +## 5. entitlement 策略 + +- `POLYWEATHER_AUTH_ENABLED=true`:要求请求携带有效 Supabase 用户会话。 +- `POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=true`:额外要求 `subscriptions` 表里存在有效 `active` 记录。 + +## 6. 验证 + +1. 访问 `/auth/login`,测试 Google 一键登录。 +2. 登录后访问首页,确认页面可用。 +3. 调用 `/api/cities`,确认返回 200。 +4. 退出登录后再次访问,确认被重定向到登录页。 + diff --git a/frontend/.env.example b/frontend/.env.example index 2aa4f4e7..548c1d4d 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,4 +1,8 @@ POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000 +# Supabase Auth (Google + Email) +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +POLYWEATHER_AUTH_ENABLED=false # Optional dashboard guard (Next.js middleware) # If set, open dashboard with: /?access_token= POLYWEATHER_DASHBOARD_ACCESS_TOKEN= diff --git a/frontend/app/account/page.tsx b/frontend/app/account/page.tsx new file mode 100644 index 00000000..1a35dc17 --- /dev/null +++ b/frontend/app/account/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { I18nProvider } from "@/hooks/useI18n"; +import { AccountCenter } from "@/components/account/AccountCenter"; + +export const metadata: Metadata = { + title: "PolyWeather | Account Center", + description: "PolyWeather account center for identity and entitlement status.", +}; + +export default function AccountPage() { + return ( + + + + ); +} + diff --git a/frontend/app/api/auth/me/route.ts b/frontend/app/api/auth/me/route.ts new file mode 100644 index 00000000..88bebcab --- /dev/null +++ b/frontend/app/api/auth/me/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const res = await fetch(`${API_BASE}/api/auth/me`, { + headers: auth.headers, + cache: "no-store", + }); + if (!res.ok) { + const raw = await res.text(); + const response = NextResponse.json( + { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, + { status: 502 }, + ); + return applyAuthResponseCookies(response, auth.response); + } + const data = await res.json(); + const response = NextResponse.json(data); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return NextResponse.json( + { error: "Failed to fetch auth profile", detail: String(error) }, + { status: 500 }, + ); + } +} + diff --git a/frontend/app/api/cities/route.ts b/frontend/app/api/cities/route.ts index 38ce29ba..df4c5c3d 100644 --- a/frontend/app/api/cities/route.ts +++ b/frontend/app/api/cities/route.ts @@ -1,5 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -import { buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; import { buildCachedJsonResponse } from "@/lib/http-cache"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -7,34 +10,39 @@ export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { if (!API_BASE) { - return NextResponse.json( + const response = NextResponse.json( { error: "POLYWEATHER_API_BASE_URL is not configured" }, { status: 500 }, ); + return response; } try { + const auth = await buildBackendRequestHeaders(req); const res = await fetch(`${API_BASE}/api/cities`, { - headers: buildBackendRequestHeaders(), + headers: auth.headers, cache: "no-store", }); if (!res.ok) { const raw = await res.text(); - return NextResponse.json( + const response = NextResponse.json( { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, { status: 502 }, ); + return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); - return buildCachedJsonResponse( + const response = buildCachedJsonResponse( req, data, "public, max-age=0, s-maxage=300, stale-while-revalidate=1800", ); + return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( + const response = NextResponse.json( { error: "Failed to fetch cities", detail: String(error) }, { status: 500 }, ); + return response; } } diff --git a/frontend/app/api/city/[name]/detail/route.ts b/frontend/app/api/city/[name]/detail/route.ts index a498689e..745a6437 100644 --- a/frontend/app/api/city/[name]/detail/route.ts +++ b/frontend/app/api/city/[name]/detail/route.ts @@ -1,5 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -import { buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -8,10 +11,11 @@ export async function GET( context: { params: Promise<{ name: string }> }, ) { if (!API_BASE) { - return NextResponse.json( + const response = NextResponse.json( { error: "POLYWEATHER_API_BASE_URL is not configured" }, { status: 500 }, ); + return response; } const { name } = await context.params; @@ -30,23 +34,27 @@ export async function GET( const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`; try { + const auth = await buildBackendRequestHeaders(req); const res = await fetch(url, { - headers: buildBackendRequestHeaders(), + headers: auth.headers, cache: "no-store", }); if (!res.ok) { const raw = await res.text(); - return NextResponse.json( + const response = NextResponse.json( { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, { status: 502 }, ); + return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); - return NextResponse.json(data); + const response = NextResponse.json(data); + return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( + const response = NextResponse.json( { error: "Failed to fetch city detail aggregate", detail: String(error) }, { status: 500 }, ); + return response; } } diff --git a/frontend/app/api/city/[name]/route.ts b/frontend/app/api/city/[name]/route.ts index 74de954d..c0a17fe3 100644 --- a/frontend/app/api/city/[name]/route.ts +++ b/frontend/app/api/city/[name]/route.ts @@ -1,5 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -import { buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -8,10 +11,11 @@ export async function GET( context: { params: Promise<{ name: string }> }, ) { if (!API_BASE) { - return NextResponse.json( + const response = NextResponse.json( { error: "POLYWEATHER_API_BASE_URL is not configured" }, { status: 500 }, ); + return response; } const { name } = await context.params; @@ -19,23 +23,27 @@ export async function GET( const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}`; try { + const auth = await buildBackendRequestHeaders(req); const res = await fetch(url, { - headers: buildBackendRequestHeaders(), + headers: auth.headers, cache: "no-store", }); if (!res.ok) { const raw = await res.text(); - return NextResponse.json( + const response = NextResponse.json( { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, { status: 502 }, ); + return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); - return NextResponse.json(data); + const response = NextResponse.json(data); + return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( + const response = NextResponse.json( { error: "Failed to fetch city detail", detail: String(error) }, { status: 500 }, ); + return response; } } diff --git a/frontend/app/api/city/[name]/summary/route.ts b/frontend/app/api/city/[name]/summary/route.ts index 127a1bc5..581387b6 100644 --- a/frontend/app/api/city/[name]/summary/route.ts +++ b/frontend/app/api/city/[name]/summary/route.ts @@ -1,5 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -import { buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; import { buildCachedJsonResponse } from "@/lib/http-cache"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -9,10 +12,11 @@ export async function GET( context: { params: Promise<{ name: string }> }, ) { if (!API_BASE) { - return NextResponse.json( + const response = NextResponse.json( { error: "POLYWEATHER_API_BASE_URL is not configured" }, { status: 500 }, ); + return response; } const { name } = await context.params; @@ -21,34 +25,39 @@ export async function GET( const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`; try { + const auth = await buildBackendRequestHeaders(req); const res = await fetch(url, { - headers: buildBackendRequestHeaders(), + headers: auth.headers, cache: "no-store", }); if (!res.ok) { const raw = await res.text(); - return NextResponse.json( + const response = NextResponse.json( { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, { status: 502 }, ); + return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); if (bypassCache) { - return NextResponse.json(data, { + const response = NextResponse.json(data, { headers: { "Cache-Control": "no-store", }, }); + return applyAuthResponseCookies(response, auth.response); } - return buildCachedJsonResponse( + const response = buildCachedJsonResponse( req, data, "public, max-age=0, s-maxage=20, stale-while-revalidate=60", ); + return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( + const response = NextResponse.json( { error: "Failed to fetch city summary", detail: String(error) }, { status: 500 }, ); + return response; } } diff --git a/frontend/app/api/history/[name]/route.ts b/frontend/app/api/history/[name]/route.ts index e091dc34..f2d15971 100644 --- a/frontend/app/api/history/[name]/route.ts +++ b/frontend/app/api/history/[name]/route.ts @@ -1,5 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -import { buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; import { buildCachedJsonResponse } from "@/lib/http-cache"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -9,37 +12,42 @@ export async function GET( context: { params: Promise<{ name: string }> }, ) { if (!API_BASE) { - return NextResponse.json( + const response = NextResponse.json( { error: "POLYWEATHER_API_BASE_URL is not configured" }, { status: 500 }, ); + return response; } const { name } = await context.params; const url = `${API_BASE}/api/history/${encodeURIComponent(name)}`; try { + const auth = await buildBackendRequestHeaders(req); const res = await fetch(url, { - headers: buildBackendRequestHeaders(), + headers: auth.headers, cache: "no-store", }); if (!res.ok) { const raw = await res.text(); - return NextResponse.json( + const response = NextResponse.json( { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, { status: 502 }, ); + return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); - return buildCachedJsonResponse( + const response = buildCachedJsonResponse( req, data, "public, max-age=0, s-maxage=60, stale-while-revalidate=300", ); + return applyAuthResponseCookies(response, auth.response); } catch (error) { - return NextResponse.json( + const response = NextResponse.json( { error: "Failed to fetch history", detail: String(error) }, { status: 500 }, ); + return response; } } diff --git a/frontend/app/auth/callback/route.ts b/frontend/app/auth/callback/route.ts new file mode 100644 index 00000000..1c7cb046 --- /dev/null +++ b/frontend/app/auth/callback/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server"; + +function normalizeNextPath(input: string | null) { + const fallback = "/"; + const raw = String(input || "").trim(); + if (!raw) return fallback; + if (!raw.startsWith("/")) return fallback; + if (raw.startsWith("//")) return fallback; + return raw; +} + +export async function GET(request: NextRequest) { + const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next")); + const redirectUrl = request.nextUrl.clone(); + redirectUrl.pathname = nextPath; + redirectUrl.search = ""; + + if (!hasSupabaseServerEnv()) { + return NextResponse.redirect(redirectUrl); + } + + const response = NextResponse.redirect(redirectUrl); + const supabase = createSupabaseRouteClient(request, response); + const code = request.nextUrl.searchParams.get("code"); + if (code) { + await supabase.auth.exchangeCodeForSession(code); + } + + return response; +} + diff --git a/frontend/app/auth/login/page.tsx b/frontend/app/auth/login/page.tsx new file mode 100644 index 00000000..4530d5e1 --- /dev/null +++ b/frontend/app/auth/login/page.tsx @@ -0,0 +1,21 @@ +import { LoginClient } from "@/components/auth/LoginClient"; + +type PageProps = { + searchParams?: Promise<{ next?: string }>; +}; + +function normalizeNextPath(input: string | undefined) { + const fallback = "/"; + const raw = String(input || "").trim(); + if (!raw) return fallback; + if (!raw.startsWith("/")) return fallback; + if (raw.startsWith("//")) return fallback; + return raw; +} + +export default async function LoginPage({ searchParams }: PageProps) { + const params = (await searchParams) || {}; + const nextPath = normalizeNextPath(params.next); + return ; +} + diff --git a/frontend/app/entitlement-required/page.tsx b/frontend/app/entitlement-required/page.tsx index 7ef054d3..f298bb47 100644 --- a/frontend/app/entitlement-required/page.tsx +++ b/frontend/app/entitlement-required/page.tsx @@ -33,9 +33,17 @@ export default async function EntitlementRequiredPage({ searchParams }: Props) { Entitlement Required

- This dashboard is protected. Append{" "} - ?access_token=<your-token> to the URL once, and - the session cookie will be set automatically. + This dashboard is protected. If Supabase auth is enabled, please go to{" "} + + /auth/login + {" "} + to sign in first. +

+

+ Legacy mode still supports ?access_token=<your-token>.

Requested path: {nextPath} diff --git a/frontend/components/account/AccountCenter.module.css b/frontend/components/account/AccountCenter.module.css new file mode 100644 index 00000000..c5ad7174 --- /dev/null +++ b/frontend/components/account/AccountCenter.module.css @@ -0,0 +1,418 @@ +.page { + position: relative; + min-height: 100vh; + padding: 34px 20px 28px; + color: #e2ecff; + background: + radial-gradient(circle at 8% 8%, rgba(56, 189, 248, 0.24), transparent 38%), + radial-gradient(circle at 92% 10%, rgba(45, 212, 191, 0.2), transparent 34%), + radial-gradient(circle at 50% 120%, rgba(14, 165, 233, 0.26), transparent 40%), + #050b16; + overflow-x: hidden; +} + +.aurora { + position: absolute; + inset: -10% -5% auto; + height: 340px; + background: linear-gradient( + 95deg, + rgba(56, 189, 248, 0.2) 0%, + rgba(45, 212, 191, 0.12) 46%, + rgba(99, 102, 241, 0.08) 100% + ); + filter: blur(58px); + pointer-events: none; +} + +.gridNoise { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(148, 163, 184, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(148, 163, 184, 0.06) 1px, transparent 1px); + background-size: 42px 42px; + mask-image: radial-gradient(circle at 50% 25%, black, transparent 72%); + pointer-events: none; + opacity: 0.22; +} + +.shell { + position: relative; + width: min(1120px, 100%); + margin: 0 auto; + display: grid; + gap: 16px; +} + +.topBar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + border: 1px solid rgba(148, 163, 184, 0.2); + background: rgba(15, 23, 42, 0.76); + backdrop-filter: blur(14px); + border-radius: 16px; + padding: 16px 18px; +} + +.brandBlock { + display: grid; + gap: 5px; +} + +.title { + margin: 0; + font-size: clamp(24px, 2.2vw, 30px); + line-height: 1.15; + letter-spacing: -0.02em; + color: #f8fbff; +} + +.subtitle { + margin: 0; + font-size: 13px; + color: #8ea3c9; +} + +.actions { + display: flex; + align-items: center; + gap: 8px; +} + +.ghostBtn, +.primaryBtn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 36px; + border-radius: 10px; + border: 1px solid rgba(148, 163, 184, 0.35); + padding: 0 12px; + color: #dce8ff; + font-size: 12px; + font-weight: 600; + text-decoration: none; + transition: all 0.22s ease; + background: rgba(15, 23, 42, 0.5); + cursor: pointer; +} + +.ghostBtn:hover, +.primaryBtn:hover { + transform: translateY(-1px); +} + +.ghostBtn:hover { + border-color: rgba(45, 212, 191, 0.45); + color: #f8fcff; + background: rgba(45, 212, 191, 0.12); +} + +.primaryBtn { + border-color: rgba(56, 189, 248, 0.52); + background: linear-gradient(135deg, rgba(14, 116, 144, 0.88), rgba(6, 182, 212, 0.74)); + color: #f6fcff; + box-shadow: 0 12px 26px rgba(8, 47, 73, 0.35); +} + +.primaryBtn:hover { + border-color: rgba(103, 232, 249, 0.72); + box-shadow: 0 16px 30px rgba(14, 116, 144, 0.42); +} + +.heroCard { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 16px; + align-items: center; + border: 1px solid rgba(56, 189, 248, 0.24); + background: + linear-gradient(135deg, rgba(14, 116, 144, 0.17) 0%, rgba(15, 23, 42, 0.86) 58%), + rgba(15, 23, 42, 0.8); + border-radius: 18px; + padding: 18px 20px; +} + +.avatar { + width: 64px; + height: 64px; + border-radius: 18px; + display: grid; + place-items: center; + font-size: 22px; + font-weight: 800; + color: #e7fbff; + border: 1px solid rgba(103, 232, 249, 0.45); + background: linear-gradient(135deg, rgba(6, 182, 212, 0.65), rgba(59, 130, 246, 0.56)); + box-shadow: 0 18px 38px rgba(2, 132, 199, 0.28); +} + +.heroMain { + display: grid; + gap: 8px; +} + +.heroMain h2 { + margin: 0; + font-size: clamp(20px, 1.9vw, 28px); + letter-spacing: -0.02em; + color: #f8fcff; +} + +.heroMain p { + margin: 0; + color: #a7bcdd; + font-size: 13px; +} + +.badges { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.badge, +.badgeWarn, +.badgeGhost { + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 999px; + padding: 5px 11px; + font-size: 12px; + font-weight: 600; +} + +.badge { + border: 1px solid rgba(34, 197, 94, 0.45); + background: rgba(34, 197, 94, 0.12); + color: #86efac; +} + +.badgeWarn { + border: 1px solid rgba(245, 158, 11, 0.46); + background: rgba(245, 158, 11, 0.13); + color: #fcd34d; +} + +.badgeGhost { + border: 1px solid rgba(148, 163, 184, 0.34); + background: rgba(148, 163, 184, 0.12); + color: #cbd5e1; +} + +.updatedText { + justify-self: end; + font-size: 12px; + color: #8ea3c9; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.noticeRow, +.errorRow { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 12px; + padding: 11px 14px; + font-size: 13px; +} + +.noticeRow { + border: 1px solid rgba(56, 189, 248, 0.3); + background: rgba(15, 23, 42, 0.75); + color: #bae6fd; +} + +.errorRow { + border: 1px solid rgba(248, 113, 113, 0.44); + background: rgba(127, 29, 29, 0.34); + color: #fecaca; +} + +.cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.card, +.cardWide { + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 14px; + background: + linear-gradient(180deg, rgba(15, 23, 42, 0.84), rgba(15, 23, 42, 0.72)), + rgba(10, 15, 30, 0.78); + padding: 16px; +} + +.cardWide { + grid-column: 1 / -1; +} + +.card h3, +.cardWide h3 { + margin: 0 0 12px; + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 15px; + color: #e6f0ff; +} + +.metaList { + margin: 0; + display: grid; + gap: 10px; +} + +.metaList > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border-bottom: 1px dashed rgba(148, 163, 184, 0.22); + padding-bottom: 9px; +} + +.metaList > div:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.metaList dt { + font-size: 12px; + color: #89a1c8; +} + +.metaList dd { + margin: 0; + font-size: 13px; + color: #eff6ff; + max-width: 62%; + text-align: right; + word-break: break-word; +} + +.mono { + font-family: "Consolas", "SFMono-Regular", "Menlo", monospace; + font-size: 12px !important; + color: #c7d2fe !important; +} + +.hint { + margin: 0 0 12px; + font-size: 13px; + color: #9ab0d2; + line-height: 1.6; +} + +.commandRow { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.command { + flex: 1 1 420px; + min-height: 40px; + display: flex; + align-items: center; + border-radius: 10px; + border: 1px solid rgba(103, 232, 249, 0.24); + background: rgba(8, 47, 73, 0.32); + color: #d1f9ff; + padding: 0 12px; + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; +} + +.copyBtn { + height: 36px; + border-radius: 9px; + border: 1px solid rgba(45, 212, 191, 0.48); + background: rgba(20, 184, 166, 0.12); + color: #99f6e4; + padding: 0 12px; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + cursor: pointer; + transition: all 0.2s ease; +} + +.copyBtn:hover { + background: rgba(20, 184, 166, 0.22); + border-color: rgba(94, 234, 212, 0.78); + color: #ecfeff; +} + +.spin { + animation: spin 0.9s linear infinite; +} + +@keyframes spin { + 100% { + transform: rotate(360deg); + } +} + +@media (max-width: 900px) { + .topBar { + flex-direction: column; + align-items: stretch; + } + + .actions { + flex-wrap: wrap; + } + + .heroCard { + grid-template-columns: auto 1fr; + align-items: start; + } + + .updatedText { + grid-column: 1 / -1; + justify-self: start; + } + + .cards { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + .page { + padding: 18px 12px 20px; + } + + .topBar, + .heroCard, + .card, + .cardWide { + border-radius: 12px; + padding: 13px; + } + + .ghostBtn, + .primaryBtn { + width: 100%; + } + + .actions { + flex-direction: column; + align-items: stretch; + } +} + diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx new file mode 100644 index 00000000..32b5532d --- /dev/null +++ b/frontend/components/account/AccountCenter.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import type { User } from "@supabase/supabase-js"; +import { + ArrowLeft, + Bot, + CheckCircle2, + Copy, + KeyRound, + Loader2, + LogOut, + RefreshCw, + ShieldCheck, + UserCircle2, +} from "lucide-react"; +import { useI18n } from "@/hooks/useI18n"; +import { + getSupabaseBrowserClient, + hasSupabasePublicEnv, +} from "@/lib/supabase/client"; +import styles from "./AccountCenter.module.css"; + +type AuthMeResponse = { + authenticated?: boolean; + user_id?: string | null; + email?: string | null; + entitlement_mode?: string | null; + subscription_required?: boolean; + subscription_active?: boolean | null; +}; + +function formatTime(value: string | undefined | null, locale: string) { + if (!value) return ""; + try { + const dt = new Date(value); + if (Number.isNaN(dt.getTime())) return ""; + return new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(dt); + } catch { + return ""; + } +} + +function normalizeProvider(user: User | null) { + const provider = String(user?.app_metadata?.provider || "").trim().toLowerCase(); + if (provider) return provider; + const providers = user?.app_metadata?.providers; + if (Array.isArray(providers) && providers.length) { + return String(providers[0] || "").trim().toLowerCase(); + } + return ""; +} + +export function AccountCenter() { + const { locale, t } = useI18n(); + const router = useRouter(); + + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [errorText, setErrorText] = useState(""); + const [copied, setCopied] = useState(false); + const [updatedAt, setUpdatedAt] = useState(""); + const [user, setUser] = useState(null); + const [backend, setBackend] = useState(null); + + const supabaseReady = hasSupabasePublicEnv(); + + const loadSnapshot = useCallback(async () => { + setErrorText(""); + try { + const userPromise = supabaseReady + ? getSupabaseBrowserClient().auth.getUser() + : Promise.resolve({ data: { user: null as User | null } }); + const backendPromise = fetch("/api/auth/me", { cache: "no-store" }); + + const [userResult, backendResult] = await Promise.all([ + userPromise, + backendPromise, + ]); + + setUser(userResult.data?.user ?? null); + + if (!backendResult.ok) { + const raw = (await backendResult.text()).slice(0, 240); + throw new Error(`HTTP ${backendResult.status} ${raw}`.trim()); + } + const backendJson = (await backendResult.json()) as AuthMeResponse; + setBackend(backendJson); + setUpdatedAt(new Date().toISOString()); + } catch (error) { + setErrorText(String(error)); + } + }, [supabaseReady]); + + useEffect(() => { + let cancelled = false; + const run = async () => { + setLoading(true); + await loadSnapshot(); + if (!cancelled) setLoading(false); + }; + void run(); + return () => { + cancelled = true; + }; + }, [loadSnapshot]); + + const onRefresh = async () => { + setRefreshing(true); + await loadSnapshot(); + setRefreshing(false); + }; + + const onSignOut = async () => { + if (supabaseReady) { + try { + const supabase = getSupabaseBrowserClient(); + await supabase.auth.signOut(); + } catch {} + } + router.replace("/auth/login"); + }; + + const userId = backend?.user_id || user?.id || ""; + const email = backend?.email || user?.email || ""; + const providerRaw = normalizeProvider(user); + const provider = providerRaw ? providerRaw.toUpperCase() : t("account.na"); + const lastSignIn = formatTime(user?.last_sign_in_at, locale) || t("account.na"); + const updatedAtLabel = formatTime(updatedAt, locale) || t("account.na"); + const displayName = + String(user?.user_metadata?.full_name || "").trim() || + (email ? String(email).split("@")[0] : "") || + t("account.guestName"); + const initials = displayName.slice(0, 2).toUpperCase(); + + const modeLabel = useMemo(() => { + const mode = String(backend?.entitlement_mode || "").trim().toLowerCase(); + if (mode === "supabase") return t("account.mode.supabase"); + if (mode === "legacy_token") return t("account.mode.legacy"); + if (mode === "disabled") return t("account.mode.disabled"); + return t("account.mode.unknown"); + }, [backend?.entitlement_mode, t]); + + const subscriptionLabel = useMemo(() => { + if (!backend?.subscription_required) return t("account.subscription.notRequired"); + if (backend.subscription_active === true) return t("account.subscription.active"); + if (backend.subscription_active === false) return t("account.subscription.inactive"); + return t("account.subscription.unknown"); + }, [backend?.subscription_active, backend?.subscription_required, t]); + + const bindCommand = userId + ? `/bind ${userId}${email ? ` ${email}` : ""}` + : "/bind "; + + const copyBindCommand = async () => { + try { + await navigator.clipboard.writeText(bindCommand); + setCopied(true); + window.setTimeout(() => setCopied(false), 1300); + } catch {} + }; + + return ( +

+
+
+
+
+
+

{t("account.title")}

+

{t("account.subtitle")}

+
+
+ + + {t("account.backDashboard")} + + + +
+
+ +
+
{initials || "PW"}
+
+

{displayName}

+

{email || t("account.na")}

+
+ + + {t("account.authenticated")} + + {backend?.subscription_active ? ( + + + {t("account.subscriptionActive")} + + ) : backend?.subscription_required ? ( + + + {t("account.subscriptionRequired")} + + ) : ( + + + {t("account.subscriptionUnknown")} + + )} +
+
+
{t("account.updatedAt", { time: updatedAtLabel })}
+
+ + {loading ? ( +
+ + {t("account.loading")} +
+ ) : null} + + {errorText ? ( +
+ {t("account.error", { message: errorText })} +
+ ) : null} + +
+
+

+ + {t("account.card.membership")} +

+
+
+
{t("account.field.mode")}
+
{modeLabel}
+
+
+
{t("account.field.backendStatus")}
+
+ {backend?.authenticated + ? t("account.backend.ok") + : t("account.backend.fail")} +
+
+
+
{t("account.field.requirement")}
+
+ {backend?.subscription_required + ? t("account.subscriptionRequired") + : t("account.subscription.notRequired")} +
+
+
+
{t("account.field.subscription")}
+
{subscriptionLabel}
+
+
+
+ +
+

+ + {t("account.card.identity")} +

+
+
+
{t("account.field.email")}
+
{email || t("account.na")}
+
+
+
{t("account.field.userId")}
+
{userId || t("account.na")}
+
+
+
{t("account.field.provider")}
+
{provider}
+
+
+
{t("account.field.lastSignIn")}
+
{lastSignIn}
+
+
+
+ +
+

+ + {t("account.card.bot")} +

+

{t("account.field.bindHint")}

+
+ {bindCommand} + +
+
+
+ + {!supabaseReady ? ( +
+ + NEXT_PUBLIC_SUPABASE_URL / ANON_KEY is not configured. +
+ ) : null} +
+
+ ); +} + diff --git a/frontend/components/auth/LoginClient.tsx b/frontend/components/auth/LoginClient.tsx new file mode 100644 index 00000000..c8dba5ce --- /dev/null +++ b/frontend/components/auth/LoginClient.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { FormEvent, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + getSupabaseBrowserClient, + hasSupabasePublicEnv, +} from "@/lib/supabase/client"; + +type Mode = "login" | "signup"; + +type LoginClientProps = { + nextPath: string; +}; + +export function LoginClient({ nextPath }: LoginClientProps) { + const router = useRouter(); + const [mode, setMode] = useState("login"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [errorText, setErrorText] = useState(""); + const [infoText, setInfoText] = useState(""); + + const supabaseReady = hasSupabasePublicEnv(); + + useEffect(() => { + if (!supabaseReady) return; + const run = async () => { + const supabase = getSupabaseBrowserClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + if (session?.user) { + router.replace(nextPath); + } + }; + void run(); + }, [nextPath, router, supabaseReady]); + + const onGoogleSignIn = async () => { + setErrorText(""); + setInfoText(""); + if (!supabaseReady) { + setErrorText("Supabase 未配置,无法使用登录"); + return; + } + + setLoading(true); + try { + const supabase = getSupabaseBrowserClient(); + const redirectTo = `${window.location.origin}/auth/callback?next=${encodeURIComponent( + nextPath, + )}`; + const { error } = await supabase.auth.signInWithOAuth({ + provider: "google", + options: { + redirectTo, + }, + }); + if (error) { + setErrorText(error.message); + } + } finally { + setLoading(false); + } + }; + + const onEmailSubmit = async (event: FormEvent) => { + event.preventDefault(); + setErrorText(""); + setInfoText(""); + if (!supabaseReady) { + setErrorText("Supabase 未配置,无法使用登录"); + return; + } + if (!email.trim() || !password.trim()) { + setErrorText("请输入邮箱和密码"); + return; + } + + setLoading(true); + try { + const supabase = getSupabaseBrowserClient(); + if (mode === "login") { + const { error } = await supabase.auth.signInWithPassword({ + email: email.trim(), + password, + }); + if (error) { + setErrorText(error.message); + return; + } + router.replace(nextPath); + return; + } + + const emailRedirectTo = `${window.location.origin}/auth/callback?next=${encodeURIComponent( + nextPath, + )}`; + const { data, error } = await supabase.auth.signUp({ + email: email.trim(), + password, + options: { + emailRedirectTo, + }, + }); + if (error) { + setErrorText(error.message); + return; + } + if (data.session?.user) { + router.replace(nextPath); + return; + } + setInfoText("注册成功,请检查邮箱并完成验证后登录。"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

PolyWeather 登录

+

+ 优先推荐 Google 一键登录,邮箱注册/登录可并行使用。 +

+ + + +
+ 或使用邮箱 {mode === "login" ? "登录" : "注册"} +
+ +
+ + +
+ +
void onEmailSubmit(event)}> + setEmail(event.target.value)} + placeholder="you@example.com" + style={{ + width: "100%", + marginBottom: 10, + padding: "12px", + borderRadius: 8, + border: "1px solid rgba(116, 148, 206, 0.4)", + background: "rgba(10, 23, 47, 0.92)", + color: "#e6f0ff", + }} + /> + setPassword(event.target.value)} + placeholder="至少 6 位密码" + style={{ + width: "100%", + marginBottom: 12, + padding: "12px", + borderRadius: 8, + border: "1px solid rgba(116, 148, 206, 0.4)", + background: "rgba(10, 23, 47, 0.92)", + color: "#e6f0ff", + }} + /> + +
+ + {errorText ? ( +

{errorText}

+ ) : null} + {infoText ? ( +

{infoText}

+ ) : null} + +

+ 登录后将跳转到: {nextPath} +

+
+
+ ); +} + diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index d0a19070..eedb67ba 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -1643,6 +1643,28 @@ } /* ── Info Button ── */ +.root :global(.account-btn) { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 12px; + border-radius: 8px; + border: 1px solid rgba(34, 211, 238, 0.34); + background: rgba(34, 211, 238, 0.08); + color: var(--accent-cyan); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + text-decoration: none; + transition: var(--transition); +} +.root :global(.account-btn:hover) { + background: rgba(34, 211, 238, 0.16); + border-color: rgba(34, 211, 238, 0.62); + color: #f8fbff; + transform: translateY(-1px); +} + .root :global(.info-btn) { background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); diff --git a/frontend/components/dashboard/HeaderBar.tsx b/frontend/components/dashboard/HeaderBar.tsx index 621a537d..e73d7a3d 100644 --- a/frontend/components/dashboard/HeaderBar.tsx +++ b/frontend/components/dashboard/HeaderBar.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import clsx from "clsx"; import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; @@ -33,6 +34,15 @@ export function HeaderBar() { + + {t("header.account")} + +