diff --git a/apps/code/src/main/services/auth/service.ts b/apps/code/src/main/services/auth/service.ts index f391226de..fcd8c4d75 100644 --- a/apps/code/src/main/services/auth/service.ts +++ b/apps/code/src/main/services/auth/service.ts @@ -113,6 +113,16 @@ export class AuthService extends TypedEventEmitter { return this.getState(); } async getValidAccessToken(): Promise { + const override = process.env.VITE_POSTHOG_ACCESS_TOKEN_OVERRIDE; + if (override) { + await this.initialize(); + const region = this.session?.cloudRegion ?? "us"; + return { + accessToken: override, + apiHost: getCloudUrlFromRegion(region), + }; + } + await this.initialize(); const session = await this.ensureValidSession(); @@ -122,6 +132,16 @@ export class AuthService extends TypedEventEmitter { }; } async refreshAccessToken(): Promise { + const override = process.env.VITE_POSTHOG_ACCESS_TOKEN_OVERRIDE; + if (override) { + await this.initialize(); + const region = this.session?.cloudRegion ?? "us"; + return { + accessToken: override, + apiHost: getCloudUrlFromRegion(region), + }; + } + await this.initialize(); const session = await this.ensureValidSession(true); diff --git a/apps/code/src/renderer/api/fetcher.test.ts b/apps/code/src/renderer/api/fetcher.test.ts index e3890a7ae..62a510620 100644 --- a/apps/code/src/renderer/api/fetcher.test.ts +++ b/apps/code/src/renderer/api/fetcher.test.ts @@ -13,11 +13,19 @@ describe("buildApiFetcher", () => { status: 200, json: () => Promise.resolve(data), }); - const err = (status: number) => ({ - ok: false, - status, - json: () => Promise.resolve({ error: status }), - }); + const err = (status: number) => { + const response = { + ok: false, + status, + statusText: `Error ${status}`, + json: () => Promise.resolve({ error: status }), + clone: () => ({ + ...response, + text: () => Promise.resolve(`Error ${status}`), + }), + }; + return response; + }; beforeEach(() => { vi.resetAllMocks(); diff --git a/apps/code/src/renderer/api/fetcher.ts b/apps/code/src/renderer/api/fetcher.ts index cccefd7eb..74a45e26d 100644 --- a/apps/code/src/renderer/api/fetcher.ts +++ b/apps/code/src/renderer/api/fetcher.ts @@ -64,7 +64,12 @@ export const buildApiFetcher: (config: { await config.refreshAccessToken(), ); } catch { - const errorResponse = await response.json(); + const cloned = response.clone(); + const errorResponse = await response + .json() + .catch(() => + cloned.text().then((t) => ({ error: t || `${response.status}` })), + ); throw new Error( `Failed request: [${response.status}] ${JSON.stringify(errorResponse)}`, ); @@ -72,7 +77,12 @@ export const buildApiFetcher: (config: { } if (!response.ok) { - const errorResponse = await response.json(); + const cloned = response.clone(); + const errorResponse = await response + .json() + .catch(() => + cloned.text().then((t) => ({ error: t || `${response.status}` })), + ); throw new Error( `Failed request: [${response.status}] ${JSON.stringify(errorResponse)}`, ); diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 8f64e5037..1c1e10fb1 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -2460,11 +2460,15 @@ export class PostHogAPIClient { return data.results ?? data ?? []; } - async getMySeat(): Promise { + async getMySeat( + options: { best?: boolean } = { best: true }, + ): Promise { try { const url = new URL(`${this.api.baseUrl}/api/seats/me/`); url.searchParams.set("product_key", SEAT_PRODUCT_KEY); - url.searchParams.set("best", "true"); + if (options.best) { + url.searchParams.set("best", "true"); + } const response = await this.api.fetcher.fetch({ method: "get", url, diff --git a/apps/code/src/renderer/components/FullScreenLayout.tsx b/apps/code/src/renderer/components/FullScreenLayout.tsx index 3fcfef1aa..45037b565 100644 --- a/apps/code/src/renderer/components/FullScreenLayout.tsx +++ b/apps/code/src/renderer/components/FullScreenLayout.tsx @@ -1,3 +1,4 @@ +import { UpdateBanner } from "@features/sidebar/components/UpdateBanner"; import { Lifebuoy } from "@phosphor-icons/react"; import { Button, Flex, Theme } from "@radix-ui/themes"; import phWordmark from "@renderer/assets/images/wordmark.svg"; @@ -63,20 +64,23 @@ export function FullScreenLayout({ className="absolute right-[32px] bottom-[20px] left-[32px] z-[2]" > {footerLeft ?? ( - + + + + )} {footerRight ??
} diff --git a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts b/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts index 03d489290..a8ed01f4c 100644 --- a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts +++ b/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts @@ -14,6 +14,7 @@ import { trpcClient } from "@renderer/trpc/client"; import { BILLING_FLAG } from "@shared/constants"; import { identifyUser, resetUser } from "@utils/analytics"; import { logger } from "@utils/logger"; +import { queryClient } from "@utils/queryClient"; import { useEffect } from "react"; const log = logger.scope("auth-session"); @@ -93,9 +94,8 @@ function useSeatSync( return; } - void useSeatStore.getState().fetchSeat({ - autoProvision: true, - }); + void useSeatStore.getState().fetchSeat({ autoProvision: true }); + void queryClient.invalidateQueries({ queryKey: [["llmGateway"]] }); }, [authIdentity, billingEnabled]); } diff --git a/apps/code/src/renderer/features/billing/stores/seatStore.test.ts b/apps/code/src/renderer/features/billing/stores/seatStore.test.ts index b2e4cffb3..72e344b73 100644 --- a/apps/code/src/renderer/features/billing/stores/seatStore.test.ts +++ b/apps/code/src/renderer/features/billing/stores/seatStore.test.ts @@ -85,6 +85,7 @@ describe("seatStore", () => { vi.clearAllMocks(); useSeatStore.setState({ seat: null, + orgSeat: null, isLoading: false, error: null, redirectUrl: null, diff --git a/apps/code/src/renderer/features/billing/stores/seatStore.ts b/apps/code/src/renderer/features/billing/stores/seatStore.ts index 531ab5c01..f5064416b 100644 --- a/apps/code/src/renderer/features/billing/stores/seatStore.ts +++ b/apps/code/src/renderer/features/billing/stores/seatStore.ts @@ -14,6 +14,7 @@ const log = logger.scope("seat-store"); interface SeatStoreState { seat: SeatData | null; + orgSeat: SeatData | null; isLoading: boolean; error: string | null; redirectUrl: string | null; @@ -40,6 +41,25 @@ async function getClient() { return client; } +async function fetchAndProvision( + client: Awaited>, + options: { best: boolean; autoProvision: boolean }, +): Promise { + let seat = await client.getMySeat({ best: options.best }); + if (!seat && options.autoProvision) { + log.info("No seat found, auto-provisioning free plan", { + best: options.best, + }); + try { + seat = await client.createSeat(PLAN_FREE); + } catch { + log.info("Auto-provision failed, re-fetching seat"); + seat = await client.getMySeat({ best: options.best }); + } + } + return seat; +} + function handleSeatError( error: unknown, set: (state: Partial) => void, @@ -77,6 +97,7 @@ function invalidatePlanCache(): void { const initialState: SeatStoreState = { seat: null, + orgSeat: null, isLoading: false, error: null, redirectUrl: null, @@ -90,18 +111,14 @@ export const useSeatStore = create()((set, get) => ({ set({ isLoading: true, error: null, redirectUrl: null }); try { const client = await getClient(); - let seat = await client.getMySeat(); - if (!seat && options?.autoProvision) { - log.info("No seat found, auto-provisioning free plan"); - try { - seat = await client.createSeat(PLAN_FREE); - } catch { - log.info("Auto-provision failed, re-fetching seat"); - seat = await client.getMySeat(); - } - } + const autoProvision = options?.autoProvision ?? false; + const [seat, orgSeat] = await Promise.all([ + fetchAndProvision(client, { best: true, autoProvision }), + fetchAndProvision(client, { best: false, autoProvision }), + ]); set({ seat, + orgSeat, isLoading: false, billingOrgId: seat?.organization_id ?? null, }); @@ -165,6 +182,7 @@ export const useSeatStore = create()((set, get) => ({ const seat = await client.upgradeSeat(PLAN_PRO); set({ seat, + orgSeat: seat, isLoading: false, billingOrgId: seat.organization_id ?? null, }); @@ -174,6 +192,7 @@ export const useSeatStore = create()((set, get) => ({ const seat = await client.createSeat(PLAN_PRO); set({ seat, + orgSeat: seat, isLoading: false, billingOrgId: seat.organization_id ?? null, }); @@ -191,6 +210,7 @@ export const useSeatStore = create()((set, get) => ({ const seat = await client.getMySeat(); set({ seat, + orgSeat: seat, isLoading: false, billingOrgId: seat?.organization_id ?? null, }); @@ -207,6 +227,7 @@ export const useSeatStore = create()((set, get) => ({ const seat = await client.reactivateSeat(); set({ seat, + orgSeat: seat, isLoading: false, billingOrgId: seat.organization_id ?? null, }); diff --git a/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx b/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx index 7686f6584..60844ef22 100644 --- a/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx @@ -7,6 +7,7 @@ import { useAuthStateValue, useCurrentUser, } from "@features/auth/hooks/authQueries"; +import { useSeatStore } from "@features/billing/stores/seatStore"; import { type ProjectInfo, useProjects, @@ -107,6 +108,7 @@ export function ProjectSelectStep({ onNext, onBack }: ProjectSelectStepProps) { await queryClient.invalidateQueries({ queryKey: authKeys.currentUsers(), }); + void useSeatStore.getState().fetchSeat({ autoProvision: true }); }, onMutate: () => { setIsSwitchingOrg(true); diff --git a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx index f86802341..182a1b662 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -8,6 +8,7 @@ import { ArrowSquareOut, Check, CreditCard, + Info, WarningCircle, } from "@phosphor-icons/react"; import { @@ -56,13 +57,15 @@ function formatResetTime(seconds: number): string { export function PlanUsageSettings() { const { seat, - isPro, + orgSeat, + isOrgPro, isCanceling, activeUntil, isLoading, error, redirectUrl, billingOrgId, + hasBetterPlanElsewhere, } = useSeat(); const { fetchSeat, upgradeToPro, cancelSeat, reactivateSeat, clearError } = useSeatStore(); @@ -73,7 +76,7 @@ export function PlanUsageSettings() { : null; const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); - const isAlpha = seat?.plan_key === PLAN_PRO_ALPHA; + const isAlpha = orgSeat?.plan_key === PLAN_PRO_ALPHA; const { usage, isLoading: usageLoading, @@ -83,7 +86,7 @@ export function PlanUsageSettings() { }); useEffect(() => { - void fetchSeat(); + void fetchSeat({ autoProvision: true }); void refetchUsage(); }, [fetchSeat, refetchUsage]); @@ -108,7 +111,27 @@ export function PlanUsageSettings() { - {error} + + + {error} + + Update your payment method in PostHog to continue. + + + + )} @@ -142,8 +165,21 @@ export function PlanUsageSettings() { )} + {hasBetterPlanElsewhere && seat?.organization_name && ( + + + + + + You have a Pro plan on{" "} + {seat.organization_name}. Usage on this + page reflects your current organization. + + + )} + - {seat ? ( + {orgSeat ? ( <> - Alpha plan + Extended Alpha Plan - You're on the free alpha Pro plan with full Pro features. You can - upgrade to the paid Pro plan anytime for higher usage limits. + You're on the free Pro plan with full Pro features until June 4, + 2026. @@ -285,7 +321,7 @@ export function PlanUsageSettings() { )} - {isPro && ( + {isOrgPro && ( Billing s.status); const version = useUpdateStore((s) => s.version); const isEnabled = useUpdateStore((s) => s.isEnabled); @@ -13,6 +17,49 @@ export function UpdateBanner() { isEnabled && (status === "downloading" || status === "ready" || status === "installing"); + if (variant === "compact") { + return ( + + {isVisible && ( + + {status === "downloading" && ( +
+ + Downloading update... +
+ )} + + {status === "ready" && ( + + )} + + {status === "installing" && ( +
+ + Restarting... +
+ )} +
+ )} +
+ ); + } + return ( {isVisible && ( diff --git a/apps/code/src/renderer/hooks/useSeat.ts b/apps/code/src/renderer/hooks/useSeat.ts index a3ff0883f..063df469e 100644 --- a/apps/code/src/renderer/hooks/useSeat.ts +++ b/apps/code/src/renderer/hooks/useSeat.ts @@ -3,29 +3,40 @@ import { isProPlan, seatHasAccess } from "@shared/types/seat"; export function useSeat() { const seat = useSeatStore((s) => s.seat); + const orgSeat = useSeatStore((s) => s.orgSeat); const isLoading = useSeatStore((s) => s.isLoading); const error = useSeatStore((s) => s.error); const redirectUrl = useSeatStore((s) => s.redirectUrl); const billingOrgId = useSeatStore((s) => s.billingOrgId); const isPro = isProPlan(seat?.plan_key); + const isOrgPro = isProPlan(orgSeat?.plan_key); const hasAccess = seat ? seatHasAccess(seat.status) : false; - const isCanceling = seat?.status === "canceling"; + const isCanceling = orgSeat?.status === "canceling"; const planLabel = isPro ? "Pro" : "Free"; - const activeUntil = seat?.active_until - ? new Date(seat.active_until * 1000) + const activeUntil = orgSeat?.active_until + ? new Date(orgSeat.active_until * 1000) : null; + const hasBetterPlanElsewhere = + seat !== null && + orgSeat !== null && + isProPlan(seat.plan_key) && + !isProPlan(orgSeat.plan_key); + return { seat, + orgSeat, isLoading, error, redirectUrl, billingOrgId, isPro, + isOrgPro, hasAccess, isCanceling, planLabel, activeUntil, + hasBetterPlanElsewhere, }; } diff --git a/apps/code/vite.main.config.mts b/apps/code/vite.main.config.mts index 402b6c341..596433f67 100644 --- a/apps/code/vite.main.config.mts +++ b/apps/code/vite.main.config.mts @@ -550,6 +550,9 @@ export default defineConfig(({ mode }) => { "process.env.VITE_POSTHOG_API_HOST": JSON.stringify( env.VITE_POSTHOG_API_HOST || "", ), + "process.env.VITE_POSTHOG_ACCESS_TOKEN_OVERRIDE": JSON.stringify( + env.VITE_POSTHOG_ACCESS_TOKEN_OVERRIDE || "", + ), "process.env.SKILLS_ZIP_URL": JSON.stringify(SKILLS_ZIP_URL), "process.env.CONTEXT_MILL_ZIP_URL": JSON.stringify(CONTEXT_MILL_ZIP_URL), ...createForceDevModeDefine(),