1 Commits

Author SHA1 Message Date
jules
ea34bcd886 fix(auth): reject expired JWT on session read (silent-401 shell)
readFromStorage validated token shape but never checked exp, so an expired
token mounted the full authed shell and every API call 401d silently. Decode
the JWT and treat an expired token as no session. Pattern backported from
skyai-finance. Frontend audit 2026-06-20, rank 1.

Also clears the localStorage Session in onUnauthorized (root.tsx) so a 401
fully logs out instead of leaving a dead session behind getToken.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:24:20 +10:00
4 changed files with 51 additions and 572 deletions

View File

@@ -1,145 +0,0 @@
// Tenant self-service billing helpers — wraps /api/v1/billing/* on arcadia-core.
//
// The provider is resolved server-side (mock in dev, a real PSP in prod), so
// these helpers are provider-agnostic: `createCheckoutSession` returns a hosted
// URL to redirect the browser to, whatever the backend provider is.
// Shapes mirror `ArcadiaWeb.API.BillingJSON`.
import type { ArcadiaClient } from "@crema/arcadia-client"
// ============================================================================
// Wire types
// ============================================================================
export interface BillingOverview {
billing_model: string | null
plan: string
subscription_status: string
current_period_start: string | null
current_period_end: string | null
trial_ends_at: string | null
billing_email: string | null
plan_limits: Record<string, unknown>
tenant_limits: Record<string, unknown>
}
export interface PlanPricing {
period: string
price_cents: number
currency: string
discount_label: string | null
}
export interface PlanMeter {
meter_key: string
included_units: number | null
overage_price_cents: number | null
}
export interface CatalogPlan {
slug: string
name: string
description: string | null
billing_track: string
trial_days: number
metadata: Record<string, unknown>
pricing: PlanPricing[]
meters: PlanMeter[]
}
export interface Invoice {
id: string | null
amount: number | null
currency: string | null
status: string | null
due_date: string | null
paid_at: string | null
invoice_url: string | null
pdf_url: string | null
}
export interface CheckoutSession {
url: string
}
export interface PortalSession {
url: string
}
// ============================================================================
// Reads
// ============================================================================
export async function getBillingOverview(
arcadia: ArcadiaClient,
): Promise<BillingOverview> {
const res = await arcadia.GET<{ data: BillingOverview }>(
"/api/v1/billing/overview",
)
return res.data
}
export async function listBillingPlans(
arcadia: ArcadiaClient,
): Promise<CatalogPlan[]> {
const res = await arcadia.GET<{ data: CatalogPlan[] }>(
"/api/v1/billing/plans",
)
return res.data
}
export async function listInvoices(
arcadia: ArcadiaClient,
opts: { limit?: number } = {},
): Promise<Invoice[]> {
const qs = opts.limit ? `?limit=${opts.limit}` : ""
const res = await arcadia.GET<{ data: Invoice[] }>(
`/api/v1/billing/invoices${qs}`,
)
return res.data
}
// ============================================================================
// Actions
// ============================================================================
/**
* Create a hosted checkout session for `plan` and return the URL to redirect
* the browser to. `successUrl`/`cancelUrl` are where the provider returns the
* user (default: back to this page).
*/
export async function createCheckoutSession(
arcadia: ArcadiaClient,
input: { plan: string; successUrl?: string; cancelUrl?: string },
): Promise<CheckoutSession> {
const res = await arcadia.POST<{ data: CheckoutSession }>(
"/api/v1/billing/checkout",
{
body: {
plan: input.plan,
success_url: input.successUrl,
cancel_url: input.cancelUrl,
},
},
)
return res.data
}
/** Create a billing-portal session for self-service payment management. */
export async function createPortalSession(
arcadia: ArcadiaClient,
input: { returnUrl?: string } = {},
): Promise<PortalSession> {
const res = await arcadia.POST<{ data: PortalSession }>(
"/api/v1/billing/portal",
{ body: { return_url: input.returnUrl } },
)
return res.data
}
/** Cancel the active subscription (at period end). */
export async function cancelSubscription(
arcadia: ArcadiaClient,
): Promise<void> {
await arcadia.POST("/api/v1/billing/cancel", { body: {} })
}

