feat: Implement account management center with authentication and subscription features, supported by new backend, i18n, and Supabase integration.

This commit is contained in:
2569718930@qq.com
2026-03-13 03:27:56 +08:00
parent 0a869459c4
commit 9cc9ab93c4
7 changed files with 125 additions and 35 deletions
+2
View File
@@ -28,6 +28,8 @@ ENV=production
POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/ POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
# Unified Auth (Supabase + Google/Email) # Unified Auth (Supabase + Google/Email)
POLYWEATHER_AUTH_ENABLED=false POLYWEATHER_AUTH_ENABLED=false
# If true: website APIs require login; if false: guest access, login optional.
POLYWEATHER_AUTH_REQUIRED=false
# If true, authenticated users must also have an active row in `subscriptions`. # If true, authenticated users must also have an active row in `subscriptions`.
POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false
SUPABASE_URL= SUPABASE_URL=
+11 -6
View File
@@ -37,6 +37,8 @@
NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_PUBLIC_SUPABASE_ANON_KEY=
POLYWEATHER_AUTH_ENABLED=true POLYWEATHER_AUTH_ENABLED=true
# true: 强制登录;false: 游客可用(可选登录)
POLYWEATHER_AUTH_REQUIRED=false
POLYWEATHER_API_BASE_URL=http://<backend-host>:8000 POLYWEATHER_API_BASE_URL=http://<backend-host>:8000
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
``` ```
@@ -45,6 +47,8 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
```env ```env
POLYWEATHER_AUTH_ENABLED=true POLYWEATHER_AUTH_ENABLED=true
# true: 后端 API 强制鉴权;false: 游客可访问,若带会话则自动识别用户
POLYWEATHER_AUTH_REQUIRED=false
POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=false
SUPABASE_URL= SUPABASE_URL=
SUPABASE_ANON_KEY= SUPABASE_ANON_KEY=
@@ -63,13 +67,14 @@ POLYWEATHER_BOT_USE_SUPABASE_ENTITLEMENT=true
## 5. entitlement 策略 ## 5. entitlement 策略
- `POLYWEATHER_AUTH_ENABLED=true`要求请求携带有效 Supabase 用户会话 - `POLYWEATHER_AUTH_ENABLED=true`启用 Supabase 登录能力(Google/邮箱)
- `POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=true`:额外要求 `subscriptions` 表里存在有效 `active`录。 - `POLYWEATHER_AUTH_REQUIRED=true`:网站与后端 API 强制登录。
- `POLYWEATHER_AUTH_REQUIRED=false`:游客可访问全部功能,用户可主动登录。
- `POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION=true`:在强制鉴权模式下,额外要求 `subscriptions` 表里存在有效 `active` 记录。
## 6. 验证 ## 6. 验证
1. 访问 `/auth/login`,测试 Google 一键登录。 1. 访问 `/auth/login`,测试 Google 一键登录。
2. 登录访问首页,确认页面可用 2. `POLYWEATHER_AUTH_REQUIRED=false` 时,未登录访问首页`/api/cities` 应返回 200
3. 调用 `/api/cities`确认返回 200 3. 登录后访问 `/api/auth/me`返回 `authenticated=true``user_id`
4. 退出登录后再次访问,确认被重定向到登录页。 4. `POLYWEATHER_AUTH_REQUIRED=true` 时,未登录访问受保护接口应返回 401 或跳转登录页。
+2
View File
@@ -3,6 +3,8 @@ POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_PUBLIC_SUPABASE_ANON_KEY=
POLYWEATHER_AUTH_ENABLED=false POLYWEATHER_AUTH_ENABLED=false
# If true: middleware forces login; if false: optional login mode (guests allowed).
POLYWEATHER_AUTH_REQUIRED=false
# Optional dashboard guard (Next.js middleware) # Optional dashboard guard (Next.js middleware)
# If set, open dashboard with: /?access_token=<token> # If set, open dashboard with: /?access_token=<token>
POLYWEATHER_DASHBOARD_ACCESS_TOKEN= POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
+43 -21
View File
@@ -11,6 +11,7 @@ import {
Copy, Copy,
KeyRound, KeyRound,
Loader2, Loader2,
LogIn,
LogOut, LogOut,
RefreshCw, RefreshCw,
ShieldCheck, ShieldCheck,
@@ -28,6 +29,7 @@ type AuthMeResponse = {
user_id?: string | null; user_id?: string | null;
email?: string | null; email?: string | null;
entitlement_mode?: string | null; entitlement_mode?: string | null;
auth_required?: boolean;
subscription_required?: boolean; subscription_required?: boolean;
subscription_active?: boolean | null; subscription_active?: boolean | null;
}; };
@@ -130,6 +132,7 @@ export function AccountCenter() {
}; };
const userId = backend?.user_id || user?.id || ""; const userId = backend?.user_id || user?.id || "";
const isAuthenticated = Boolean(userId);
const email = backend?.email || user?.email || ""; const email = backend?.email || user?.email || "";
const providerRaw = normalizeProvider(user); const providerRaw = normalizeProvider(user);
const provider = providerRaw ? providerRaw.toUpperCase() : t("account.na"); const provider = providerRaw ? providerRaw.toUpperCase() : t("account.na");
@@ -143,6 +146,8 @@ export function AccountCenter() {
const modeLabel = useMemo(() => { const modeLabel = useMemo(() => {
const mode = String(backend?.entitlement_mode || "").trim().toLowerCase(); const mode = String(backend?.entitlement_mode || "").trim().toLowerCase();
if (mode === "supabase_required") return t("account.mode.supabaseRequired");
if (mode === "supabase_optional") return t("account.mode.supabaseOptional");
if (mode === "supabase") return t("account.mode.supabase"); if (mode === "supabase") return t("account.mode.supabase");
if (mode === "legacy_token") return t("account.mode.legacy"); if (mode === "legacy_token") return t("account.mode.legacy");
if (mode === "disabled") return t("account.mode.disabled"); if (mode === "disabled") return t("account.mode.disabled");
@@ -192,10 +197,17 @@ export function AccountCenter() {
{refreshing ? <Loader2 size={15} className={styles.spin} /> : <RefreshCw size={15} />} {refreshing ? <Loader2 size={15} className={styles.spin} /> : <RefreshCw size={15} />}
{t("account.refresh")} {t("account.refresh")}
</button> </button>
<button type="button" className={styles.primaryBtn} onClick={() => void onSignOut()}> {isAuthenticated ? (
<LogOut size={15} /> <button type="button" className={styles.primaryBtn} onClick={() => void onSignOut()}>
{t("account.signOut")} <LogOut size={15} />
</button> {t("account.signOut")}
</button>
) : (
<Link className={styles.primaryBtn} href="/auth/login?next=%2Faccount">
<LogIn size={15} />
{t("account.signIn")}
</Link>
)}
</div> </div>
</header> </header>
@@ -205,24 +217,33 @@ export function AccountCenter() {
<h2>{displayName}</h2> <h2>{displayName}</h2>
<p>{email || t("account.na")}</p> <p>{email || t("account.na")}</p>
<div className={styles.badges}> <div className={styles.badges}>
<span className={styles.badge}> {isAuthenticated ? (
<CheckCircle2 size={14} /> <>
{t("account.authenticated")} <span className={styles.badge}>
</span> <CheckCircle2 size={14} />
{backend?.subscription_active ? ( {t("account.authenticated")}
<span className={styles.badge}> </span>
<ShieldCheck size={14} /> {backend?.subscription_active ? (
{t("account.subscriptionActive")} <span className={styles.badge}>
</span> <ShieldCheck size={14} />
) : backend?.subscription_required ? ( {t("account.subscriptionActive")}
<span className={styles.badgeWarn}> </span>
<KeyRound size={14} /> ) : backend?.subscription_required ? (
{t("account.subscriptionRequired")} <span className={styles.badgeWarn}>
</span> <KeyRound size={14} />
{t("account.subscriptionRequired")}
</span>
) : (
<span className={styles.badgeGhost}>
<ShieldCheck size={14} />
{t("account.subscriptionUnknown")}
</span>
)}
</>
) : ( ) : (
<span className={styles.badgeGhost}> <span className={styles.badgeGhost}>
<ShieldCheck size={14} /> <ShieldCheck size={14} />
{t("account.subscriptionUnknown")} {t("account.guest")}
</span> </span>
)} )}
</div> </div>
@@ -259,7 +280,9 @@ export function AccountCenter() {
<dd> <dd>
{backend?.authenticated {backend?.authenticated
? t("account.backend.ok") ? t("account.backend.ok")
: t("account.backend.fail")} : backend?.auth_required
? t("account.backend.fail")
: t("account.guest")}
</dd> </dd>
</div> </div>
<div> <div>
@@ -328,4 +351,3 @@ export function AccountCenter() {
</main> </main>
); );
} }
+8
View File
@@ -114,12 +114,14 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"account.subtitle": "查看身份、权限与 Bot 绑定信息", "account.subtitle": "查看身份、权限与 Bot 绑定信息",
"account.backDashboard": "返回看板", "account.backDashboard": "返回看板",
"account.refresh": "刷新", "account.refresh": "刷新",
"account.signIn": "登录 / 注册",
"account.signOut": "退出登录", "account.signOut": "退出登录",
"account.loading": "正在同步账户信息...", "account.loading": "正在同步账户信息...",
"account.error": "加载失败: {message}", "account.error": "加载失败: {message}",
"account.updatedAt": "最近同步: {time}", "account.updatedAt": "最近同步: {time}",
"account.guestName": "PolyWeather 用户", "account.guestName": "PolyWeather 用户",
"account.authenticated": "已登录", "account.authenticated": "已登录",
"account.guest": "游客模式",
"account.subscriptionActive": "订阅有效", "account.subscriptionActive": "订阅有效",
"account.subscriptionRequired": "需要订阅", "account.subscriptionRequired": "需要订阅",
"account.subscriptionUnknown": "订阅状态未知", "account.subscriptionUnknown": "订阅状态未知",
@@ -138,6 +140,8 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"account.field.bindCommand": "绑定命令", "account.field.bindCommand": "绑定命令",
"account.field.bindHint": "account.field.bindHint":
"将下面命令发送到 Telegram Bot,可把网页账户与机器人权限绑定。", "将下面命令发送到 Telegram Bot,可把网页账户与机器人权限绑定。",
"account.mode.supabaseRequired": "Supabase 强制登录",
"account.mode.supabaseOptional": "Supabase 可选登录",
"account.mode.supabase": "Supabase 会话鉴权", "account.mode.supabase": "Supabase 会话鉴权",
"account.mode.legacy": "Legacy Token 鉴权", "account.mode.legacy": "Legacy Token 鉴权",
"account.mode.disabled": "未启用权限校验", "account.mode.disabled": "未启用权限校验",
@@ -266,12 +270,14 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"account.subtitle": "Review identity, access status, and bot binding info", "account.subtitle": "Review identity, access status, and bot binding info",
"account.backDashboard": "Back to Dashboard", "account.backDashboard": "Back to Dashboard",
"account.refresh": "Refresh", "account.refresh": "Refresh",
"account.signIn": "Sign in / Sign up",
"account.signOut": "Sign out", "account.signOut": "Sign out",
"account.loading": "Syncing account snapshot...", "account.loading": "Syncing account snapshot...",
"account.error": "Failed to load: {message}", "account.error": "Failed to load: {message}",
"account.updatedAt": "Last synced: {time}", "account.updatedAt": "Last synced: {time}",
"account.guestName": "PolyWeather User", "account.guestName": "PolyWeather User",
"account.authenticated": "Authenticated", "account.authenticated": "Authenticated",
"account.guest": "Guest Mode",
"account.subscriptionActive": "Subscription active", "account.subscriptionActive": "Subscription active",
"account.subscriptionRequired": "Subscription required", "account.subscriptionRequired": "Subscription required",
"account.subscriptionUnknown": "Subscription unknown", "account.subscriptionUnknown": "Subscription unknown",
@@ -290,6 +296,8 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"account.field.bindCommand": "Binding command", "account.field.bindCommand": "Binding command",
"account.field.bindHint": "account.field.bindHint":
"Send this command to the Telegram bot to bind web account identity.", "Send this command to the Telegram bot to bind web account identity.",
"account.mode.supabaseRequired": "Supabase required auth",
"account.mode.supabaseOptional": "Supabase optional auth",
"account.mode.supabase": "Supabase session auth", "account.mode.supabase": "Supabase session auth",
"account.mode.legacy": "Legacy token auth", "account.mode.legacy": "Legacy token auth",
"account.mode.disabled": "Auth guard disabled", "account.mode.disabled": "Auth guard disabled",
+27 -4
View File
@@ -5,10 +5,19 @@ import {
} from "@/lib/supabase/server"; } from "@/lib/supabase/server";
const SESSION_COOKIE = "polyweather_entitlement"; const SESSION_COOKIE = "polyweather_entitlement";
function readEnvBool(name: string, fallback: boolean) {
const raw = process.env[name];
if (raw == null) return fallback;
return String(raw).trim().toLowerCase() === "true";
}
const SUPABASE_AUTH_ENABLED = const SUPABASE_AUTH_ENABLED =
String(process.env.POLYWEATHER_AUTH_ENABLED || "") readEnvBool("POLYWEATHER_AUTH_ENABLED", false);
.trim() const SUPABASE_AUTH_REQUIRED = readEnvBool(
.toLowerCase() === "true"; "POLYWEATHER_AUTH_REQUIRED",
SUPABASE_AUTH_ENABLED,
);
function isStaticAsset(pathname: string) { function isStaticAsset(pathname: string) {
return ( return (
@@ -114,6 +123,17 @@ async function handleSupabaseAuthGate(request: NextRequest) {
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);
} }
async function handleSupabaseOptionalSession(request: NextRequest) {
const response = NextResponse.next({
request: {
headers: request.headers,
},
});
const supabase = createSupabaseMiddlewareClient(request, response);
await supabase.auth.getUser();
return response;
}
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
if (isStaticAsset(pathname)) { if (isStaticAsset(pathname)) {
@@ -121,7 +141,10 @@ export async function middleware(request: NextRequest) {
} }
if (SUPABASE_AUTH_ENABLED && hasSupabaseServerEnv()) { if (SUPABASE_AUTH_ENABLED && hasSupabaseServerEnv()) {
return handleSupabaseAuthGate(request); if (SUPABASE_AUTH_REQUIRED) {
return handleSupabaseAuthGate(request);
}
return handleSupabaseOptionalSession(request);
} }
return handleLegacyTokenGate(request); return handleLegacyTokenGate(request);
} }
+32 -4
View File
@@ -91,6 +91,10 @@ def _env_bool(name: str, default: bool = False) -> bool:
_ENTITLEMENT_GUARD_ENABLED = _env_bool("POLYWEATHER_REQUIRE_ENTITLEMENT", False) _ENTITLEMENT_GUARD_ENABLED = _env_bool("POLYWEATHER_REQUIRE_ENTITLEMENT", False)
_ENTITLEMENT_HEADER = "x-polyweather-entitlement" _ENTITLEMENT_HEADER = "x-polyweather-entitlement"
_ENTITLEMENT_TOKEN = (os.getenv("POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN") or "").strip() _ENTITLEMENT_TOKEN = (os.getenv("POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN") or "").strip()
_SUPABASE_AUTH_REQUIRED = _env_bool(
"POLYWEATHER_AUTH_REQUIRED",
SUPABASE_ENTITLEMENT.enabled,
)
def _legacy_service_token_valid(request: Request) -> bool: def _legacy_service_token_valid(request: Request) -> bool:
@@ -100,10 +104,26 @@ def _legacy_service_token_valid(request: Request) -> bool:
return bool(_ENTITLEMENT_TOKEN and token == _ENTITLEMENT_TOKEN) return bool(_ENTITLEMENT_TOKEN and token == _ENTITLEMENT_TOKEN)
def _bind_optional_supabase_identity(request: Request) -> None:
if not SUPABASE_ENTITLEMENT.configured:
return
access_token = extract_bearer_token(request.headers.get("authorization"))
if not access_token:
return
identity = SUPABASE_ENTITLEMENT.get_identity(access_token)
if not identity:
return
request.state.auth_user_id = identity.user_id
request.state.auth_email = identity.email
def _assert_entitlement(request: Request) -> None: def _assert_entitlement(request: Request) -> None:
if SUPABASE_ENTITLEMENT.enabled: if SUPABASE_ENTITLEMENT.enabled:
if _legacy_service_token_valid(request): if _legacy_service_token_valid(request):
return return
if not _SUPABASE_AUTH_REQUIRED:
_bind_optional_supabase_identity(request)
return
if not SUPABASE_ENTITLEMENT.configured: if not SUPABASE_ENTITLEMENT.configured:
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
@@ -1003,28 +1023,36 @@ async def city_history(request: Request, name: str):
@app.get("/api/auth/me") @app.get("/api/auth/me")
async def auth_me(request: Request): async def auth_me(request: Request):
_assert_entitlement(request) _assert_entitlement(request)
_bind_optional_supabase_identity(request)
user_id = getattr(request.state, "auth_user_id", None) user_id = getattr(request.state, "auth_user_id", None)
subscription_required = bool( subscription_required = bool(
SUPABASE_ENTITLEMENT.enabled and SUPABASE_ENTITLEMENT.require_subscription SUPABASE_ENTITLEMENT.enabled
and _SUPABASE_AUTH_REQUIRED
and SUPABASE_ENTITLEMENT.require_subscription
) )
subscription_active = None subscription_active = None
if SUPABASE_ENTITLEMENT.enabled and user_id: if SUPABASE_ENTITLEMENT.enabled and user_id:
try: try:
subscription_active = SUPABASE_ENTITLEMENT.has_active_subscription(user_id) if SUPABASE_ENTITLEMENT.require_subscription:
subscription_active = SUPABASE_ENTITLEMENT.has_active_subscription(user_id)
except Exception: except Exception:
subscription_active = None subscription_active = None
return { return {
"authenticated": True, "authenticated": bool(user_id),
"user_id": user_id, "user_id": user_id,
"email": getattr(request.state, "auth_email", None), "email": getattr(request.state, "auth_email", None),
"entitlement_mode": ( "entitlement_mode": (
"supabase" "supabase_required"
if SUPABASE_ENTITLEMENT.enabled and _SUPABASE_AUTH_REQUIRED
else "supabase_optional"
if SUPABASE_ENTITLEMENT.enabled if SUPABASE_ENTITLEMENT.enabled
else "legacy_token" else "legacy_token"
if _ENTITLEMENT_GUARD_ENABLED if _ENTITLEMENT_GUARD_ENABLED
else "disabled" else "disabled"
), ),
"auth_required": bool(SUPABASE_ENTITLEMENT.enabled and _SUPABASE_AUTH_REQUIRED),
"subscription_required": subscription_required, "subscription_required": subscription_required,
"subscription_active": subscription_active, "subscription_active": subscription_active,
} }