fix: recover account subscription state
This commit is contained in:
@@ -286,7 +286,22 @@ export function AccountCenter() {
|
|||||||
copy.guestUser;
|
copy.guestUser;
|
||||||
const initials = (displayName.slice(0, 2) || "PW").toUpperCase();
|
const initials = (displayName.slice(0, 2) || "PW").toUpperCase();
|
||||||
const joinedAt = formatTime(user?.created_at, locale);
|
const joinedAt = formatTime(user?.created_at, locale);
|
||||||
const isSubscribed = Boolean(backend?.subscription_active);
|
const backendAuthenticated = backend?.authenticated === true;
|
||||||
|
const localAuthenticated = Boolean(user?.id);
|
||||||
|
const isSubscriptionUnknown = Boolean(
|
||||||
|
localAuthenticated &&
|
||||||
|
(!backend ||
|
||||||
|
backend.subscription_active == null ||
|
||||||
|
backend.authenticated === false),
|
||||||
|
);
|
||||||
|
const isSubscribed = backend?.subscription_active === true;
|
||||||
|
const subscriptionStatusLabel = isSubscribed
|
||||||
|
? copy.proMember
|
||||||
|
: isSubscriptionUnknown
|
||||||
|
? copy.subscriptionChecking
|
||||||
|
: isEn
|
||||||
|
? "UNSUBSCRIBED"
|
||||||
|
: "未订阅";
|
||||||
const planCode = String(backend?.subscription_plan_code || "").trim();
|
const planCode = String(backend?.subscription_plan_code || "").trim();
|
||||||
const currentExpiryRaw = String(
|
const currentExpiryRaw = String(
|
||||||
backend?.subscription_expires_at ||
|
backend?.subscription_expires_at ||
|
||||||
@@ -323,6 +338,8 @@ export function AccountCenter() {
|
|||||||
? expiryFormatted !== "--"
|
? expiryFormatted !== "--"
|
||||||
? expiryFormatted
|
? expiryFormatted
|
||||||
: displayExpiryRaw || copy.proPendingSync
|
: displayExpiryRaw || copy.proPendingSync
|
||||||
|
: isSubscriptionUnknown
|
||||||
|
? copy.subscriptionUnknown
|
||||||
: copy.noProSubscription;
|
: copy.noProSubscription;
|
||||||
const showExpiringSoon = Boolean(
|
const showExpiringSoon = Boolean(
|
||||||
isSubscribed &&
|
isSubscribed &&
|
||||||
@@ -337,6 +354,7 @@ export function AccountCenter() {
|
|||||||
const paymentFeatureReady = paymentReadyForRecovery;
|
const paymentFeatureReady = paymentReadyForRecovery;
|
||||||
const canOpenCheckoutOverlay = Boolean(
|
const canOpenCheckoutOverlay = Boolean(
|
||||||
paymentFeatureReady &&
|
paymentFeatureReady &&
|
||||||
|
!isSubscriptionUnknown &&
|
||||||
(!isSubscribed || showExpiringSoon || showExpiredReminder),
|
(!isSubscribed || showExpiringSoon || showExpiredReminder),
|
||||||
);
|
);
|
||||||
const subscriptionStatusTitle = showExpiredReminder
|
const subscriptionStatusTitle = showExpiredReminder
|
||||||
@@ -533,7 +551,13 @@ export function AccountCenter() {
|
|||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`absolute -bottom-2 -right-2 rounded-lg border-4 border-white p-1.5 ${isSubscribed ? "bg-amber-400 text-slate-950" : "bg-slate-100 text-slate-400"}`}
|
className={`absolute -bottom-2 -right-2 rounded-lg border-4 border-white p-1.5 ${
|
||||||
|
isSubscribed
|
||||||
|
? "bg-amber-400 text-slate-950"
|
||||||
|
: isSubscriptionUnknown
|
||||||
|
? "bg-blue-100 text-blue-500"
|
||||||
|
: "bg-slate-100 text-slate-400"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Crown size={16} fill="currentColor" />
|
<Crown size={16} fill="currentColor" />
|
||||||
</div>
|
</div>
|
||||||
@@ -542,13 +566,15 @@ export function AccountCenter() {
|
|||||||
<div className="flex items-center justify-center md:justify-start gap-3 mb-1">
|
<div className="flex items-center justify-center md:justify-start gap-3 mb-1">
|
||||||
<h2 className="text-3xl font-bold text-slate-950">{displayName}</h2>
|
<h2 className="text-3xl font-bold text-slate-950">{displayName}</h2>
|
||||||
<span
|
<span
|
||||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-black uppercase ${isSubscribed ? "border-blue-200 bg-blue-50 text-blue-700" : "border-slate-200 bg-slate-50 text-slate-500"}`}
|
className={`rounded-full border px-2 py-0.5 text-[10px] font-black uppercase ${
|
||||||
|
isSubscribed
|
||||||
|
? "border-blue-200 bg-blue-50 text-blue-700"
|
||||||
|
: isSubscriptionUnknown
|
||||||
|
? "border-blue-200 bg-blue-50 text-blue-600"
|
||||||
|
: "border-slate-200 bg-slate-50 text-slate-500"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isSubscribed
|
{subscriptionStatusLabel}
|
||||||
? copy.proMember
|
|
||||||
: isEn
|
|
||||||
? "UNSUBSCRIBED"
|
|
||||||
: "未订阅"}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-4 font-mono text-sm text-slate-500">
|
<p className="mb-4 font-mono text-sm text-slate-500">
|
||||||
@@ -685,19 +711,37 @@ export function AccountCenter() {
|
|||||||
<InfoRow
|
<InfoRow
|
||||||
icon={Zap}
|
icon={Zap}
|
||||||
label={copy.intradayAnalysis}
|
label={copy.intradayAnalysis}
|
||||||
value={isSubscribed ? copy.deepMode : copy.compactVisible}
|
value={
|
||||||
|
isSubscriptionUnknown
|
||||||
|
? copy.subscriptionChecking
|
||||||
|
: isSubscribed
|
||||||
|
? copy.deepMode
|
||||||
|
: copy.compactVisible
|
||||||
|
}
|
||||||
isPrimary={isSubscribed}
|
isPrimary={isSubscribed}
|
||||||
/>
|
/>
|
||||||
<InfoRow
|
<InfoRow
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
label={copy.historyFuture}
|
label={copy.historyFuture}
|
||||||
value={isSubscribed ? copy.enabled : copy.locked}
|
value={
|
||||||
|
isSubscriptionUnknown
|
||||||
|
? copy.subscriptionChecking
|
||||||
|
: isSubscribed
|
||||||
|
? copy.enabled
|
||||||
|
: copy.locked
|
||||||
|
}
|
||||||
isPrimary={isSubscribed}
|
isPrimary={isSubscribed}
|
||||||
/>
|
/>
|
||||||
<InfoRow
|
<InfoRow
|
||||||
icon={Bot}
|
icon={Bot}
|
||||||
label={copy.smartPush}
|
label={copy.smartPush}
|
||||||
value={isSubscribed ? copy.enabled : copy.locked}
|
value={
|
||||||
|
isSubscriptionUnknown
|
||||||
|
? copy.subscriptionChecking
|
||||||
|
: isSubscribed
|
||||||
|
? copy.enabled
|
||||||
|
: copy.locked
|
||||||
|
}
|
||||||
isPrimary={isSubscribed}
|
isPrimary={isSubscribed}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -724,7 +768,13 @@ export function AccountCenter() {
|
|||||||
<InfoRow
|
<InfoRow
|
||||||
icon={UserCheck}
|
icon={UserCheck}
|
||||||
label={copy.authResult}
|
label={copy.authResult}
|
||||||
value={backend?.authenticated ? copy.passed : copy.restricted}
|
value={
|
||||||
|
backendAuthenticated
|
||||||
|
? copy.passed
|
||||||
|
: isSubscriptionUnknown
|
||||||
|
? copy.subscriptionChecking
|
||||||
|
: copy.restricted
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{queuedExtensionSummary ? (
|
{queuedExtensionSummary ? (
|
||||||
<p className="rounded-xl border border-cyan-200 bg-cyan-50 px-4 py-3 text-xs text-cyan-800">
|
<p className="rounded-xl border border-cyan-200 bg-cyan-50 px-4 py-3 text-xs text-cyan-800">
|
||||||
|
|||||||
@@ -139,6 +139,20 @@ export function runTests() {
|
|||||||
accountCenterSource.includes('trackAppEvent("dashboard_active"'),
|
accountCenterSource.includes('trackAppEvent("dashboard_active"'),
|
||||||
"account center must emit signup_completed and dashboard_active so the ops funnel has top-of-funnel data",
|
"account center must emit signup_completed and dashboard_active so the ops funnel has top-of-funnel data",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
accountCenterSource.includes("isSubscriptionUnknown") &&
|
||||||
|
accountCenterSource.includes("subscriptionStatusLabel") &&
|
||||||
|
!accountCenterSource.includes(
|
||||||
|
'backend?.subscription_active);',
|
||||||
|
),
|
||||||
|
"account center must distinguish unknown subscription sync state from a confirmed unsubscribed account",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
hookSource.includes("backendJson.authenticated === false") &&
|
||||||
|
hookSource.includes("refreshSession()") &&
|
||||||
|
hookSource.includes("retriedBackendJson"),
|
||||||
|
"account snapshot loader must retry with a refreshed Supabase token when local user exists but /api/auth/me reports unauthenticated",
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
subscriptionsPageSource.includes("getSupabaseBrowserClient") &&
|
subscriptionsPageSource.includes("getSupabaseBrowserClient") &&
|
||||||
subscriptionsPageSource.includes("refreshSession") &&
|
subscriptionsPageSource.includes("refreshSession") &&
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
|||||||
: "钱包已绑定,正在创建订单并发起支付...",
|
: "钱包已绑定,正在创建订单并发起支付...",
|
||||||
proMember: "PRO MEMBER",
|
proMember: "PRO MEMBER",
|
||||||
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
|
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
|
||||||
|
subscriptionChecking: isEn ? "Checking subscription" : "订阅同步中",
|
||||||
|
subscriptionUnknown: isEn
|
||||||
|
? "Subscription status is syncing. Please refresh shortly."
|
||||||
|
: "订阅状态正在同步,请稍后刷新。",
|
||||||
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
|
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
|
||||||
proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期",
|
proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期",
|
||||||
proEndsSoonBody: isEn
|
proEndsSoonBody: isEn
|
||||||
|
|||||||
@@ -167,7 +167,36 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
|||||||
const raw = (await backendResult.text()).slice(0, 260);
|
const raw = (await backendResult.text()).slice(0, 260);
|
||||||
throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw));
|
throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw));
|
||||||
}
|
}
|
||||||
const backendJson = (await backendResult.json()) as AuthMeResponse;
|
let backendJson = (await backendResult.json()) as AuthMeResponse;
|
||||||
|
if (
|
||||||
|
retry &&
|
||||||
|
supabaseReady &&
|
||||||
|
userResult.data?.user &&
|
||||||
|
backendJson.authenticated === false
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { session: refreshedSession },
|
||||||
|
} = await getSupabaseBrowserClient().auth.refreshSession();
|
||||||
|
const refreshedToken = String(
|
||||||
|
refreshedSession?.access_token || "",
|
||||||
|
).trim();
|
||||||
|
if (refreshedToken) {
|
||||||
|
const retriedBackendResult = await fetch("/api/auth/me", {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { Authorization: `Bearer ${refreshedToken}` },
|
||||||
|
});
|
||||||
|
if (retriedBackendResult.ok) {
|
||||||
|
const retriedBackendJson =
|
||||||
|
(await retriedBackendResult.json()) as AuthMeResponse;
|
||||||
|
backendJson = retriedBackendJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep the first response; the UI treats a logged-in local user with
|
||||||
|
// an unauthenticated backend snapshot as a temporary sync state.
|
||||||
|
}
|
||||||
|
}
|
||||||
setBackend(backendJson);
|
setBackend(backendJson);
|
||||||
setUpdatedAt(new Date().toISOString());
|
setUpdatedAt(new Date().toISOString());
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
import web.core as web_core
|
||||||
from web.app import app
|
from web.app import app
|
||||||
import web.routes as routes
|
import web.routes as routes
|
||||||
import web.scan_terminal_cache as scan_terminal_cache
|
import web.scan_terminal_cache as scan_terminal_cache
|
||||||
@@ -141,6 +143,30 @@ def test_auth_me_does_not_reconcile_on_status_probe(monkeypatch):
|
|||||||
assert reconcile_calls["count"] == 0
|
assert reconcile_calls["count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_entitlement_token_binds_forwarded_supabase_identity(monkeypatch):
|
||||||
|
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
|
||||||
|
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
|
||||||
|
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key")
|
||||||
|
monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True)
|
||||||
|
monkeypatch.setattr(web_core, "_ENTITLEMENT_TOKEN", "backend-token")
|
||||||
|
|
||||||
|
request = Request(
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"headers": [
|
||||||
|
(b"x-polyweather-entitlement", b"backend-token"),
|
||||||
|
(b"x-polyweather-auth-user-id", b"user-1"),
|
||||||
|
(b"x-polyweather-auth-email", b"user@example.com"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
web_core._assert_entitlement(request)
|
||||||
|
|
||||||
|
assert request.state.auth_user_id == "user-1"
|
||||||
|
assert request.state.auth_email == "user@example.com"
|
||||||
|
|
||||||
|
|
||||||
def test_ops_memberships_prefers_supabase_auth_email(monkeypatch):
|
def test_ops_memberships_prefers_supabase_auth_email(monkeypatch):
|
||||||
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||||
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
|
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
|
||||||
|
|||||||
+18
@@ -217,14 +217,31 @@ 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_forwarded_supabase_identity(request: Request) -> bool:
|
||||||
|
if not _legacy_service_token_valid(request):
|
||||||
|
return False
|
||||||
|
forwarded_user_id = str(
|
||||||
|
request.headers.get(_FORWARDED_SUPABASE_USER_ID_HEADER) or ""
|
||||||
|
).strip()
|
||||||
|
if not forwarded_user_id:
|
||||||
|
return False
|
||||||
|
request.state.auth_user_id = forwarded_user_id
|
||||||
|
request.state.auth_email = str(
|
||||||
|
request.headers.get(_FORWARDED_SUPABASE_EMAIL_HEADER) or ""
|
||||||
|
).strip()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _bind_optional_supabase_identity(request: Request) -> None:
|
def _bind_optional_supabase_identity(request: Request) -> None:
|
||||||
if not SUPABASE_ENTITLEMENT.configured:
|
if not SUPABASE_ENTITLEMENT.configured:
|
||||||
return
|
return
|
||||||
access_token = extract_bearer_token(request.headers.get("authorization"))
|
access_token = extract_bearer_token(request.headers.get("authorization"))
|
||||||
if not access_token:
|
if not access_token:
|
||||||
|
_bind_forwarded_supabase_identity(request)
|
||||||
return
|
return
|
||||||
identity = SUPABASE_ENTITLEMENT.get_identity(access_token)
|
identity = SUPABASE_ENTITLEMENT.get_identity(access_token)
|
||||||
if not identity:
|
if not identity:
|
||||||
|
_bind_forwarded_supabase_identity(request)
|
||||||
return
|
return
|
||||||
request.state.auth_user_id = identity.user_id
|
request.state.auth_user_id = identity.user_id
|
||||||
request.state.auth_email = identity.email
|
request.state.auth_email = identity.email
|
||||||
@@ -286,6 +303,7 @@ def _resolve_weekly_profile(request: Request) -> Dict[str, Any]:
|
|||||||
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):
|
||||||
|
_bind_forwarded_supabase_identity(request)
|
||||||
return
|
return
|
||||||
if not _SUPABASE_AUTH_REQUIRED:
|
if not _SUPABASE_AUTH_REQUIRED:
|
||||||
_bind_optional_supabase_identity(request)
|
_bind_optional_supabase_identity(request)
|
||||||
|
|||||||
Reference in New Issue
Block a user