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

This commit is contained in:
2569718930@qq.com
2026-03-13 02:23:01 +08:00
parent 987aec2fa6
commit 0a869459c4
34 changed files with 2318 additions and 68 deletions
+61 -8
View File
@@ -1,14 +1,67 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
export const BACKEND_ENTITLEMENT_HEADER = "x-polyweather-entitlement";
export function buildBackendRequestHeaders(): HeadersInit {
const headers: HeadersInit = {
Accept: "application/json",
};
type HeaderBuildResult = {
headers: HeadersInit;
response: NextResponse | null;
};
const token = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim();
if (token) {
headers[BACKEND_ENTITLEMENT_HEADER] = token;
function extractBearerToken(headerValue: string | null) {
if (!headerValue) return "";
const parts = headerValue.trim().split(/\s+/);
if (parts.length === 2 && parts[0].toLowerCase() === "bearer") {
return parts[1];
}
return "";
}
export async function buildBackendRequestHeaders(
request: NextRequest,
): Promise<HeaderBuildResult> {
const headers = new Headers({
Accept: "application/json",
});
const backendToken = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim();
if (backendToken) {
headers.set(BACKEND_ENTITLEMENT_HEADER, backendToken);
}
return headers;
const incomingAuth = extractBearerToken(request.headers.get("authorization"));
if (incomingAuth) {
headers.set("Authorization", `Bearer ${incomingAuth}`);
return { headers, response: null };
}
if (!hasSupabaseServerEnv()) {
return { headers, response: null };
}
const passthroughResponse = new NextResponse(null, { status: 200 });
const supabase = createSupabaseRouteClient(request, passthroughResponse);
const {
data: { session },
} = await supabase.auth.getSession();
const accessToken = session?.access_token || "";
if (accessToken) {
headers.set("Authorization", `Bearer ${accessToken}`);
}
return { headers, response: passthroughResponse };
}
export function applyAuthResponseCookies(
target: NextResponse,
source: NextResponse | null,
) {
if (!source) return target;
for (const [name, value] of source.headers.entries()) {
if (name.toLowerCase() === "set-cookie") {
target.headers.append(name, value);
}
}
return target;
}
+88
View File
@@ -10,6 +10,8 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"header.subtitle": "天气衍生品智能分析",
"header.info": "技术说明",
"header.infoAria": "查看系统技术说明",
"header.account": "账户",
"header.accountAria": "打开账户页",
"header.live": "实时",
"header.refreshAria": "刷新所有数据",
"header.langAria": "切换语言",
@@ -108,12 +110,56 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"section.distance": "距离",
"section.note": "注意",
"account.title": "账户中心",
"account.subtitle": "查看身份、权限与 Bot 绑定信息",
"account.backDashboard": "返回看板",
"account.refresh": "刷新",
"account.signOut": "退出登录",
"account.loading": "正在同步账户信息...",
"account.error": "加载失败: {message}",
"account.updatedAt": "最近同步: {time}",
"account.guestName": "PolyWeather 用户",
"account.authenticated": "已登录",
"account.subscriptionActive": "订阅有效",
"account.subscriptionRequired": "需要订阅",
"account.subscriptionUnknown": "订阅状态未知",
"account.card.membership": "会员与权限",
"account.card.identity": "身份信息",
"account.card.backend": "后端鉴权",
"account.card.bot": "Bot 绑定",
"account.field.email": "邮箱",
"account.field.userId": "用户 ID",
"account.field.provider": "登录方式",
"account.field.lastSignIn": "最近登录",
"account.field.mode": "鉴权模式",
"account.field.backendStatus": "后端状态",
"account.field.subscription": "订阅结果",
"account.field.requirement": "订阅要求",
"account.field.bindCommand": "绑定命令",
"account.field.bindHint":
"将下面命令发送到 Telegram Bot,可把网页账户与机器人权限绑定。",
"account.mode.supabase": "Supabase 会话鉴权",
"account.mode.legacy": "Legacy Token 鉴权",
"account.mode.disabled": "未启用权限校验",
"account.mode.unknown": "未知模式",
"account.backend.ok": "通过",
"account.backend.fail": "失败",
"account.subscription.active": "有效",
"account.subscription.inactive": "无效/已过期",
"account.subscription.notRequired": "当前未强制订阅",
"account.subscription.unknown": "未知",
"account.copy": "复制",
"account.copied": "已复制",
"account.na": "--",
"common.na": "--",
},
"en-US": {
"header.subtitle": "Weather Derivatives Intelligence",
"header.info": "Tech Notes",
"header.infoAria": "Open system technical notes",
"header.account": "Account",
"header.accountAria": "Open account center",
"header.live": "LIVE",
"header.refreshAria": "Refresh all data",
"header.langAria": "Switch language",
@@ -216,6 +262,48 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"section.distance": "Distance",
"section.note": "Note",
"account.title": "Account Center",
"account.subtitle": "Review identity, access status, and bot binding info",
"account.backDashboard": "Back to Dashboard",
"account.refresh": "Refresh",
"account.signOut": "Sign out",
"account.loading": "Syncing account snapshot...",
"account.error": "Failed to load: {message}",
"account.updatedAt": "Last synced: {time}",
"account.guestName": "PolyWeather User",
"account.authenticated": "Authenticated",
"account.subscriptionActive": "Subscription active",
"account.subscriptionRequired": "Subscription required",
"account.subscriptionUnknown": "Subscription unknown",
"account.card.membership": "Membership & Access",
"account.card.identity": "Identity",
"account.card.backend": "Backend Auth",
"account.card.bot": "Bot Binding",
"account.field.email": "Email",
"account.field.userId": "User ID",
"account.field.provider": "Sign-in method",
"account.field.lastSignIn": "Last sign-in",
"account.field.mode": "Entitlement mode",
"account.field.backendStatus": "Backend status",
"account.field.subscription": "Subscription result",
"account.field.requirement": "Subscription policy",
"account.field.bindCommand": "Binding command",
"account.field.bindHint":
"Send this command to the Telegram bot to bind web account identity.",
"account.mode.supabase": "Supabase session auth",
"account.mode.legacy": "Legacy token auth",
"account.mode.disabled": "Auth guard disabled",
"account.mode.unknown": "Unknown mode",
"account.backend.ok": "Passed",
"account.backend.fail": "Failed",
"account.subscription.active": "Active",
"account.subscription.inactive": "Inactive/Expired",
"account.subscription.notRequired": "Not required now",
"account.subscription.unknown": "Unknown",
"account.copy": "Copy",
"account.copied": "Copied",
"account.na": "--",
"common.na": "--",
},
};
+29
View File
@@ -0,0 +1,29 @@
import { createBrowserClient } from "@supabase/ssr";
import type { SupabaseClient } from "@supabase/supabase-js";
let cachedClient: SupabaseClient | null = null;
function readSupabasePublicEnv() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
return { anonKey, url };
}
export function hasSupabasePublicEnv() {
const { anonKey, url } = readSupabasePublicEnv();
return Boolean(url && anonKey);
}
export function getSupabaseBrowserClient(): SupabaseClient {
if (cachedClient) {
return cachedClient;
}
const { anonKey, url } = readSupabasePublicEnv();
if (!url || !anonKey) {
throw new Error("Supabase public env is not configured");
}
cachedClient = createBrowserClient(url, anonKey);
return cachedClient;
}
+70
View File
@@ -0,0 +1,70 @@
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import type { NextRequest, NextResponse } from "next/server";
type CookieAdapter = {
getAll: () => { name: string; value: string }[];
setAll: (cookies: { name: string; value: string; options?: CookieOptions }[]) => void;
};
function readSupabasePublicEnv() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
return { anonKey, url };
}
export function hasSupabaseServerEnv() {
const { anonKey, url } = readSupabasePublicEnv();
return Boolean(url && anonKey);
}
export function createSupabaseServerClient(
cookieAdapter: CookieAdapter,
) {
const { anonKey, url } = readSupabasePublicEnv();
if (!url || !anonKey) {
throw new Error("Supabase env is not configured");
}
return createServerClient(url, anonKey, {
cookies: cookieAdapter,
});
}
export function createSupabaseMiddlewareClient(
request: NextRequest,
response: NextResponse,
) {
return createSupabaseServerClient({
getAll() {
return request.cookies.getAll().map((item) => ({
name: item.name,
value: item.value,
}));
},
setAll(cookiesToSet) {
for (const cookie of cookiesToSet) {
response.cookies.set(cookie.name, cookie.value, cookie.options);
}
},
});
}
export function createSupabaseRouteClient(
request: NextRequest,
response: NextResponse,
) {
return createSupabaseServerClient({
getAll() {
return request.cookies.getAll().map((item) => ({
name: item.name,
value: item.value,
}));
},
setAll(cookiesToSet) {
for (const cookie of cookiesToSet) {
response.cookies.set(cookie.name, cookie.value, cookie.options);
}
},
});
}