console: boot session load times out (no infinite splash) + kill auto welcome-credit
Two fixes: 1) BOOT HANG (console.hanzo.ai splash): the session bootstrap awaited AccountApi.session() (→ /v1/iam/get-account) with NO timeout, so a degraded backend — the beego IAM proxy hop, a dead pruned route blocking 12s — left the splash pending forever (diagnosed live: get-account PENDING >25s, body empty). New reusable withTimeout() primitive caps the boot resolve at 8s → on timeout the visitor is anonymous and the sign-in card renders; a later reload/refresh resolves the real session. An app must never hand the browser to one request. (Root cause is the beego get-account proxy — fixed for real by the iam2 flip.) 2) AUTO-CREDIT: removed claimWelcomeGrantOnce() from the session bootstrap — the console auto-claimed the $5 welcome trial credit on every authenticated load. No more automatic credit; admin grants at admin.hanzo.ai only. The 19 local tsc errors are a node_modules gap (@hanzo/capture ^0.1.0 not npm-installed locally); CI resolves it. session.tsx + with-timeout.ts are clean.
This commit is contained in:
@@ -18,11 +18,11 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
|
||||
import { AccountApi, type Account } from '~/lib/api'
|
||||
import { withTimeout } from '~/lib/with-timeout'
|
||||
import { isAdminHost } from '~/config'
|
||||
import { getProviderSigninUrl, getSigninUrl, stashReturnTo } from './iam'
|
||||
import { refreshSession } from './refresh'
|
||||
import { setCurrentActor } from '~/lib/actor-scope'
|
||||
import { claimWelcomeGrantOnce } from '~/lib/billing/welcome'
|
||||
import { claimReferralOnce, stashReferralCode } from '~/lib/referrals/claim'
|
||||
import { attributeAffiliateOnce, stashAffiliateCode } from '~/lib/affiliates/claim'
|
||||
|
||||
@@ -47,6 +47,10 @@ const SessionContext = createContext<SessionState | null>(null)
|
||||
const MIN_PROACTIVE_MS = 30_000
|
||||
const MAX_PROACTIVE_MS = 2 * 60 * 60 * 1000 // 2h
|
||||
|
||||
/** Cap the boot session resolve so a slow/hung backend can't hold the splash
|
||||
* forever — on timeout the visitor is anonymous and the sign-in card renders. */
|
||||
const SESSION_BOOT_TIMEOUT_MS = 8_000
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const [account, setAccount] = useState<Account | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -58,11 +62,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const applyAccount = useCallback((a: Account | null) => {
|
||||
setAccount(a)
|
||||
setCurrentActor(a && a.owner && a.name ? `${a.owner}/${a.name}` : '')
|
||||
// Self-heal the one-time $5 welcome trial credit for a just-resolved authenticated
|
||||
// account (social-login + pre-existing $0 users the signup-time grant never reached).
|
||||
// Idempotent server-side; guarded to fire at most once per browser session per org.
|
||||
if (a && a.owner && a.name) {
|
||||
claimWelcomeGrantOnce(a.owner)
|
||||
// Claim a stashed referral (?ref= captured at signup) → binds this org as the
|
||||
// referee. Once per session per org, server-idempotent, best-effort.
|
||||
claimReferralOnce(a.owner)
|
||||
@@ -92,7 +92,16 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { account: acct, expiresIn } = await AccountApi.session()
|
||||
// The boot "who am I" (AccountApi.session → /v1/iam/get-account) must never
|
||||
// pin the splash: a degraded backend (the beego IAM proxy hop, a dead pruned
|
||||
// route) can leave it pending indefinitely. Cap it — on timeout treat the
|
||||
// visitor as anonymous so the sign-in card renders. A later reload/refresh
|
||||
// (or a recovered backend) resolves the real session.
|
||||
const { account: acct, expiresIn } = await withTimeout(
|
||||
AccountApi.session(),
|
||||
SESSION_BOOT_TIMEOUT_MS,
|
||||
{ account: null, expiresIn: null },
|
||||
)
|
||||
applyAccount(acct)
|
||||
armRefresh(acct ? expiresIn : null)
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* withTimeout — race a promise against a deadline, resolving to `fallback` if it
|
||||
* doesn't settle in time instead of awaiting forever. The ONE reusable primitive
|
||||
* for "a UX path must degrade gracefully, never hang on a slow/dead backend".
|
||||
*
|
||||
* Client-safe (no next/server); the server BFF has its own AbortSignal-based
|
||||
* `lib/server/fetch-timeout` for outbound fetch — this is the client-side,
|
||||
* promise-level twin for boot/auth gates that can't hold the browser hostage to
|
||||
* one request. A rejection is treated exactly like a timeout: the fallback wins.
|
||||
*/
|
||||
export function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
return new Promise<T>((resolve) => {
|
||||
let settled = false
|
||||
const done = (v: T) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve(v)
|
||||
}
|
||||
const timer = setTimeout(() => done(fallback), ms)
|
||||
promise.then(done, () => done(fallback))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user