Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@types/node": "^22.13.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"happy-dom": "^20.11.2",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ export function GitHubConnection() {
return () => window.removeEventListener(GITHUB_SOURCES_CHANGED_EVENT, refreshForSourceChange);
}, [loadStatus]);

// The provider finishes device/token sign-in asynchronously. This card owns
// a separate status snapshot, so refresh it when the pending action finishes.
const previousActionRef = useRef(cliAction);
useEffect(() => {
if (previousActionRef.current && !cliAction) void loadStatus(true);
previousActionRef.current = cliAction;
}, [cliAction, loadStatus]);

// Connect/install opens a separate window (OAuth popup or the GitHub App
// install tab). The connect call returns as soon as that window opens, so
// the immediate loadStatus below is stale. Arm this flag on click and
Expand Down Expand Up @@ -285,19 +293,19 @@ export function GitHubConnection() {
iconBg="bg-foreground/5"
iconColor="text-foreground"
>
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<div className="size-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
{t.settings.github.checkingConnection}
</div>
) : !anyConnected && cliAction ? (
{cliAction ? (
/* A login is in flight. It's the only actionable thing on the card, so it
replaces the chooser entirely instead of appearing underneath it. */
<DeviceFlowPanel
cliAction={cliAction}
onRefresh={() => void loadStatus(true)}
isDesktop={isDesktop}
/>
) : loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<div className="size-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
{t.settings.github.checkingConnection}
</div>
) : anyConnected ? (
<div className="space-y-4">
{/* The identity that is actually authorizing clones, first. */}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// @vitest-environment happy-dom

import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GitHubProvider, type GitHubConnectionState } from "@/context/GitHubContext";
import { GitHubConnection } from "./GitHubConnection";

const api = vi.hoisted(() => ({
connect: vi.fn(),
pollConnect: vi.fn(),
getUserHome: vi.fn(),
getStatusDeduped: vi.fn(),
invalidateStatus: vi.fn(),
showToast: vi.fn(),
}));

vi.mock("@/lib/api", () => ({
githubApi: api,
settingsApi: { get: async () => ({}) },
getApiErrorMessage: (error: Error) => error.message,
GITHUB_SOURCES_CHANGED_EVENT: "github-sources-changed",
}));
vi.mock("@/context/ToastContext", () => ({ useToast: () => ({ showToast: api.showToast }) }));
vi.mock("@/context/CloudContext", () => ({ useCloud: () => ({ connected: false }) }));
vi.mock("@/context/ModalContext", () => ({ useModal: () => ({}) }));
vi.mock("@/context/PlatformContext", () => ({
usePlatform: () => ({ selfHosted: true, deployMode: "docker" }),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));

const disconnected: GitHubConnectionState = {
primary: null,
sources: {
openshipApp: { connected: false },
ghCli: { available: false, problem: "rejected", method: "device" },
},
};
const connected: GitHubConnectionState = {
primary: "gh-cli",
sources: {
openshipApp: { connected: false },
ghCli: { available: true, login: "new-account", method: "device" },
},
};
const deviceResponse = {
connected: false,
flow: "device_code",
userCode: "ABCD-1234",
verificationUri: "https://github.com/login/device",
expiresIn: 899,
interval: 5,
};

let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
api.connect.mockResolvedValue(deviceResponse);
api.pollConnect.mockResolvedValue({ status: "pending" });
api.getStatusDeduped.mockResolvedValue({ state: disconnected });
api.getUserHome.mockResolvedValue({ state: disconnected });
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});

afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.useRealTimers();
vi.unstubAllGlobals();
});

async function render(initialState = disconnected) {
await act(async () => {
root.render(
<GitHubProvider initialData={{ state: initialState }}>
<GitHubConnection />
</GitHubProvider>,
);
});
}

async function click(label: string) {
const button = [...container.querySelectorAll("button")].find((el) =>
el.textContent?.includes(label),
);
expect(button, `button: ${label}`).toBeDefined();
await act(async () => button!.click());
}

function expectDeviceInstructions() {
expect(container.textContent).toContain(deviceResponse.userCode);
expect(container.querySelector('a[href="https://github.com/login/device"]')).not.toBeNull();
}

