Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions plugins/codex/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,69 @@
}
}

function decodeBase64UrlUtf8(value) {
try {
let base64 = String(value).replace(/-/g, "+").replace(/_/g, "/")
while (base64.length % 4 !== 0) base64 += "="

let binary = null
if (typeof atob === "function") {
binary = atob(base64)
} else {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
binary = ""
for (let i = 0; i < base64.length;) {
const e1 = chars.indexOf(base64.charAt(i++))
const e2 = chars.indexOf(base64.charAt(i++))
const e3 = chars.indexOf(base64.charAt(i++))
const e4 = chars.indexOf(base64.charAt(i++))
if (e1 < 0 || e2 < 0) return null
binary += String.fromCharCode((e1 << 2) | (e2 >> 4))
if (e3 !== 64 && e3 >= 0) binary += String.fromCharCode(((e2 & 15) << 4) | (e3 >> 2))
if (e4 !== 64 && e4 >= 0) binary += String.fromCharCode(((e3 & 3) << 6) | e4)
}
}

const bytes = []
for (let i = 0; i < binary.length; i++) bytes.push(binary.charCodeAt(i))
if (typeof TextDecoder !== "undefined") {
return new TextDecoder("utf-8", { fatal: false }).decode(new Uint8Array(bytes))
}
return decodeURIComponent(binary.split("").map((c) => {
const h = c.charCodeAt(0).toString(16)
return "%" + (h.length === 1 ? "0" + h : h)
}).join(""))
} catch {}
return null
}

function readString(value) {
return typeof value === "string" && value.trim() ? value.trim() : null
}

function parseIdTokenClaims(ctx, idToken) {
if (!idToken) return null
if (typeof idToken === "object") return idToken
if (typeof idToken !== "string") return null
const parts = idToken.split(".")
if (parts.length < 2) return null
const payload = decodeBase64UrlUtf8(parts[1])
return payload ? ctx.util.tryParseJson(payload) : null
}

function getCodexAccountIdentity(ctx, auth) {
const tokens = auth && auth.tokens ? auth.tokens : null
if (!tokens) return null

const claims = parseIdTokenClaims(ctx, tokens.id_token)
const email = readString(claims?.email) ||
readString(claims?.profile?.email) ||
readString(claims?.["https://api.openai.com/profile.email"])
if (email) return email

return readString(tokens.account_id)
}

function tryParseAuthJson(ctx, text) {
if (!text) return null
const parsed = ctx.util.tryParseJson(text)
Expand Down Expand Up @@ -441,6 +504,7 @@
const nowMs = Date.now()
let accessToken = auth.tokens.access_token
const accountId = auth.tokens.account_id
const accountIdentity = getCodexAccountIdentity(ctx, auth)
Comment thread
mrevanzak marked this conversation as resolved.
Outdated

if (needsRefresh(ctx, auth, nowMs)) {
ctx.host.log.info("token needs refresh (age > " + (REFRESH_AGE_MS / 1000 / 60 / 60 / 24) + " days)")
Expand Down Expand Up @@ -679,6 +743,10 @@
lines.push(ctx.line.badge({ label: "Status", text: "No usage data", color: "#a3a3a3" }))
}

if (accountIdentity) {
lines.push(ctx.line.text({ label: "Account", value: accountIdentity }))
}

return { plan: plan, lines: lines }
}

Expand Down
1 change: 1 addition & 0 deletions plugins/codex/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
{ "type": "progress", "label": "Spark Weekly", "scope": "detail" },
{ "type": "progress", "label": "Reviews", "scope": "detail" },
{ "type": "progress", "label": "Credits", "scope": "detail" },
{ "type": "text", "label": "Account", "scope": "detail" },
Comment thread
mrevanzak marked this conversation as resolved.
Outdated
{ "type": "text", "label": "Today", "scope": "detail" },
{ "type": "text", "label": "Yesterday", "scope": "detail" },
{ "type": "text", "label": "Last 30 Days", "scope": "detail" }
Expand Down
13 changes: 12 additions & 1 deletion plugins/codex/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ const loadPlugin = async () => {
return globalThis.__openusage_plugin
}

const jwtWithPayload = (payload) => {
const encode = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64url")
return encode({ alg: "none" }) + "." + encode(payload) + "."
}