View File

@@ -44,6 +44,10 @@ function readFromStorage(): Session | null {
typeof parsed.token !== "string"
)
return null
// An expired JWT is not a session: without this the shell renders as
// "logged in" and every API call 401s silently. Treat it as null so the
// app bounces to /login. (Frontend audit 2026-06-20.)
if (isTokenExpired(parsed.token)) return null
return {
userId: parsed.userId,
name:
@@ -76,6 +80,35 @@ export function loadSession(): Session | null {
return readFromStorage()
}
// A token counts as expired only if it's a JWT carrying an `exp` in the past
// (minus a small clock-skew grace). Non-JWT dev/mock tokens (no decodable
// `exp`) are treated as non-expiring so offline/test flows keep working.
const TOKEN_EXPIRY_SKEW_S = 30
export function isTokenExpired(token: string | undefined | null): boolean {
if (!token) return true
const claims = decodeJwtPayload(token)
const exp =
claims && typeof claims.exp === "number" ? (claims.exp as number) : null
if (exp === null) return false
return Date.now() / 1000 >= exp - TOKEN_EXPIRY_SKEW_S
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split(".")
if (parts.length !== 3) return null
try {
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/")
const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4))
const json =
typeof atob === "function"
? atob(b64 + pad)
: Buffer.from(b64 + pad, "base64").toString("utf-8")
return JSON.parse(json) as Record<string, unknown>
} catch {
return null
}
}
export function signOut() {
if (typeof window === "undefined") return
localStorage.removeItem(STORAGE_KEY)

View File

@@ -15,6 +15,7 @@ import { CommandBusProvider } from "@crema/action-bus"
import { ArcadiaProvider } from "@crema/arcadia-client"
import { LlmConfigBootstrap } from "~/lib/llm-config-bootstrap"
import { ProfileBootstrap } from "~/lib/profile-bootstrap"
import { signOut } from "~/lib/session"
// CREMA:PROVIDERS-IMPORTS
const ARCADIA_URL = import.meta.env.VITE_ARCADIA_URL ?? "http://localhost:4000"
@@ -59,6 +60,10 @@ export default function App() {
if (typeof window !== "undefined") {
sessionStorage.removeItem("arcadia_access_token")
sessionStorage.removeItem("arcadia_refresh_token")
// Also clear the localStorage Session (crema.session); otherwise
// useSession() still reports "logged in" after a 401 and the shell
// keeps mounting with a dead token. (Frontend audit 2026-06-20.)
signOut()
}
}}
>

View File