describe("Settings GitHub device sign-in (#851)", () => {
it("shows the returned code, copies it, and updates the card after polling completes", async () => {
const copy = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue();
await render();
await click("Sign in with GitHub");
expectDeviceInstructions();
await click(deviceResponse.userCode);
expect(copy).toHaveBeenCalledWith(deviceResponse.userCode);

api.pollConnect.mockResolvedValue({ status: "complete" });
api.getStatusDeduped.mockResolvedValue({ state: connected });
api.getUserHome.mockResolvedValue({ state: connected });
await act(async () => vi.advanceTimersByTimeAsync(5000));

expect(container.textContent).not.toContain(deviceResponse.userCode);
expect(container.textContent).toContain("@new-account");
expect(container.textContent).not.toContain("Sign in with GitHub");
api.pollConnect.mockClear();
await act(async () => vi.advanceTimersByTimeAsync(15000));
expect(api.pollConnect).not.toHaveBeenCalled();
copy.mockRestore();
});

it("keeps device instructions when switching from an already connected App", async () => {
const appState: GitHubConnectionState = {
...disconnected,
primary: "openship-app",
sources: { ...disconnected.sources, openshipApp: { connected: true, login: "app-user" } },
};
api.getStatusDeduped.mockResolvedValue({ state: appState });
api.getUserHome.mockResolvedValue({ state: appState });
await render(appState);
await click("Change method");
await click("Sign in with GitHub");
expectDeviceInstructions();
await act(async () => vi.advanceTimersByTimeAsync(5000));
expectDeviceInstructions();
});

it("does not let stale provider connectivity dismiss a new device grant", async () => {
// The library provider can still have its initial verified identity while
// the Settings card's fresh probe reports that credential as rejected.
await render(connected);
await click("Sign in with GitHub");
expectDeviceInstructions();
await act(async () => vi.advanceTimersByTimeAsync(5000));
expectDeviceInstructions();
});

it("keeps the code visible while the card refreshes its status", async () => {
await render();
let resolveStatus!: (value: { state: GitHubConnectionState }) => void;
api.getStatusDeduped.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveStatus = resolve;
}),
);
await click("Sign in with GitHub");
expectDeviceInstructions();
await act(async () => resolveStatus({ state: disconnected }));
expectDeviceInstructions();
});

it("returns to sign-in and surfaces the error when the device grant expires", async () => {
await render();
await click("Sign in with GitHub");
api.pollConnect.mockResolvedValue({ status: "error", message: "The device code expired" });
await act(async () => vi.advanceTimersByTimeAsync(5000));
expect(container.textContent).not.toContain(deviceResponse.userCode);
expect(container.textContent).toContain("Sign in with GitHub");
expect(api.showToast).toHaveBeenCalledWith("The device code expired", "error", "GitHub");
});
});
11 changes: 5 additions & 6 deletions apps/dashboard/src/context/GitHubContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,6 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
if (res?.capabilities) setCapabilities(res.capabilities as GitHubCapabilities);

if (nextState.primary !== null) {
setCliAction(null);
setAccounts(res.accounts ?? []);
const primaryLogin =
nextState.sources.openshipApp.login ?? nextState.sources.ghCli.login ?? "";
Expand Down Expand Up @@ -494,12 +493,12 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
}, [cliAction, refresh, showToast]);

/* ── Auto-detect a completed login ──────────────────────────── */
// Any pending CLI action (the device flow OR a `gh auth login` the operator ran
// on the instance) clears the moment the connection lands, so the UI never gets
// stuck showing a code/command after success.
// Only a terminal login completes through a status probe. A device grant has
// its own authoritative poll above; a previously connected App or stale CLI
// identity must not dismiss the new code before the operator authorizes it.
useEffect(() => {
if (connected && cliAction) setCliAction(null);
}, [connected, cliAction]);
if (cliAction?.type === "terminal" && state.sources.ghCli.available) setCliAction(null);
}, [state.sources.ghCli.available, cliAction]);

// Terminal (`gh auth login`) has no device code to poll — refresh the status
// periodically so the UI flips to connected as soon as the operator finishes,
Expand Down
Loading