Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
23 changes: 21 additions & 2 deletions src/app/settings/connections/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);

Expand All @@ -83,12 +88,18 @@ export default async function OAuthConnectionsPage({
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">CoinPay</h2>
{coinpayIdentity && (
{coinpayIdentity && !needsReconnect && (
<span className="inline-flex items-center gap-1 rounded-full border border-green-500/20 bg-green-500/10 px-2 py-0.5 text-xs font-medium text-green-700">
<CheckCircle2 className="h-3 w-3" />
Connected
</span>
)}
{needsReconnect && (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-700">
<AlertTriangle className="h-3 w-3" />
Reconnect required
</span>
)}
</div>
<p className="mt-2 text-sm text-muted-foreground">
Connect your CoinPay account so ugig can read your CoinPay global wallet addresses
Expand All @@ -112,6 +123,14 @@ export default async function OAuthConnectionsPage({
</div>
</div>

{needsReconnect && (
<div className="mt-5 rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-800">
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.
</div>
)}

{coinpayIdentity ? (
<dl className="mt-5 grid gap-3 text-sm sm:grid-cols-2">
<div className="rounded-md border border-border bg-background p-3">
Expand Down
52 changes: 52 additions & 0 deletions src/lib/coinpay-oauth.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
33 changes: 24 additions & 9 deletions src/lib/coinpay-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,25 @@ function metadataObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}

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;
Expand Down Expand Up @@ -80,14 +98,11 @@ export async function getConnectedCoinpayAccessToken(userId: string): Promise<st
const metadata = metadataObject(data?.metadata);
const accessToken =
typeof metadata.access_token === "string" ? metadata.access_token.trim() : "";
if (!accessToken) return null;

// Tokens issued before wallet:read was added to the OAuth scope can't read
// the user's global wallets via /api/oauth/userinfo. Treat them as
// disconnected so the UI prompts the user to reconnect CoinPay.
const scope = typeof metadata.scope === "string" ? metadata.scope : "";
const scopes = scope.split(/\s+/).filter(Boolean);
if (!scopes.includes(REQUIRED_COINPAY_SCOPE)) return null;

// Tokens issued without wallet:read can't read the user's global wallets via
// /api/oauth/userinfo. Treat them as disconnected so the UI prompts the user
// to reconnect CoinPay — the connections page uses the same predicate.
if (!coinpayLinkCanReadWallets(metadata)) return null;

// Proactively refresh if the token is expired or about to expire.
const expiresAt = typeof metadata.expires_at === "string" ? metadata.expires_at : null;
Expand Down
Loading