diff --git a/src/app/settings/connections/page.tsx b/src/app/settings/connections/page.tsx index 5f3edd91..2ee0a4fd 100644 --- a/src/app/settings/connections/page.tsx +++ b/src/app/settings/connections/page.tsx @@ -4,7 +4,8 @@ import { Header } from "@/components/layout/Header"; import { createClient } from "@/lib/supabase/server"; import { createServiceClient } from "@/lib/supabase/service"; import { buttonVariants } from "@/components/ui/button"; -import { CheckCircle2, ExternalLink, LinkIcon, RefreshCw } from "lucide-react"; +import { AlertTriangle, CheckCircle2, ExternalLink, LinkIcon, RefreshCw } from "lucide-react"; +import { coinpayLinkCanReadWallets } from "@/lib/coinpay-oauth"; import { DisconnectCoinpayButton } from "./DisconnectCoinpayButton"; export const metadata = { @@ -58,6 +59,10 @@ export default async function OAuthConnectionsPage({ const connectedAt = typeof metadata.connected_at === "string" ? metadata.connected_at : coinpayIdentity?.updated_at || null; const tokenExpiresAt = typeof metadata.expires_at === "string" ? metadata.expires_at : null; + // A link whose token lacks wallet:read exists but cannot do the one job this + // connection is for. Say so here rather than letting the invoice form be the + // first place the user finds out. + const needsReconnect = Boolean(coinpayIdentity) && !coinpayLinkCanReadWallets(metadata); const params = await searchParams; const message = statusMessage(params.coinpay, params.linked_to); @@ -83,12 +88,18 @@ export default async function OAuthConnectionsPage({

CoinPay

- {coinpayIdentity && ( + {coinpayIdentity && !needsReconnect && ( Connected )} + {needsReconnect && ( + + + Reconnect required + + )}

Connect your CoinPay account so ugig can read your CoinPay global wallet addresses @@ -112,6 +123,14 @@ export default async function OAuthConnectionsPage({

+ {needsReconnect && ( +
+ This CoinPay account is linked, but the link was granted without permission to + read your wallet addresses, so ugig cannot send invoices with it. Click + Reconnect above to re-authorize — nothing else on your account changes. +
+ )} + {coinpayIdentity ? (
diff --git a/src/lib/coinpay-oauth.test.ts b/src/lib/coinpay-oauth.test.ts new file mode 100644 index 00000000..6b7ab060 --- /dev/null +++ b/src/lib/coinpay-oauth.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { coinpayLinkCanReadWallets } from "./coinpay-oauth"; + +// The connections page and the invoice gate must agree on what a usable CoinPay +// link is. They did not: any oauth_identities row rendered as "Connected", while +// invoicing rejected a token without wallet:read. A worker saw a green +// "Connected" badge and "Connect your CoinPay account before sending an invoice" +// from the same account, and reconnecting could not fix it — the ugig.net OAuth +// client was registered without wallet:read, so CoinPay filtered the scope out +// of every grant. +describe("coinpayLinkCanReadWallets", () => { + it("accepts a token granted wallet:read", () => { + expect( + coinpayLinkCanReadWallets({ + access_token: "tok", + scope: "openid profile email wallet:read", + }) + ).toBe(true); + }); + + it("rejects a token granted without wallet:read", () => { + expect( + coinpayLinkCanReadWallets({ access_token: "tok", scope: "openid profile email" }) + ).toBe(false); + }); + + it("rejects a link with no recorded scope", () => { + expect(coinpayLinkCanReadWallets({ access_token: "tok", scope: null })).toBe(false); + }); + + it("rejects a link with no access token", () => { + expect(coinpayLinkCanReadWallets({ scope: "openid wallet:read" })).toBe(false); + }); + + it("rejects a blank access token", () => { + expect( + coinpayLinkCanReadWallets({ access_token: " ", scope: "openid wallet:read" }) + ).toBe(false); + }); + + it("does not match wallet:read as a substring of another scope", () => { + expect( + coinpayLinkCanReadWallets({ access_token: "tok", scope: "openid wallet:readwrite" }) + ).toBe(false); + }); + + it("treats a missing or non-object metadata as unusable", () => { + expect(coinpayLinkCanReadWallets(null)).toBe(false); + expect(coinpayLinkCanReadWallets(undefined)).toBe(false); + expect(coinpayLinkCanReadWallets("openid wallet:read")).toBe(false); + }); +}); diff --git a/src/lib/coinpay-oauth.ts b/src/lib/coinpay-oauth.ts index c68fde28..fa28af27 100644 --- a/src/lib/coinpay-oauth.ts +++ b/src/lib/coinpay-oauth.ts @@ -4,7 +4,25 @@ function metadataObject(value: unknown): Record { return value && typeof value === "object" ? (value as Record) : {}; } -const REQUIRED_COINPAY_SCOPE = "wallet:read"; +export const REQUIRED_COINPAY_SCOPE = "wallet:read"; + +/** + * Does this stored CoinPay link carry the scope we need to read wallets? + * + * The connection UI and the invoice gate have to answer this the same way. They + * did not: the UI called any `oauth_identities` row "Connected", while invoicing + * additionally required `wallet:read` and rejected the link without it. A user + * whose token lacked the scope saw a green "Connected" badge and + * "Connect your CoinPay account before sending an invoice" from the same + * account, with no way to reconcile the two. + */ +export function coinpayLinkCanReadWallets(metadata: unknown): boolean { + const meta = metadataObject(metadata); + const accessToken = typeof meta.access_token === "string" ? meta.access_token.trim() : ""; + if (!accessToken) return false; + const scope = typeof meta.scope === "string" ? meta.scope : ""; + return scope.split(/\s+/).filter(Boolean).includes(REQUIRED_COINPAY_SCOPE); +} const TOKEN_URL = "https://coinpayportal.com/api/oauth/token"; // Refresh if token expires within 5 minutes const EXPIRY_BUFFER_MS = 5 * 60 * 1000; @@ -80,14 +98,11 @@ export async function getConnectedCoinpayAccessToken(userId: string): Promise