fix: surface CLI device code errors (#3166)

This commit is contained in:
Patrick Erichsen
2026-07-17 15:46:45 -07:00
committed by GitHub
parent 5c52b27bf7
commit efa3dc7af7
4 changed files with 63 additions and 13 deletions
+30
View File
@@ -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<string>).data).toBe(message);
});
it("denies the newest active pending row when duplicate user codes exist", async () => {
const now = Date.now();
const { ctx, patch } = makeCtx([
+11 -11
View File
@@ -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 });
}
+19
View File
@@ -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(<CliDeviceAuth />);
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();
});
});
+3 -2
View File
@@ -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);