@@ -1,210 +1,18 @@
// Tenant subscription + billing — self-service surface.
//
// Wired to /api/v1/billing/* on arcadia-core. The payment provider is resolved
// server-side (mock in dev), so "Subscribe" creates a hosted checkout session
// and redirects the browser to the returned URL; on return the overview
// reflects the new plan.
// Tenant subscription + billing — placeholder. Real surface lists the
// active plan, renewal date, invoices, and payment method for the
// active tenant. Data source not wired yet.
import { useCallback, useEffect, useState } from "react"
import {
AlertCircle,
Check,
CreditCard,
ExternalLink,
Loader2,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import { CreditCard } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell"
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Separator } from "~/components/ui/separator"
import { Skeleton } from "~/components/ui/skeleton"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table"
import {
type BillingOverview,
type CatalogPlan,
type Invoice,
type PlanPricing,
cancelSubscription,
createCheckoutSession,
createPortalSession,
getBillingOverview,
listBillingPlans,
listInvoices,
} from "~/lib/arcadia/billing"
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
function monthlyPricing(plan: CatalogPlan): PlanPricing | null {
if (!plan.pricing.length) return null
return plan.pricing.find((p) => p.period === "monthly") ?? plan.pricing[0]
}
function formatMoney(cents: number | null, currency: string | null): string {
if (cents == null) return "—"
const cur = currency || "AUD"
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: cur,
}).format(cents / 100)
} catch {
return `${(cents / 100).toFixed(2)} ${cur}`
}
}
function formatDate(iso: string | null): string {
if (!iso) return "—"
const d = new Date(iso)
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})
}
function statusVariant(
status: string,
): "default" | "secondary" | "destructive" | "outline" {
switch (status) {
case "active":
return "default"
case "trialing":
return "secondary"
case "past_due":
case "unpaid":
case "canceled":
case "cancelled":
return "destructive"
default:
return "outline"
}
}
// ---------------------------------------------------------------------------
// Route
// ---------------------------------------------------------------------------
export default function PlanRoute() {
const arcadia = useArcadiaClient()
const [overview, setOverview] = useState<BillingOverview | null>(null)
const [plans, setPlans] = useState<CatalogPlan[]>([])
const [invoices, setInvoices] = useState<Invoice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// slug currently being acted on (checkout), or "__portal"/"__cancel"
const [busy, setBusy] = useState<string | null>(null)
const load = useCallback(async () => {
setError(null)
try {
const [ov, pls, invs] = await Promise.all([
getBillingOverview(arcadia),
listBillingPlans(arcadia).catch(() => [] as CatalogPlan[]),
listInvoices(arcadia, { limit: 12 }).catch(() => [] as Invoice[]),
])
setOverview(ov)
setPlans(pls)
setInvoices(invs)
} catch (e) {
setError(
e instanceof ArcadiaError || e instanceof Error
? e.message
: "Failed to load billing details.",
)
} finally {
setLoading(false)
}
}, [arcadia])
useEffect(() => {
void load()
}, [load])
const returnUrl =
typeof window !== "undefined" ? window.location.href : undefined
async function handleSubscribe(plan: CatalogPlan) {
setBusy(plan.slug)
setError(null)
try {
const { url } = await createCheckoutSession(arcadia, {
plan: plan.slug,
successUrl: returnUrl,
cancelUrl: returnUrl,
})
window.location.href = url
} catch (e) {
setError(e instanceof Error ? e.message : "Could not start checkout.")
setBusy(null)
}
}
async function handlePortal() {
setBusy("__portal")
setError(null)
try {
const { url } = await createPortalSession(arcadia, { returnUrl })
window.location.href = url
} catch (e) {
setError(
e instanceof Error ? e.message : "Could not open the billing portal.",
)
setBusy(null)
}
}
async function handleCancel() {
if (
typeof window !== "undefined" &&
!window.confirm(
"Cancel your subscription at the end of the current period?",
)
) {
return
}
setBusy("__cancel")
setError(null)
try {
await cancelSubscription(arcadia)
await load()
} catch (e) {
setError(
e instanceof Error ? e.message : "Could not cancel the subscription.",
)
} finally {
setBusy(null)
}
}
const currentSlug = overview?.plan
const hasActiveSub =
overview != null &&
overview.plan !== "free" &&
["active", "trialing", "past_due"].includes(overview.subscription_status)
return (
<AppShell>
<div className="flex items-center gap-3">
@@ -218,237 +26,15 @@ export default function PlanRoute() {
</p>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Something went wrong</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
</div>
</div>
) : (
<>
{/* Current plan */}
{overview && (
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<CardTitle className="capitalize">
{overview.plan} plan
</CardTitle>
<Badge variant={statusVariant(overview.subscription_status)}>
{overview.subscription_status}
</Badge>
</div>
<div className="flex gap-2">
{hasActiveSub && (
<Button
variant="outline"
size="sm"
onClick={handlePortal}
disabled={busy != null}
>
{busy === "__portal" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ExternalLink className="size-4" />
)}
Manage billing
</Button>
)}
{hasActiveSub && (
<Button
variant="ghost"
size="sm"
onClick={handleCancel}
disabled={busy != null}
>
{busy === "__cancel" ? (
<Loader2 className="size-4 animate-spin" />
) : null}
Cancel
</Button>
)}
</div>
</div>
<CardTitle>Coming soon</CardTitle>
<CardDescription>
{overview.current_period_end
? `Renews ${formatDate(overview.current_period_end)}`
: "No active billing period."}
{overview.trial_ends_at
? ` · Trial ends ${formatDate(overview.trial_ends_at)}`
: ""}
Billing is not yet wired to a payment provider on this deployment.
</CardDescription>
</CardHeader>
{overview.billing_email && (
<CardContent className="text-sm text-muted-foreground">
Billing email: {overview.billing_email}
</CardContent>
)}
<CardContent />
</Card>
)}
{/* Plan picker */}
{plans.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-medium">Available plans</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{plans.map((plan) => {
const price = monthlyPricing(plan)
const isCurrent = plan.slug === currentSlug
const isAud = (price?.currency || "AUD") === "AUD"
return (
<Card
key={plan.slug}
className={isCurrent ? "border-primary" : undefined}
>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle>{plan.name}</CardTitle>
{isCurrent && (
<Badge variant="secondary">
<Check className="size-3" />
Current
</Badge>
)}
</div>
<CardDescription>{plan.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-baseline gap-1">
<span className="text-2xl font-semibold">
{price
? formatMoney(price.price_cents, price.currency)
: "—"}
</span>
{price && (
<span className="text-sm text-muted-foreground">
/{price.period}
{isAud ? " incl. GST" : ""}
</span>
)}
</div>
{price?.discount_label && (
<Badge variant="outline">{price.discount_label}</Badge>
)}
{plan.trial_days > 0 && (
<p className="text-sm text-muted-foreground">
{plan.trial_days}-day free trial
</p>
)}
{plan.meters.length > 0 && (
<>
<Separator />
<ul className="space-y-1 text-sm text-muted-foreground">
{plan.meters.slice(0, 5).map((m) => (
<li
key={m.meter_key}
className="flex items-center gap-2"
>
<Check className="size-3 text-primary" />
{m.included_units != null
? `${m.included_units.toLocaleString()} `
: ""}
{m.meter_key.replace(/_/g, " ")}
</li>
))}
</ul>
</>
)}
</CardContent>
<CardFooter>
<Button
className="w-full"
variant={isCurrent ? "outline" : "default"}
disabled={isCurrent || busy != null}
onClick={() => handleSubscribe(plan)}
>
{busy === plan.slug ? (
<Loader2 className="size-4 animate-spin" />
) : null}
{isCurrent
? "Current plan"
: hasActiveSub
? `Switch to ${plan.name}`
: `Subscribe to ${plan.name}`}
</Button>
</CardFooter>
</Card>
)
})}
</div>
</div>
)}
{/* Invoices */}
<Card>
<CardHeader>
<CardTitle>Invoices</CardTitle>
<CardDescription>
Recent billing history for this tenant.
</CardDescription>
</CardHeader>
<CardContent>
{invoices.length === 0 ? (
<p className="text-sm text-muted-foreground">No invoices yet.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead className="text-right">Invoice</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((inv, i) => (
<TableRow key={inv.id ?? i}>
<TableCell>
{formatDate(inv.paid_at ?? inv.due_date)}
</TableCell>
<TableCell>
<Badge variant={statusVariant(inv.status ?? "")}>
{inv.status ?? "—"}
</Badge>
</TableCell>
<TableCell className="text-right">
{formatMoney(inv.amount, inv.currency)}
</TableCell>
<TableCell className="text-right">
{inv.invoice_url || inv.pdf_url ? (
<a
href={(inv.invoice_url ?? inv.pdf_url) as string}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
View <ExternalLink className="size-3" />
</a>
) : (
"—"
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</>
)}
</AppShell>
)
}