From efa3dc7af7673d09d44625c28bbe106978110fbf Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Fri, 17 Jul 2026 15:46:45 -0700 Subject: [PATCH] fix: surface CLI device code errors (#3166) --- convex/cliDeviceAuth.test.ts | 30 ++++++++++++++++++++++++++++++ convex/cliDeviceAuth.ts | 22 +++++++++++----------- src/routes/cli/-device.test.tsx | 19 +++++++++++++++++++ src/routes/cli/device.tsx | 5 +++-- 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/convex/cliDeviceAuth.test.ts b/convex/cliDeviceAuth.test.ts index 1af04e34..7c2af3ed 100644 --- a/convex/cliDeviceAuth.test.ts +++ b/convex/cliDeviceAuth.test.ts @@ -1,3 +1,4 @@ +import { ConvexError } from "convex/values"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("./lib/access", () => ({ @@ -208,6 +209,35 @@ describe("cliDeviceAuth approval", () => { expect(patch).not.toHaveBeenCalledWith("cliDeviceCodes:consumed", expect.anything()); }); + it.each([ + ["expired", "Device code expired"], + ["consumed", "Device code already used"], + ["approved", "Device code already authorized"], + ["denied", "Device code was denied"], + ])("surfaces %s codes as user-facing errors", async (status, message) => { + const now = Date.now(); + const { ctx } = makeCtx([ + { + _id: `cliDeviceCodes:${status}`, + _creationTime: now - 1_000, + status, + userCode: "Q639-NBSX", + createdAt: now - 1_000, + expiresAt: now + 60_000, + }, + ]); + + let caught: unknown; + try { + await approveHandler(ctx, { userCode: "Q639-NBSX" }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(ConvexError); + expect((caught as ConvexError).data).toBe(message); + }); + it("denies the newest active pending row when duplicate user codes exist", async () => { const now = Date.now(); const { ctx, patch } = makeCtx([ diff --git a/convex/cliDeviceAuth.ts b/convex/cliDeviceAuth.ts index dc109857..bb780924 100644 --- a/convex/cliDeviceAuth.ts +++ b/convex/cliDeviceAuth.ts @@ -1,4 +1,4 @@ -import { v } from "convex/values"; +import { ConvexError, v } from "convex/values"; import type { Doc } from "./_generated/dataModel"; import type { MutationCtx } from "./_generated/server"; import { internalMutation, mutation } from "./functions"; @@ -88,19 +88,19 @@ export const approve = mutation({ handler: async (ctx, args) => { const { userId } = await requireUser(ctx); const normalized = normalizeUserCode(args.userCode); - if (!normalized) throw new Error("Code required"); + if (!normalized) throw new ConvexError("Code required"); const userCodeHash = await hashToken(normalized); const now = Date.now(); const rows = await expireStaleRows(ctx, await getRowsByUserCodeHash(ctx, userCodeHash), now); const row = pickLatestRow(rows, now, "pending") ?? pickLatestRow(rows, now) ?? pickLatestRow(rows); - if (!row) throw new Error("Device code not found"); - if (row.expiresAt <= now) throw new Error("Device code expired"); - if (row.status === "expired") throw new Error("Device code expired"); - if (row.status === "consumed") throw new Error("Device code already used"); - if (row.status === "approved") throw new Error("Device code already authorized"); - if (row.status === "denied") throw new Error("Device code was denied"); + if (!row) throw new ConvexError("Device code not found"); + if (row.expiresAt <= now) throw new ConvexError("Device code expired"); + if (row.status === "expired") throw new ConvexError("Device code expired"); + if (row.status === "consumed") throw new ConvexError("Device code already used"); + if (row.status === "approved") throw new ConvexError("Device code already authorized"); + if (row.status === "denied") throw new ConvexError("Device code was denied"); await ctx.db.patch(row._id, { status: "approved", @@ -116,14 +116,14 @@ export const deny = mutation({ handler: async (ctx, args) => { await requireUser(ctx); const normalized = normalizeUserCode(args.userCode); - if (!normalized) throw new Error("Code required"); + if (!normalized) throw new ConvexError("Code required"); const userCodeHash = await hashToken(normalized); const now = Date.now(); const rows = await expireStaleRows(ctx, await getRowsByUserCodeHash(ctx, userCodeHash), now); const row = pickLatestRow(rows, now, "pending") ?? pickLatestRow(rows, now) ?? pickLatestRow(rows); - if (!row) throw new Error("Device code not found"); - if (row.status === "approved") throw new Error("Device code already authorized"); + if (!row) throw new ConvexError("Device code not found"); + if (row.status === "approved") throw new ConvexError("Device code already authorized"); if (row.status === "pending") { await ctx.db.patch(row._id, { status: "denied", deniedAt: now }); } diff --git a/src/routes/cli/-device.test.tsx b/src/routes/cli/-device.test.tsx index b2416740..a58a2ede 100644 --- a/src/routes/cli/-device.test.tsx +++ b/src/routes/cli/-device.test.tsx @@ -148,4 +148,23 @@ describe("CliDeviceAuth", () => { expect(authorize).toHaveProperty("disabled", true); expect(deny).toHaveProperty("disabled", true); }); + + it("shows a clean message when a device code was already used", async () => { + approveMock.mockRejectedValue( + new Error( + "[CONVEX M(cliDeviceAuth:approve)] [Request ID: test] Server Error Called by client ConvexError: Device code already used", + ), + ); + denyMock.mockResolvedValue(undefined); + useMutationMock.mockImplementation((mutation: string) => + mutation === "approve" ? approveMock : denyMock, + ); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Authorize" })); + + expect(await screen.findByText("Device code already used")).toBeTruthy(); + expect(screen.queryByText(/Server Error Called by client/)).toBeNull(); + }); }); diff --git a/src/routes/cli/device.tsx b/src/routes/cli/device.tsx index ad388173..f63e2605 100644 --- a/src/routes/cli/device.tsx +++ b/src/routes/cli/device.tsx @@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/ca import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; import { isCliDeviceUserCode } from "../../lib/cliDeviceCode"; +import { getUserFacingConvexError } from "../../lib/convexError"; import { useAuthStatus } from "../../lib/useAuthStatus"; export const Route = createFileRoute("/cli/device")({ @@ -45,7 +46,7 @@ export function CliDeviceAuth() { setIsComplete(true); setStatus("Authorized. You can return to your terminal."); } catch (error) { - setStatus(error instanceof Error ? error.message : "Authorization failed."); + setStatus(getUserFacingConvexError(error, "Authorization failed.")); } finally { actionInFlight.current = false; setPendingAction(null); @@ -64,7 +65,7 @@ export function CliDeviceAuth() { setIsComplete(true); setStatus("Denied. You can close this page."); } catch (error) { - setStatus(error instanceof Error ? error.message : "Deny failed."); + setStatus(getUserFacingConvexError(error, "Deny failed.")); } finally { actionInFlight.current = false; setPendingAction(null);