feat: Implement Supabase authentication, account management UI, and entitlement services.
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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. 退出登录后再次访问,确认被重定向到登录页。
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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=<your-token></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=<your-token></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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": "--",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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).*)"],
|
||||
};
|
||||
|
||||
Generated
+243
-6
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Authentication and entitlement helpers."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 <supabase_user_id> [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)
|
||||
|
||||
|
||||
@@ -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 <supabase_user_id> [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()
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user