feat: Implement Supabase authentication, account management UI, and entitlement services.

This commit is contained in:
2569718930@qq.com
2026-03-13 02:23:01 +08:00
parent 987aec2fa6
commit 0a869459c4
34 changed files with 2318 additions and 68 deletions
+11
View File
@@ -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=
+75
View File
@@ -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://<your-project-ref>.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://<backend-host>: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. 退出登录后再次访问,确认被重定向到登录页。
+4
View File
@@ -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=<token>
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
+17
View File
@@ -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 (
<I18nProvider>
<AccountCenter />
</I18nProvider>
);
}
+41
View File
@@ -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 },
);
}
}
+14 -6
View File
@@ -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;
}
}
+14 -6
View File
@@ -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;
}
}
+14 -6
View File
@@ -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;
}
}
+16 -7
View File
@@ -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;
}
}
+14 -6
View File
@@ -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;
}
}
+32
View File
@@ -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;
}
+21
View File
@@ -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 <LoginClient nextPath={nextPath} />;
}
+11 -3
View File
@@ -33,9 +33,17 @@ export default async function EntitlementRequiredPage({ searchParams }: Props) {
Entitlement Required
</h1>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
This dashboard is protected. Append{" "}
<code>?access_token=&lt;your-token&gt;</code> to the URL once, and
the session cookie will be set automatically.
This dashboard is protected. If Supabase auth is enabled, please go to{" "}
<a
href={`/auth/login?next=${encodeURIComponent(nextPath)}`}
style={{ color: "#8fc5ff" }}
>
/auth/login
</a>{" "}
to sign in first.
</p>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
Legacy mode still supports <code>?access_token=&lt;your-token&gt;</code>.
</p>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
Requested path: <code>{nextPath}</code>
@@ -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;
}
}
@@ -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<string>("");
const [user, setUser] = useState<User | null>(null);
const [backend, setBackend] = useState<AuthMeResponse | null>(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 <supabase_user_id> <email>";
const copyBindCommand = async () => {
try {
await navigator.clipboard.writeText(bindCommand);
setCopied(true);
window.setTimeout(() => setCopied(false), 1300);
} catch {}
};
return (
<main className={styles.page}>
<div className={styles.aurora} />
<div className={styles.gridNoise} />
<div className={styles.shell}>
<header className={styles.topBar}>
<div className={styles.brandBlock}>
<h1 className={styles.title}>{t("account.title")}</h1>
<p className={styles.subtitle}>{t("account.subtitle")}</p>
</div>
<div className={styles.actions}>
<Link className={styles.ghostBtn} href="/">
<ArrowLeft size={15} />
{t("account.backDashboard")}
</Link>
<button
type="button"
className={styles.ghostBtn}
onClick={() => void onRefresh()}
disabled={refreshing || loading}
>
{refreshing ? <Loader2 size={15} className={styles.spin} /> : <RefreshCw size={15} />}
{t("account.refresh")}
</button>
<button type="button" className={styles.primaryBtn} onClick={() => void onSignOut()}>
<LogOut size={15} />
{t("account.signOut")}
</button>
</div>
</header>
<section className={styles.heroCard}>
<div className={styles.avatar}>{initials || "PW"}</div>
<div className={styles.heroMain}>
<h2>{displayName}</h2>
<p>{email || t("account.na")}</p>
<div className={styles.badges}>
<span className={styles.badge}>
<CheckCircle2 size={14} />
{t("account.authenticated")}
</span>
{backend?.subscription_active ? (
<span className={styles.badge}>
<ShieldCheck size={14} />
{t("account.subscriptionActive")}
</span>
) : backend?.subscription_required ? (
<span className={styles.badgeWarn}>
<KeyRound size={14} />
{t("account.subscriptionRequired")}
</span>
) : (
<span className={styles.badgeGhost}>
<ShieldCheck size={14} />
{t("account.subscriptionUnknown")}
</span>
)}
</div>
</div>
<div className={styles.updatedText}>{t("account.updatedAt", { time: updatedAtLabel })}</div>
</section>
{loading ? (
<section className={styles.noticeRow}>
<Loader2 size={16} className={styles.spin} />
<span>{t("account.loading")}</span>
</section>
) : null}
{errorText ? (
<section className={styles.errorRow}>
{t("account.error", { message: errorText })}
</section>
) : null}
<section className={styles.cards}>
<article className={styles.card}>
<h3>
<ShieldCheck size={17} />
{t("account.card.membership")}
</h3>
<dl className={styles.metaList}>
<div>
<dt>{t("account.field.mode")}</dt>
<dd>{modeLabel}</dd>
</div>
<div>
<dt>{t("account.field.backendStatus")}</dt>
<dd>
{backend?.authenticated
? t("account.backend.ok")
: t("account.backend.fail")}
</dd>
</div>
<div>
<dt>{t("account.field.requirement")}</dt>
<dd>
{backend?.subscription_required
? t("account.subscriptionRequired")
: t("account.subscription.notRequired")}
</dd>
</div>
<div>
<dt>{t("account.field.subscription")}</dt>
<dd>{subscriptionLabel}</dd>
</div>
</dl>
</article>
<article className={styles.card}>
<h3>
<UserCircle2 size={17} />
{t("account.card.identity")}
</h3>
<dl className={styles.metaList}>
<div>
<dt>{t("account.field.email")}</dt>
<dd>{email || t("account.na")}</dd>
</div>
<div>
<dt>{t("account.field.userId")}</dt>
<dd className={styles.mono}>{userId || t("account.na")}</dd>
</div>
<div>
<dt>{t("account.field.provider")}</dt>
<dd>{provider}</dd>
</div>
<div>
<dt>{t("account.field.lastSignIn")}</dt>
<dd>{lastSignIn}</dd>
</div>
</dl>
</article>
<article className={styles.cardWide}>
<h3>
<Bot size={17} />
{t("account.card.bot")}
</h3>
<p className={styles.hint}>{t("account.field.bindHint")}</p>
<div className={styles.commandRow}>
<code className={styles.command}>{bindCommand}</code>
<button type="button" className={styles.copyBtn} onClick={() => void copyBindCommand()}>
<Copy size={14} />
{copied ? t("account.copied") : t("account.copy")}
</button>
</div>
</article>
</section>
{!supabaseReady ? (
<section className={styles.noticeRow}>
<KeyRound size={15} />
<span>NEXT_PUBLIC_SUPABASE_URL / ANON_KEY is not configured.</span>
</section>
) : null}
</div>
</main>
);
}
+272
View File
@@ -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<Mode>("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<HTMLFormElement>) => {
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 (
<main
style={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
background:
"radial-gradient(circle at 18% 15%, #0f2b59 0%, #0a1a39 38%, #050b16 100%)",
color: "#d8e6ff",
padding: "24px",
}}
>
<section
style={{
width: "100%",
maxWidth: 460,
borderRadius: 16,
border: "1px solid rgba(84, 118, 177, 0.45)",
background: "rgba(8, 18, 37, 0.9)",
boxShadow: "0 24px 60px rgba(0, 0, 0, 0.4)",
padding: 24,
}}
>
<h1 style={{ margin: 0, fontSize: 28 }}>PolyWeather </h1>
<p style={{ marginTop: 10, color: "#9db5df", lineHeight: 1.5 }}>
Google /使
</p>
<button
type="button"
onClick={() => void onGoogleSignIn()}
disabled={loading}
style={{
width: "100%",
marginTop: 12,
padding: "12px 14px",
borderRadius: 10,
border: "1px solid rgba(132, 169, 237, 0.5)",
background: "linear-gradient(135deg, #1a4c95 0%, #2a6ed2 100%)",
color: "#f3f7ff",
fontWeight: 700,
cursor: "pointer",
}}
>
使 Google
</button>
<div style={{ marginTop: 18, marginBottom: 14, color: "#8ea8d8" }}>
使 {mode === "login" ? "登录" : "注册"}
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 14 }}>
<button
type="button"
onClick={() => setMode("login")}
style={{
flex: 1,
padding: "10px 12px",
borderRadius: 8,
border: "1px solid rgba(116, 148, 206, 0.45)",
background: mode === "login" ? "#1c4a90" : "transparent",
color: "#d8e6ff",
cursor: "pointer",
}}
>
</button>
<button
type="button"
onClick={() => setMode("signup")}
style={{
flex: 1,
padding: "10px 12px",
borderRadius: 8,
border: "1px solid rgba(116, 148, 206, 0.45)",
background: mode === "signup" ? "#1c4a90" : "transparent",
color: "#d8e6ff",
cursor: "pointer",
}}
>
</button>
</div>
<form onSubmit={(event) => void onEmailSubmit(event)}>
<input
type="email"
required
value={email}
onChange={(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",
}}
/>
<input
type="password"
required
minLength={6}
value={password}
onChange={(event) => 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",
}}
/>
<button
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "12px 14px",
borderRadius: 10,
border: "1px solid rgba(105, 214, 179, 0.55)",
background: "linear-gradient(135deg, #1b8a71 0%, #1aa387 100%)",
color: "#f0fffb",
fontWeight: 700,
cursor: "pointer",
}}
>
{mode === "login" ? "邮箱登录" : "邮箱注册"}
</button>
</form>
{errorText ? (
<p style={{ marginTop: 12, color: "#ff8b96" }}>{errorText}</p>
) : null}
{infoText ? (
<p style={{ marginTop: 12, color: "#77e0be" }}>{infoText}</p>
) : null}
<p style={{ marginTop: 16, color: "#8ea8d8", fontSize: 13 }}>
: <code>{nextPath}</code>
</p>
</section>
</main>
);
}
@@ -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);
@@ -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() {
</button>
</div>
<Link
href="/account"
className="account-btn"
title={t("header.accountAria")}
aria-label={t("header.accountAria")}
>
{t("header.account")}
</Link>
<button
type="button"
className="info-btn"
+61 -8
View File
@@ -1,14 +1,67 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
export const BACKEND_ENTITLEMENT_HEADER = "x-polyweather-entitlement";
export function buildBackendRequestHeaders(): HeadersInit {
const headers: HeadersInit = {
Accept: "application/json",
};
type HeaderBuildResult = {
headers: HeadersInit;
response: NextResponse | null;
};
const token = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim();
if (token) {
headers[BACKEND_ENTITLEMENT_HEADER] = token;
function extractBearerToken(headerValue: string | null) {
if (!headerValue) return "";
const parts = headerValue.trim().split(/\s+/);
if (parts.length === 2 && parts[0].toLowerCase() === "bearer") {
return parts[1];
}
return "";
}
export async function buildBackendRequestHeaders(
request: NextRequest,
): Promise<HeaderBuildResult> {
const headers = new Headers({
Accept: "application/json",
});
const backendToken = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim();
if (backendToken) {
headers.set(BACKEND_ENTITLEMENT_HEADER, backendToken);
}
return headers;
const incomingAuth = extractBearerToken(request.headers.get("authorization"));
if (incomingAuth) {
headers.set("Authorization", `Bearer ${incomingAuth}`);
return { headers, response: null };
}
if (!hasSupabaseServerEnv()) {
return { headers, response: null };
}
const passthroughResponse = new NextResponse(null, { status: 200 });
const supabase = createSupabaseRouteClient(request, passthroughResponse);
const {
data: { session },
} = await supabase.auth.getSession();
const accessToken = session?.access_token || "";
if (accessToken) {
headers.set("Authorization", `Bearer ${accessToken}`);
}
return { headers, response: passthroughResponse };
}
export function applyAuthResponseCookies(
target: NextResponse,
source: NextResponse | null,
) {
if (!source) return target;
for (const [name, value] of source.headers.entries()) {
if (name.toLowerCase() === "set-cookie") {
target.headers.append(name, value);
}
}
return target;
}
+88
View File
@@ -10,6 +10,8 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"header.subtitle": "天气衍生品智能分析",
"header.info": "技术说明",
"header.infoAria": "查看系统技术说明",
"header.account": "账户",
"header.accountAria": "打开账户页",
"header.live": "实时",
"header.refreshAria": "刷新所有数据",
"header.langAria": "切换语言",
@@ -108,12 +110,56 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"section.distance": "距离",
"section.note": "注意",
"account.title": "账户中心",
"account.subtitle": "查看身份、权限与 Bot 绑定信息",
"account.backDashboard": "返回看板",
"account.refresh": "刷新",
"account.signOut": "退出登录",
"account.loading": "正在同步账户信息...",
"account.error": "加载失败: {message}",
"account.updatedAt": "最近同步: {time}",
"account.guestName": "PolyWeather 用户",
"account.authenticated": "已登录",
"account.subscriptionActive": "订阅有效",
"account.subscriptionRequired": "需要订阅",
"account.subscriptionUnknown": "订阅状态未知",
"account.card.membership": "会员与权限",
"account.card.identity": "身份信息",
"account.card.backend": "后端鉴权",
"account.card.bot": "Bot 绑定",
"account.field.email": "邮箱",
"account.field.userId": "用户 ID",
"account.field.provider": "登录方式",
"account.field.lastSignIn": "最近登录",
"account.field.mode": "鉴权模式",
"account.field.backendStatus": "后端状态",
"account.field.subscription": "订阅结果",
"account.field.requirement": "订阅要求",
"account.field.bindCommand": "绑定命令",
"account.field.bindHint":
"将下面命令发送到 Telegram Bot,可把网页账户与机器人权限绑定。",
"account.mode.supabase": "Supabase 会话鉴权",
"account.mode.legacy": "Legacy Token 鉴权",
"account.mode.disabled": "未启用权限校验",
"account.mode.unknown": "未知模式",
"account.backend.ok": "通过",
"account.backend.fail": "失败",
"account.subscription.active": "有效",
"account.subscription.inactive": "无效/已过期",
"account.subscription.notRequired": "当前未强制订阅",
"account.subscription.unknown": "未知",
"account.copy": "复制",
"account.copied": "已复制",
"account.na": "--",
"common.na": "--",
},
"en-US": {
"header.subtitle": "Weather Derivatives Intelligence",
"header.info": "Tech Notes",
"header.infoAria": "Open system technical notes",
"header.account": "Account",
"header.accountAria": "Open account center",
"header.live": "LIVE",
"header.refreshAria": "Refresh all data",
"header.langAria": "Switch language",
@@ -216,6 +262,48 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"section.distance": "Distance",
"section.note": "Note",
"account.title": "Account Center",
"account.subtitle": "Review identity, access status, and bot binding info",
"account.backDashboard": "Back to Dashboard",
"account.refresh": "Refresh",
"account.signOut": "Sign out",
"account.loading": "Syncing account snapshot...",
"account.error": "Failed to load: {message}",
"account.updatedAt": "Last synced: {time}",
"account.guestName": "PolyWeather User",
"account.authenticated": "Authenticated",
"account.subscriptionActive": "Subscription active",
"account.subscriptionRequired": "Subscription required",
"account.subscriptionUnknown": "Subscription unknown",
"account.card.membership": "Membership & Access",
"account.card.identity": "Identity",
"account.card.backend": "Backend Auth",
"account.card.bot": "Bot Binding",
"account.field.email": "Email",
"account.field.userId": "User ID",
"account.field.provider": "Sign-in method",
"account.field.lastSignIn": "Last sign-in",
"account.field.mode": "Entitlement mode",
"account.field.backendStatus": "Backend status",
"account.field.subscription": "Subscription result",
"account.field.requirement": "Subscription policy",
"account.field.bindCommand": "Binding command",
"account.field.bindHint":
"Send this command to the Telegram bot to bind web account identity.",
"account.mode.supabase": "Supabase session auth",
"account.mode.legacy": "Legacy token auth",
"account.mode.disabled": "Auth guard disabled",
"account.mode.unknown": "Unknown mode",
"account.backend.ok": "Passed",
"account.backend.fail": "Failed",
"account.subscription.active": "Active",
"account.subscription.inactive": "Inactive/Expired",
"account.subscription.notRequired": "Not required now",
"account.subscription.unknown": "Unknown",
"account.copy": "Copy",
"account.copied": "Copied",
"account.na": "--",
"common.na": "--",
},
};
+29
View File
@@ -0,0 +1,29 @@
import { createBrowserClient } from "@supabase/ssr";
import type { SupabaseClient } from "@supabase/supabase-js";
let cachedClient: SupabaseClient | null = null;
function readSupabasePublicEnv() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
return { anonKey, url };
}
export function hasSupabasePublicEnv() {
const { anonKey, url } = readSupabasePublicEnv();
return Boolean(url && anonKey);
}
export function getSupabaseBrowserClient(): SupabaseClient {
if (cachedClient) {
return cachedClient;
}
const { anonKey, url } = readSupabasePublicEnv();
if (!url || !anonKey) {
throw new Error("Supabase public env is not configured");
}
cachedClient = createBrowserClient(url, anonKey);
return cachedClient;
}
+70
View File
@@ -0,0 +1,70 @@
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import type { NextRequest, NextResponse } from "next/server";
type CookieAdapter = {
getAll: () => { name: string; value: string }[];
setAll: (cookies: { name: string; value: string; options?: CookieOptions }[]) => void;
};
function readSupabasePublicEnv() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
return { anonKey, url };
}
export function hasSupabaseServerEnv() {
const { anonKey, url } = readSupabasePublicEnv();
return Boolean(url && anonKey);
}
export function createSupabaseServerClient(
cookieAdapter: CookieAdapter,
) {
const { anonKey, url } = readSupabasePublicEnv();
if (!url || !anonKey) {
throw new Error("Supabase env is not configured");
}
return createServerClient(url, anonKey, {
cookies: cookieAdapter,
});
}
export function createSupabaseMiddlewareClient(
request: NextRequest,
response: NextResponse,
) {
return createSupabaseServerClient({
getAll() {
return request.cookies.getAll().map((item) => ({
name: item.name,
value: item.value,
}));
},
setAll(cookiesToSet) {
for (const cookie of cookiesToSet) {
response.cookies.set(cookie.name, cookie.value, cookie.options);
}
},
});
}
export function createSupabaseRouteClient(
request: NextRequest,
response: NextResponse,
) {
return createSupabaseServerClient({
getAll() {
return request.cookies.getAll().map((item) => ({
name: item.name,
value: item.value,
}));
},
setAll(cookiesToSet) {
for (const cookie of cookiesToSet) {
response.cookies.set(cookie.name, cookie.value, cookie.options);
}
},
});
}
+60 -2
View File
@@ -1,6 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import {
createSupabaseMiddlewareClient,
hasSupabaseServerEnv,
} from "@/lib/supabase/server";
const SESSION_COOKIE = "polyweather_entitlement";
const SUPABASE_AUTH_ENABLED =
String(process.env.POLYWEATHER_AUTH_ENABLED || "")
.trim()
.toLowerCase() === "true";
function isStaticAsset(pathname: string) {
return (
@@ -19,10 +27,14 @@ function isStaticAsset(pathname: string) {
}
function isPublicPage(pathname: string) {
return pathname === "/entitlement-required";
return (
pathname === "/entitlement-required" ||
pathname.startsWith("/auth/login") ||
pathname.startsWith("/auth/callback")
);
}
export function middleware(request: NextRequest) {
function handleLegacyTokenGate(request: NextRequest) {
const requiredToken = process.env.POLYWEATHER_DASHBOARD_ACCESS_TOKEN?.trim();
if (!requiredToken) {
return NextResponse.next();
@@ -68,6 +80,52 @@ export function middleware(request: NextRequest) {
return NextResponse.redirect(deniedUrl);
}
async function handleSupabaseAuthGate(request: NextRequest) {
const { pathname } = request.nextUrl;
if (isPublicPage(pathname)) {
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) {
return response;
}
if (pathname.startsWith("/api/")) {
return NextResponse.json(
{ error: "Unauthorized", detail: "Supabase session required" },
{ status: 401 },
);
}
const loginUrl = request.nextUrl.clone();
loginUrl.pathname = "/auth/login";
loginUrl.search = "";
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (isStaticAsset(pathname)) {
return NextResponse.next();
}
if (SUPABASE_AUTH_ENABLED && hasSupabaseServerEnv()) {
return handleSupabaseAuthGate(request);
}
return handleLegacyTokenGate(request);
}
export const config = {
matcher: ["/((?!_next/static|_next/image).*)"],
};
+243 -6
View File
@@ -9,6 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@radix-ui/react-slot": "^1.1.2",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.57.2",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^2.0.0",
"chart.js": "^4.5.1",
@@ -736,6 +738,92 @@
"react-dom": "^19.0.0"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.99.1",
"resolved": "https://registry.npmmirror.com/@supabase/auth-js/-/auth-js-2.99.1.tgz",
"integrity": "sha512-x7lKKTvKjABJt/FYcRSPiTT01Xhm2FF8RhfL8+RHMkmlwmRQ88/lREupIHKwFPW0W6pTCJqkZb7Yhpw/EZ+fNw==",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.99.1",
"resolved": "https://registry.npmmirror.com/@supabase/functions-js/-/functions-js-2.99.1.tgz",
"integrity": "sha512-WQE62W5geYImCO4jzFxCk/avnK7JmOdtqu2eiPz3zOaNiIJajNRSAwMMDgEGd2EMs+sUVYj1LfBjfmW3EzHgIA==",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "2.99.1",
"resolved": "https://registry.npmmirror.com/@supabase/postgrest-js/-/postgrest-js-2.99.1.tgz",
"integrity": "sha512-gtw2ibJrADvfqrpUWXGNlrYUvxttF4WVWfPpTFKOb2IRj7B6YRWMDgcrYqIuD4ZEabK4m6YKQCCGy6clgf1lPA==",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.99.1",
"resolved": "https://registry.npmmirror.com/@supabase/realtime-js/-/realtime-js-2.99.1.tgz",
"integrity": "sha512-9EDdy/5wOseGFqxW88ShV9JMRhm7f+9JGY5x+LqT8c7R0X1CTLwg5qie8FiBWcXTZ+68yYxVWunI+7W4FhkWOg==",
"dependencies": {
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/ssr": {
"version": "0.5.2",
"resolved": "https://registry.npmmirror.com/@supabase/ssr/-/ssr-0.5.2.tgz",
"integrity": "sha512-n3plRhr2Bs8Xun1o4S3k1CDv17iH5QY9YcoEvXX3bxV1/5XSasA0mNXYycFmADIdtdE6BG9MRjP5CGIs8qxC8A==",
"dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^0.7.0"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.43.4"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.99.1",
"resolved": "https://registry.npmmirror.com/@supabase/storage-js/-/storage-js-2.99.1.tgz",
"integrity": "sha512-mf7zPfqofI62SOoyQJeNUVxe72E4rQsbWim6lTDPeLu3lHija/cP5utlQADGrjeTgOUN6znx/rWn7SjrETP1dw==",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.99.1",
"resolved": "https://registry.npmmirror.com/@supabase/supabase-js/-/supabase-js-2.99.1.tgz",
"integrity": "sha512-5MRoYD9ffXq8F6a036dm65YoSHisC3by/d22mauKE99Vrwf792KxYIIr/iqCX7E4hkuugbPZ5EGYHTB7MKy6Vg==",
"dependencies": {
"@supabase/auth-js": "2.99.1",
"@supabase/functions-js": "2.99.1",
"@supabase/postgrest-js": "2.99.1",
"@supabase/realtime-js": "2.99.1",
"@supabase/storage-js": "2.99.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -744,6 +832,11 @@
"tslib": "^2.8.0"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmmirror.com/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -763,11 +856,15 @@
"version": "22.19.14",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.14.tgz",
"integrity": "sha512-a39m4Z/qy3oYWP8Fc5RO674p/ENAB88JbwnmNwu6+hlfDTbqwE649936RqKNAXAOUwfggSVg6y2KwQcYBYaTsA==",
"dev": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.7",
"resolved": "https://registry.npmmirror.com/@types/phoenix/-/phoenix-1.6.7.tgz",
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz",
@@ -786,6 +883,14 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vercel/analytics": {
"version": "1.6.1",
"resolved": "https://registry.npmmirror.com/@vercel/analytics/-/analytics-1.6.1.tgz",
@@ -1098,6 +1203,14 @@
"node": ">= 6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
@@ -1261,6 +1374,14 @@
"node": ">= 0.4"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmmirror.com/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2118,8 +2239,7 @@
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
@@ -2156,6 +2276,26 @@
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
},
"dependencies": {
@@ -2493,6 +2633,71 @@
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
"requires": {}
},
"@supabase/auth-js": {
"version": "2.99.1",
"resolved": "https://registry.npmmirror.com/@supabase/auth-js/-/auth-js-2.99.1.tgz",
"integrity": "sha512-x7lKKTvKjABJt/FYcRSPiTT01Xhm2FF8RhfL8+RHMkmlwmRQ88/lREupIHKwFPW0W6pTCJqkZb7Yhpw/EZ+fNw==",
"requires": {
"tslib": "2.8.1"
}
},
"@supabase/functions-js": {
"version": "2.99.1",
"resolved": "https://registry.npmmirror.com/@supabase/functions-js/-/functions-js-2.99.1.tgz",
"integrity": "sha512-WQE62W5geYImCO4jzFxCk/avnK7JmOdtqu2eiPz3zOaNiIJajNRSAwMMDgEGd2EMs+sUVYj1LfBjfmW3EzHgIA==",
"requires": {
"tslib": "2.8.1"
}
},
"@supabase/postgrest-js": {
"version": "2.99.1",
"resolved": "https://registry.npmmirror.com/@supabase/postgrest-js/-/postgrest-js-2.99.1.tgz",
"integrity": "sha512-gtw2ibJrADvfqrpUWXGNlrYUvxttF4WVWfPpTFKOb2IRj7B6YRWMDgcrYqIuD4ZEabK4m6YKQCCGy6clgf1lPA==",
"requires": {
"tslib": "2.8.1"
}
},
"@supabase/realtime-js": {
"version": "2.99.1",
"resolved": "https://registry.npmmirror.com/@supabase/realtime-js/-/realtime-js-2.99.1.tgz",
"integrity": "sha512-9EDdy/5wOseGFqxW88ShV9JMRhm7f+9JGY5x+LqT8c7R0X1CTLwg5qie8FiBWcXTZ+68yYxVWunI+7W4FhkWOg==",
"requires": {
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
}
},
"@supabase/ssr": {
"version": "0.5.2",
"resolved": "https://registry.npmmirror.com/@supabase/ssr/-/ssr-0.5.2.tgz",
"integrity": "sha512-n3plRhr2Bs8Xun1o4S3k1CDv17iH5QY9YcoEvXX3bxV1/5XSasA0mNXYycFmADIdtdE6BG9MRjP5CGIs8qxC8A==",
"requires": {
"@types/cookie": "^0.6.0",
"cookie": "^0.7.0"
}
},
"@supabase/storage-js": {
"version": "2.99.1",
"resolved": "https://registry.npmmirror.com/@supabase/storage-js/-/storage-js-2.99.1.tgz",
"integrity": "sha512-mf7zPfqofI62SOoyQJeNUVxe72E4rQsbWim6lTDPeLu3lHija/cP5utlQADGrjeTgOUN6znx/rWn7SjrETP1dw==",
"requires": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
}
},
"@supabase/supabase-js": {
"version": "2.99.1",
"resolved": "https://registry.npmmirror.com/@supabase/supabase-js/-/supabase-js-2.99.1.tgz",
"integrity": "sha512-5MRoYD9ffXq8F6a036dm65YoSHisC3by/d22mauKE99Vrwf792KxYIIr/iqCX7E4hkuugbPZ5EGYHTB7MKy6Vg==",
"requires": {
"@supabase/auth-js": "2.99.1",
"@supabase/functions-js": "2.99.1",
"@supabase/postgrest-js": "2.99.1",
"@supabase/realtime-js": "2.99.1",
"@supabase/storage-js": "2.99.1"
}
},
"@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -2501,6 +2706,11 @@
"tslib": "^2.8.0"
}
},
"@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmmirror.com/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
},
"@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -2520,11 +2730,15 @@
"version": "22.19.14",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.14.tgz",
"integrity": "sha512-a39m4Z/qy3oYWP8Fc5RO674p/ENAB88JbwnmNwu6+hlfDTbqwE649936RqKNAXAOUwfggSVg6y2KwQcYBYaTsA==",
"dev": true,
"requires": {
"undici-types": "~6.21.0"
}
},
"@types/phoenix": {
"version": "1.6.7",
"resolved": "https://registry.npmmirror.com/@types/phoenix/-/phoenix-1.6.7.tgz",
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="
},
"@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz",
@@ -2541,6 +2755,14 @@
"dev": true,
"requires": {}
},
"@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"requires": {
"@types/node": "*"
}
},
"@vercel/analytics": {
"version": "1.6.1",
"resolved": "https://registry.npmmirror.com/@vercel/analytics/-/analytics-1.6.1.tgz",
@@ -2692,6 +2914,11 @@
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true
},
"cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="
},
"cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
@@ -2813,6 +3040,11 @@
"function-bind": "^1.1.2"
}
},
"iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmmirror.com/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="
},
"is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -3322,8 +3554,7 @@
"undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
},
"update-browserslist-db": {
"version": "1.2.3",
@@ -3340,6 +3571,12 @@
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"ws": {
"version": "8.19.0",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"requires": {}
}
}
}
+2
View File
@@ -10,6 +10,8 @@
},
"dependencies": {
"@radix-ui/react-slot": "^1.1.2",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.57.2",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^2.0.0",
"chart.js": "^4.5.1",
+71
View File
@@ -0,0 +1,71 @@
-- PolyWeather minimal commerce/auth schema (P0)
-- Run in Supabase SQL editor.
create table if not exists public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
email text not null default '',
telegram_user_id bigint,
telegram_username text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.subscriptions (
id bigserial primary key,
user_id uuid not null references auth.users(id) on delete cascade,
plan_code text not null,
status text not null check (status in ('active', 'paused', 'expired', 'cancelled')),
starts_at timestamptz not null default now(),
expires_at timestamptz not null,
source text not null default 'manual',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_subscriptions_user_status_expiry
on public.subscriptions(user_id, status, expires_at desc);
create table if not exists public.payments (
id bigserial primary key,
user_id uuid not null references auth.users(id) on delete cascade,
amount numeric(18, 6) not null,
currency text not null default 'USDC',
chain text not null default 'polygon',
tx_hash text unique,
status text not null check (status in ('pending', 'confirmed', 'failed', 'refunded')),
raw_payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.entitlement_events (
id bigserial primary key,
user_id uuid not null references auth.users(id) on delete cascade,
action text not null,
reason text not null default '',
actor text not null default 'system',
payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create or replace function public.sync_profile_from_auth()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.profiles (id, email)
values (new.id, coalesce(new.email, ''))
on conflict (id) do update
set email = excluded.email,
updated_at = now();
return new;
end;
$$;
drop trigger if exists on_auth_user_created_polyweather on auth.users;
create trigger on_auth_user_created_polyweather
after insert on auth.users
for each row execute function public.sync_profile_from_auth();
+2
View File
@@ -0,0 +1,2 @@
"""Authentication and entitlement helpers."""
+193
View File
@@ -0,0 +1,193 @@
from __future__ import annotations
import os
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Dict, Optional
import requests
from loguru import logger
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
return int(raw)
except Exception:
return default
def extract_bearer_token(auth_header: Optional[str]) -> str:
if not auth_header:
return ""
parts = str(auth_header).strip().split()
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1].strip()
return ""
@dataclass
class SupabaseIdentity:
user_id: str
email: str
class SupabaseEntitlementService:
"""
Supabase-backed authentication and entitlement checks.
- Auth validation: /auth/v1/user with user access token.
- Entitlement check: /rest/v1/subscriptions with service role key.
"""
def __init__(self):
self.enabled = _env_bool("POLYWEATHER_AUTH_ENABLED", False)
self.require_subscription = _env_bool(
"POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION",
False,
)
self.supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
self.anon_key = str(os.getenv("SUPABASE_ANON_KEY") or "").strip()
self.service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
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._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()
@property
def configured(self) -> bool:
return bool(self.supabase_url and self.anon_key)
def _user_endpoint(self) -> str:
return f"{self.supabase_url}/auth/v1/user"
def _subscription_endpoint(self) -> str:
return f"{self.supabase_url}/rest/v1/subscriptions"
def _request_headers_for_user(self, access_token: str) -> Dict[str, str]:
return {
"apikey": self.anon_key,
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
}
def _request_headers_for_service_role(self) -> Dict[str, str]:
return {
"apikey": self.service_role_key,
"Authorization": f"Bearer {self.service_role_key}",
"Accept": "application/json",
}
def get_identity(self, access_token: str) -> Optional[SupabaseIdentity]:
if not access_token:
return None
now_ts = time.time()
with self._identity_cache_lock:
cached = self._identity_cache.get(access_token)
if cached and now_ts - float(cached.get("ts") or 0) < self.cache_ttl_sec:
identity = cached.get("identity")
if isinstance(identity, SupabaseIdentity):
return identity
if not self.configured:
return None
try:
response = requests.get(
self._user_endpoint(),
headers=self._request_headers_for_user(access_token),
timeout=self.timeout_sec,
)
if response.status_code != 200:
return None
data = response.json() if response.content else {}
user_id = str(data.get("id") or "").strip()
if not user_id:
return None
identity = SupabaseIdentity(
user_id=user_id,
email=str(data.get("email") or "").strip(),
)
with self._identity_cache_lock:
self._identity_cache[access_token] = {
"identity": identity,
"ts": now_ts,
}
return identity
except Exception as exc:
logger.warning(f"supabase auth user check failed: {exc}")
return None
def has_active_subscription(self, user_id: str) -> bool:
if not self.require_subscription:
return True
if not user_id:
return False
if not self.service_role_key:
logger.warning(
"POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=true but SUPABASE_SERVICE_ROLE_KEY is missing",
)
return False
now_ts = time.time()
with self._sub_cache_lock:
cached = self._sub_cache.get(user_id)
if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec:
return bool(cached.get("active"))
try:
now_iso = datetime.now(timezone.utc).isoformat()
params = {
"select": "id,user_id,status,expires_at",
"user_id": f"eq.{user_id}",
"status": "eq.active",
"expires_at": f"gt.{now_iso}",
"order": "expires_at.desc",
"limit": "1",
}
response = requests.get(
self._subscription_endpoint(),
headers=self._request_headers_for_service_role(),
params=params,
timeout=self.timeout_sec,
)
if response.status_code != 200:
logger.warning(
"supabase subscription query failed user_id={} status={}",
user_id,
response.status_code,
)
active = False
else:
data = response.json() if response.content else []
active = isinstance(data, list) and len(data) > 0
with self._sub_cache_lock:
self._sub_cache[user_id] = {
"active": active,
"ts": now_ts,
}
return active
except Exception as exc:
logger.warning(f"supabase subscription query error user_id={user_id}: {exc}")
return False
SUPABASE_ENTITLEMENT = SupabaseEntitlementService()
+19 -5
View File
@@ -25,12 +25,27 @@ class CommandGuard:
if decision.allowed:
return True
self.io_layer.bot.reply_to(
message,
(
if decision.reason == "bind_required":
denial_text = (
"🔒 当前指令需要订阅权限。\n"
"请先绑定账号后再试:\n"
"<code>/bind &lt;supabase_user_id&gt; [email]</code>"
)
elif decision.reason in {"supabase_subscription_required", "premium_required"}:
denial_text = (
"🔒 当前指令需要高级权限。\n"
"请先开通订阅后再使用。"
),
)
else:
denial_text = (
"🔒 当前指令需要高级权限。\n"
"请先开通订阅后再使用。"
)
self.io_layer.bot.reply_to(
message,
denial_text,
parse_mode="HTML",
)
logger.info(
"bot entitlement blocked command={} user_id={} reason={}",
@@ -44,4 +59,3 @@ class CommandGuard:
if not self.ensure_entitled(message, command_label):
return False
return self.io_layer.ensure_query_points(message, cost, command_label)
+47
View File
@@ -36,6 +36,10 @@ class BasicCommandHandler:
def _diag(message):
self.handle_diag(message)
@self.bot.message_handler(commands=["bind"])
def _bind(message):
self.handle_bind(message)
def handle_start_help(self, message: Any) -> None:
trace = CommandTrace("/start", message)
try:
@@ -73,3 +77,46 @@ class BasicCommandHandler:
trace.set_status("ok")
finally:
trace.emit()
def handle_bind(self, message: Any) -> None:
trace = CommandTrace("/bind", message)
try:
parts = (message.text or "").split(maxsplit=2)
if len(parts) < 2:
self.bot.reply_to(
message,
(
"❌ 用法:\n"
"<code>/bind &lt;supabase_user_id&gt; [email]</code>\n\n"
"示例:\n"
"<code>/bind 11111111-2222-3333-4444-555555555555 user@example.com</code>"
),
parse_mode="HTML",
)
trace.set_status("bad_request", "missing_supabase_user_id")
return
supabase_user_id = str(parts[1] or "").strip()
if len(supabase_user_id) < 8:
self.bot.reply_to(message, "❌ supabase_user_id 格式不正确。")
trace.set_status("bad_request", "invalid_supabase_user_id")
return
supabase_email = str(parts[2] or "").strip() if len(parts) >= 3 else ""
user = message.from_user
self.io_layer.db.upsert_user(user.id, self.io_layer.display_name(user))
self.io_layer.db.bind_supabase_identity(
telegram_id=user.id,
supabase_user_id=supabase_user_id,
supabase_email=supabase_email,
)
self.bot.reply_to(
message,
(
"✅ 账号绑定完成。\n"
f"supabase_user_id: <code>{supabase_user_id}</code>"
),
parse_mode="HTML",
)
trace.set_status("ok")
finally:
trace.emit()
+1
View File
@@ -59,6 +59,7 @@ class BotIOLayer:
"/top - 查看积分排行榜\n"
"/id - 获取当前聊天的 Chat ID\n\n"
"/diag - 查看 Bot 启动诊断\n\n"
"/bind - 绑定 Supabase 账号(可选)\n\n"
"示例: <code>/city 伦敦</code>\n"
f"💡 <i>提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"每日上限 {MESSAGE_DAILY_CAP} 分。</i>"
+13 -1
View File
@@ -4,6 +4,7 @@ import os
from dataclasses import dataclass
from typing import Iterable, Set
from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT
from src.database.db_manager import DBManager
@@ -36,6 +37,10 @@ class BotEntitlementService:
):
self.db = db
self.enabled = _env_bool("POLYWEATHER_BOT_REQUIRE_ENTITLEMENT", False) if enabled is None else enabled
self.use_supabase = _env_bool(
"POLYWEATHER_BOT_USE_SUPABASE_ENTITLEMENT",
SUPABASE_ENTITLEMENT.enabled,
)
commands = protected_commands or ("/city", "/deb")
self.protected_commands: Set[str] = {str(c).strip().lower() for c in commands if str(c).strip()}
@@ -47,8 +52,15 @@ class BotEntitlementService:
return EntitlementDecision(True, "command_not_protected")
user = self.db.get_user(user_id) or {}
if self.use_supabase:
supabase_user_id = str(user.get("supabase_user_id") or "").strip()
if not supabase_user_id:
return EntitlementDecision(False, "bind_required")
if SUPABASE_ENTITLEMENT.has_active_subscription(supabase_user_id):
return EntitlementDecision(True, "supabase_subscription_active")
return EntitlementDecision(False, "supabase_subscription_required")
has_premium = bool(user.get("is_web_premium") or user.get("is_group_premium"))
if has_premium:
return EntitlementDecision(True, "premium_user")
return EntitlementDecision(False, "premium_required")
+19
View File
@@ -45,6 +45,8 @@ class DBManager:
""")
self._ensure_column(conn, "users", "daily_points", "INTEGER DEFAULT 0")
self._ensure_column(conn, "users", "daily_points_date", "TEXT")
self._ensure_column(conn, "users", "supabase_user_id", "TEXT")
self._ensure_column(conn, "users", "supabase_email", "TEXT")
conn.commit()
logger.info(f"Database initialized successfully path={self.db_path}")
@@ -85,6 +87,23 @@ class DBManager:
""", (telegram_id, username))
conn.commit()
def bind_supabase_identity(
self,
telegram_id: int,
supabase_user_id: str,
supabase_email: str = "",
) -> None:
with self._get_connection() as conn:
conn.execute(
"""
UPDATE users
SET supabase_user_id = ?, supabase_email = ?
WHERE telegram_id = ?
""",
(supabase_user_id.strip(), supabase_email.strip(), telegram_id),
)
conn.commit()
def add_message_activity(
self,
telegram_id: int,
+63 -12
View File
@@ -31,6 +31,10 @@ from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES
from src.data_collection.polymarket_readonly import PolymarketReadOnlyLayer
from src.analysis.deb_algorithm import calculate_dynamic_weights, get_deb_accuracy
from src.analysis.settlement_rounding import wu_round
from src.auth.supabase_entitlement import (
SUPABASE_ENTITLEMENT,
extract_bearer_token,
)
# ──────────────────────────────────────────────────────────
# Setup
@@ -89,16 +93,37 @@ _ENTITLEMENT_HEADER = "x-polyweather-entitlement"
_ENTITLEMENT_TOKEN = (os.getenv("POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN") or "").strip()
def _extract_bearer_token(auth_header: Optional[str]) -> Optional[str]:
if not auth_header:
return None
parts = auth_header.strip().split()
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1].strip()
return None
def _legacy_service_token_valid(request: Request) -> bool:
token = request.headers.get(_ENTITLEMENT_HEADER)
if not token:
token = extract_bearer_token(request.headers.get("authorization"))
return bool(_ENTITLEMENT_TOKEN and token == _ENTITLEMENT_TOKEN)
def _assert_entitlement(request: Request) -> None:
if SUPABASE_ENTITLEMENT.enabled:
if _legacy_service_token_valid(request):
return
if not SUPABASE_ENTITLEMENT.configured:
raise HTTPException(
status_code=503,
detail="Supabase auth is enabled but SUPABASE_URL / SUPABASE_ANON_KEY is not configured",
)
access_token = extract_bearer_token(request.headers.get("authorization"))
if not access_token:
raise HTTPException(status_code=401, detail="Unauthorized")
identity = SUPABASE_ENTITLEMENT.get_identity(access_token)
if not identity:
raise HTTPException(status_code=401, detail="Unauthorized")
if not SUPABASE_ENTITLEMENT.has_active_subscription(identity.user_id):
raise HTTPException(status_code=403, detail="Subscription required")
request.state.auth_user_id = identity.user_id
request.state.auth_email = identity.email
return
if not _ENTITLEMENT_GUARD_ENABLED:
return
@@ -108,11 +133,7 @@ def _assert_entitlement(request: Request) -> None:
detail="Entitlement guard is enabled but backend token is not configured",
)
token = request.headers.get(_ENTITLEMENT_HEADER)
if not token:
token = _extract_bearer_token(request.headers.get("authorization"))
if token != _ENTITLEMENT_TOKEN:
if not _legacy_service_token_valid(request):
raise HTTPException(status_code=401, detail="Unauthorized")
@@ -979,6 +1000,36 @@ async def city_history(request: Request, name: str):
return {"history": out}
@app.get("/api/auth/me")
async def auth_me(request: Request):
_assert_entitlement(request)
user_id = getattr(request.state, "auth_user_id", None)
subscription_required = bool(
SUPABASE_ENTITLEMENT.enabled and SUPABASE_ENTITLEMENT.require_subscription
)
subscription_active = None
if SUPABASE_ENTITLEMENT.enabled and user_id:
try:
subscription_active = SUPABASE_ENTITLEMENT.has_active_subscription(user_id)
except Exception:
subscription_active = None
return {
"authenticated": True,
"user_id": user_id,
"email": getattr(request.state, "auth_email", None),
"entitlement_mode": (
"supabase"
if SUPABASE_ENTITLEMENT.enabled
else "legacy_token"
if _ENTITLEMENT_GUARD_ENABLED
else "disabled"
),
"subscription_required": subscription_required,
"subscription_active": subscription_active,
}
@app.get("/api/city/{name}/summary")
async def city_summary(request: Request, name: str, force_refresh: bool = False):
_assert_entitlement(request)