feat: Implement Supabase authentication, account management UI, and entitlement services.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user