describe("codex plugin", () => {
beforeEach(() => {
delete globalThis.__openusage_plugin
Expand Down Expand Up @@ -189,7 +194,12 @@ describe("codex plugin", () => {
const ctx = makeCtx()
const authPath = "~/.codex/auth.json"
ctx.host.fs.writeText(authPath, JSON.stringify({
tokens: { access_token: "old", refresh_token: "refresh", account_id: "acc" },
tokens: {
access_token: "old",
refresh_token: "refresh",
account_id: "acc",
id_token: jwtWithPayload({ email: "dev@example.com" }),
},
last_refresh: "2000-01-01T00:00:00.000Z",
}))
ctx.host.http.request.mockImplementation((opts) => {
Expand Down Expand Up @@ -218,6 +228,7 @@ describe("codex plugin", () => {
expect(result.plan).toBe("Pro 20x")
expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
expect(result.lines.find((line) => line.label === "Weekly")).toBeTruthy()
expect(result.lines.find((line) => line.label === "Account")?.value).toBe("dev@example.com")
const credits = result.lines.find((line) => line.label === "Credits")
expect(credits).toBeTruthy()
expect(credits.used).toBe(900)
Expand Down
44 changes: 44 additions & 0 deletions src/components/provider-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,50 @@ describe("ProviderCard", () => {
expect(screen.getByText("342 credits")).toBeInTheDocument()
})

it("shows account identity beside the plan badge", () => {
const { container } = render(
<ProviderCard
name="Codex"
plan="Plus"
displayMode="used"
lines={[
{ type: "text", label: "Account", value: "dev@example.com" },
]}
/>
)

const header = container.querySelector("h2")?.parentElement?.parentElement
expect(header).toBeTruthy()
expect(within(header as HTMLElement).getByText("Plus")).toBeInTheDocument()
const account = within(header as HTMLElement).getByText("dev@example.com")
expect(account.closest("[title]")).toHaveAttribute("title", "dev@example.com")
expect(screen.getAllByText("dev@example.com")).toHaveLength(1)
})

it("shows account identity in header when body is filtered to overview", () => {
const { container } = render(
<ProviderCard
name="Codex"
plan="Plus"
displayMode="used"
scopeFilter="overview"
skeletonLines={[
{ type: "progress", label: "Session", scope: "overview" },
{ type: "text", label: "Account", scope: "detail" },
]}
lines={[
{ type: "progress", label: "Session", used: 25, limit: 100, format: { kind: "percent" } },
{ type: "text", label: "Account", value: "dev@example.com" },
]}
/>
)

const header = container.querySelector("h2")?.parentElement?.parentElement
expect(header).toBeTruthy()
expect(within(header as HTMLElement).getByText("dev@example.com")).toBeInTheDocument()
expect(screen.queryByText("Account")).toBeNull()
})

it("renders quick links and opens URL", async () => {
render(
<ProviderCard
Expand Down
45 changes: 35 additions & 10 deletions src/components/provider-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,20 @@ export function ProviderCard({
[links]
)

const accountIdentity = useMemo(() => {
const accountLine = lines.find(
(line) => line.type === "text" && line.label.toLowerCase() === "account"
)
Comment thread
mrevanzak marked this conversation as resolved.
return accountLine?.type === "text" ? accountLine.value.trim() : ""
}, [lines])
Comment thread
mrevanzak marked this conversation as resolved.
Outdated

const displayLines = useMemo(
() => filteredLines.filter(
(line) => !(line.type === "text" && line.label.toLowerCase() === "account")
),
[filteredLines]
)

Comment thread
mrevanzak marked this conversation as resolved.
// Format remaining cooldown time as "Xm Ys"
const formatRemainingTime = () => {
if (!lastManualRefreshAt) return ""
Expand Down Expand Up @@ -247,15 +261,26 @@ export function ProviderCard({
)
)}
</div>
{plan && (
<Badge
variant="outline"
className="truncate min-w-0 max-w-[40%]"
title={plan}
>
{plan}
</Badge>
)}
<div className="ml-2 flex min-w-0 max-w-[55%] items-center justify-end gap-1.5">
{accountIdentity && (
<Badge
variant="secondary"
className="truncate min-w-0"
title={accountIdentity}
>
{accountIdentity}
</Badge>
)}
{plan && (
<Badge
variant="outline"
className="shrink-0 truncate max-w-[45%]"
title={plan}
>
{plan}
</Badge>
)}
</div>
</div>
{visibleLinks.length > 0 && (
<div className="mb-2 -mt-0.5 flex flex-wrap gap-1.5">
Expand Down Expand Up @@ -302,7 +327,7 @@ export function ProviderCard({

{hasStaleData && (
<div className="space-y-4">
{groupLinesByType(filteredLines).map((group, gi) =>
{groupLinesByType(displayLines).map((group, gi) =>
group.kind === "text" ? (
<div key={gi} className="space-y-1">
{group.lines.map((line, li) => (
Expand Down
Loading