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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,17 @@ jobs:
- name: Typecheck
run: cd ui && pnpm run typecheck

# Neither of these ran here before. `pnpm lint` had been failing in
# ui-components for long enough to accumulate 13 findings, and nothing
# reported it, because this job only built and typechecked. The tests are
# added for the same reason: a harness CI never runs is only marginally
# more visible than a lint that CI never runs.
- name: Lint
run: cd ui && pnpm run lint

- name: Test
run: cd ui && pnpm run test

- name: Build SDK TypeScript
run: |
cd sdk/typescript
Expand Down
8 changes: 6 additions & 2 deletions ui/packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist",
"lint": "eslint ."
"lint": "eslint .",
"test": "vitest run"
},
"dependencies": {
"@authsome/ui-core": "workspace:*",
Expand All @@ -56,12 +57,15 @@
"react-dom": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"jsdom": "^28.0.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwindcss": "^4.3.3",
"tsup": "^8.5.1",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^4.1.11"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { DeviceAuthorizationForm } from "./device-authorization-form";
import { routedFetch, withProvider } from "../test-support";

function at(search: string): void {
window.history.pushState({}, "", `/device${search}`);
}

function navigate(search: string): void {
act(() => {
window.history.pushState({}, "", `/device${search}`);
window.dispatchEvent(new PopStateEvent("popstate"));
});
}

function codeInput(container: HTMLElement): HTMLInputElement {
const input = container.querySelector("input");
if (!input) throw new Error("no code input rendered");
return input as HTMLInputElement;
}

describe("DeviceAuthorizationForm", () => {
it("takes the code from ?user_code, stripping the dashes", () => {
at("?user_code=ABCD-EFGH");
const { fetchFn } = routedFetch({});
const { container } = render(
withProvider(<DeviceAuthorizationForm autoSubmit={false} />, {
fetch: fetchFn,
}),
);
expect(codeInput(container).value).toBe("ABCDEFGH");
});

it("also accepts ?code", () => {
at("?code=WXYZ1234");
const { fetchFn } = routedFetch({});
const { container } = render(
withProvider(<DeviceAuthorizationForm autoSubmit={false} />, {
fetch: fetchFn,
}),
);
expect(codeInput(container).value).toBe("WXYZ1234");
});

it("follows popstate to a new user_code", () => {
at("?user_code=AAAA1111");
const { fetchFn } = routedFetch({});
const { container } = render(
withProvider(<DeviceAuthorizationForm autoSubmit={false} />, {
fetch: fetchFn,
}),
);
expect(codeInput(container).value).toBe("AAAA1111");

navigate("?user_code=BBBB2222");
expect(codeInput(container).value).toBe("BBBB2222");
});

it("adopts a new initialCode prop", () => {
at("");
const { fetchFn } = routedFetch({});
const { container, rerender } = render(
withProvider(
<DeviceAuthorizationForm autoSubmit={false} initialCode="aaaa1111" />,
{ fetch: fetchFn },
),
);
expect(codeInput(container).value).toBe("AAAA1111");

rerender(
withProvider(
<DeviceAuthorizationForm autoSubmit={false} initialCode="cccc3333" />,
{ fetch: fetchFn },
),
);
expect(codeInput(container).value).toBe("CCCC3333");
});

it("keeps what the user typed when the props have not changed", () => {
// Family C regression guard. Deriving the code from the prop on every
// render, rather than only when the prop changes, silently throws away
// typing the moment anything else re-renders the form.
at("?user_code=AAAA1111");
const { fetchFn } = routedFetch({});
const { container, rerender } = render(
withProvider(<DeviceAuthorizationForm autoSubmit={false} />, {
fetch: fetchFn,
}),
);
expect(codeInput(container).value).toBe("AAAA1111");

fireEvent.change(codeInput(container), { target: { value: "ZZZZ9999" } });
expect(codeInput(container).value).toBe("ZZZZ9999");

rerender(
withProvider(<DeviceAuthorizationForm autoSubmit={false} />, {
fetch: fetchFn,
}),
);
expect(codeInput(container).value).toBe("ZZZZ9999");
});

it("auto-submits a complete code from the URL exactly once", async () => {
at("?user_code=ABCD-EFGH");
const bodies: string[] = [];
const { fetchFn } = routedFetch({
"POST /v1/oauth/device/complete": () => {
bodies.push("called");
return { status: "approved" };
},
});

render(
withProvider(<DeviceAuthorizationForm />, { fetch: fetchFn }),
);

await waitFor(() =>
expect(screen.getByText("Device authorized successfully")).toBeTruthy(),
);
expect(bodies).toHaveLength(1);
});
});
57 changes: 19 additions & 38 deletions ui/packages/components/src/components/device-authorization-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { useAuth } from "@authsome/ui-react";
import { CheckCircle2 } from "lucide-react";
import { cn } from "../lib/utils";
import { useCodeFromURL } from "../lib/use-code-from-url";
import { Button } from "../primitives/button";
import {
InputOTP,
Expand Down Expand Up @@ -79,6 +80,7 @@ export function DeviceAuthorizationForm({
const initialCode = initialCodeProp ?? autoCode;

const [code, setCode] = useState(initialCode?.toUpperCase() ?? "");
const [appliedCode, setAppliedCode] = useState(initialCode?.toUpperCase());
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
Expand All @@ -102,7 +104,7 @@ export function DeviceAuthorizationForm({
// Use the AuthClient method — routes through the client's baseURL.
// Sends Bearer token when available; the method also sets
// credentials: "include" so cookies are sent for same-origin setups.
await (client as any).completeDeviceAuthorization(
await client.completeDeviceAuthorization(
clean,
"approve",
token ?? undefined,
Expand All @@ -126,13 +128,23 @@ export function DeviceAuthorizationForm({
[client, codeLength, isSubmitting, onError, onSubmitProp, onSuccess, token],
);

// Update code if initialCode changes.
useEffect(() => {
const newCode = (initialCodeProp ?? autoCode)?.toUpperCase();
if (newCode && newCode !== code && !isSubmitting) {
setCode(newCode);
// Adopt a new code from the props or the URL. This is React's documented
// "adjusting state when a prop changes" pattern rather than an effect: it
// compares against the last code it applied, so it fires once when that
// source changes and never on an unrelated re-render.
//
// The effect it replaces listed only [initialCodeProp, autoCode], which is
// what react-hooks/exhaustive-deps reported. Completing that dep list the
// way the rule asks is worse than leaving it: with `code` in the deps, every
// keystroke re-runs the effect, sees the typed value differ from the URL
// code, and overwrites what the user just typed. The test file pins that.
const desiredCode = (initialCodeProp ?? autoCode)?.toUpperCase();
if (desiredCode !== appliedCode) {
setAppliedCode(desiredCode);
if (desiredCode && !isSubmitting) {
setCode(desiredCode);
}
}, [initialCodeProp, autoCode]);
}

// Auto-submit when code is pre-filled from URL and is complete.
// Wait for auth to finish loading so the token is available.
Expand Down Expand Up @@ -259,34 +271,3 @@ export function DeviceAuthorizationForm({
</AuthCard>
);
}

/**
* Reads user_code or code from the current URL query params.
* Supports both raw codes (`ABCDEFGH`) and dash-formatted (`ABCD-EFGH`).
*/
function useCodeFromURL(): string | undefined {
const [code, setCode] = useState<string | undefined>(() => {
if (typeof window === "undefined") return undefined;
return parseCodeFromSearch(window.location.search);
});

useEffect(() => {
setCode(parseCodeFromSearch(window.location.search));

const handlePopState = () => {
setCode(parseCodeFromSearch(window.location.search));
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, []);

return code;
}

function parseCodeFromSearch(search: string): string | undefined {
const params = new URLSearchParams(search);
const raw = params.get("user_code") ?? params.get("code");
if (!raw) return undefined;
const cleaned = raw.replace(/[^A-Z0-9]/gi, "").toUpperCase();
return cleaned || undefined;
}
Loading
Loading