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
54 changes: 53 additions & 1 deletion src/renderer/routes/github/LoginWithDeviceFlow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => {
]);

await screen.findByTestId('device-user-code');
expect(screen.getByTestId('device-verification-link')).toBeInTheDocument();
expect(screen.getByTestId('open-browser-button')).toBeInTheDocument();

// Verify auto-copy and auto-open were called
expect(copyToClipboardSpy).toHaveBeenCalledWith('USER-1234');
Expand Down Expand Up @@ -94,6 +94,58 @@ describe('renderer/routes/github/LoginWithDeviceFlow.tsx', () => {
await screen.findByTestId('device-user-code');
});

it('should show status indicators after device flow starts', async () => {
const loginWithDeviceFlowStartMock = vi.fn().mockResolvedValueOnce({
hostname: 'github.com',
clientId: 'test-id',
deviceCode: 'device-code',
userCode: 'USER-1234',
verificationUri: 'https://github.com/login/device',
intervalSeconds: 5,
expiresAt: Date.now() + 900000,
});

renderWithProviders(<GitHubLoginWithDeviceFlowRoute />, {
loginWithDeviceFlowStart: loginWithDeviceFlowStartMock,
});

await userEvent.click(screen.getByTestId('device-scope-public'));
await screen.findByTestId('device-user-code');

expect(screen.getByText('Code copied to clipboard')).toBeInTheDocument();
expect(screen.getByText('Opened GitHub.com')).toBeInTheDocument();
expect(screen.getByText('Waiting for authorization...')).toBeInTheDocument();
});

it('should open browser and copy link via footer buttons', async () => {
const loginWithDeviceFlowStartMock = vi.fn().mockResolvedValueOnce({
hostname: 'github.com',
clientId: 'test-id',
deviceCode: 'device-code',
userCode: 'USER-1234',
verificationUri: 'https://github.com/login/device',
intervalSeconds: 5,
expiresAt: Date.now() + 900000,
});

renderWithProviders(<GitHubLoginWithDeviceFlowRoute />, {
loginWithDeviceFlowStart: loginWithDeviceFlowStartMock,
});

await userEvent.click(screen.getByTestId('device-scope-public'));
await screen.findByTestId('device-user-code');

// Reset spies to isolate button interactions
openExternalLinkSpy.mockClear();
copyToClipboardSpy.mockClear();

await userEvent.click(screen.getByTestId('open-browser-button'));
expect(openExternalLinkSpy).toHaveBeenCalledWith('https://github.com/login/device');

await userEvent.click(screen.getByTestId('copy-link-button'));
expect(copyToClipboardSpy).toHaveBeenCalledWith('https://github.com/login/device');
});

it('should copy user code to clipboard when clicking copy button', async () => {
const loginWithDeviceFlowStartMock = vi.fn().mockResolvedValueOnce({
hostname: 'github.com',
Expand Down
108 changes: 75 additions & 33 deletions src/renderer/routes/github/LoginWithDeviceFlow.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { type FC, type ReactNode, useCallback, useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

import { CopyIcon, SignInIcon, SyncIcon } from '@primer/octicons-react';
import { Banner, Button, IconButton, Link as PrimerLink, Stack, Text } from '@primer/react';
import {
CheckCircleFillIcon,
CopyIcon,
GlobeIcon,
LinkIcon,
SignInIcon,
SyncIcon,
} from '@primer/octicons-react';
import { Banner, Button, Heading, IconButton, Stack, Text } from '@primer/react';

import { Constants } from '../../constants';

Expand Down Expand Up @@ -128,6 +135,18 @@ export const GitHubLoginWithDeviceFlowRoute: FC = () => {
}
}, [session?.userCode]);

const handleOpenBrowser = useCallback(() => {
if (session?.verificationUri) {
openExternalLink(session.verificationUri as Link);
}
}, [session?.verificationUri]);

const handleCopyVerificationLink = useCallback(async () => {
if (session?.verificationUri) {
await copyToClipboard(session.verificationUri);
}
}, [session?.verificationUri]);

// Render UI states as separate functions for clarity
const renderSessionUI = () => {
if (!session) {
Expand All @@ -136,24 +155,19 @@ export const GitHubLoginWithDeviceFlowRoute: FC = () => {

return (
<Stack direction="vertical" gap="normal">
<Stack direction="vertical" gap="condensed">
<Text as="p">
Go to{' '}
<PrimerLink data-testid="device-verification-link" href={session.verificationUri}>
<code>{session.verificationUri}</code>
</PrimerLink>
</Text>
<Text as="p">and enter your device code when prompted:</Text>
</Stack>
<Heading as="h3">
Authorize the app <Text as="span">using this code.</Text>
</Heading>

<Stack align="center" direction="horizontal" justify="space-between" padding="condensed">
<Stack align="center" direction="horizontal" gap="condensed">
<Text
as="div"
data-testid="device-user-code"
style={{
fontSize: '32px',
fontWeight: 'bold',
fontFamily: 'monospace',
letterSpacing: '0.05em',
}}
>
{session.userCode}
Expand All @@ -164,27 +178,29 @@ export const GitHubLoginWithDeviceFlowRoute: FC = () => {
icon={CopyIcon}
onClick={handleCopyUserCode}
size="small"
variant="default"
variant="invisible"
/>
</Stack>

<Text as="p" size="small">
We're waiting for authorization...
</Text>
{isPolling && (
<Stack align="center" gap="normal">
<IconButton
aria-label="Polling"
className="animate-spin"
icon={SyncIcon}
size="small"
variant="invisible"
/>
<Text as="em" size="small">
Polling for authorization
<Stack direction="vertical" gap="condensed">
<Stack align="center" direction="horizontal" gap="condensed">
<CheckCircleFillIcon size={16} />
<Text size="small">Code copied to clipboard</Text>
</Stack>

<Stack align="center" direction="horizontal" gap="condensed">
<GlobeIcon size={16} />
<Text size="small">
Opened{' '}
{session.hostname === Constants.GITHUB_HOSTNAME ? 'GitHub.com' : session.hostname}
</Text>
</Stack>
)}

<Stack align="center" direction="horizontal" gap="condensed">
<SyncIcon className={isPolling ? 'animate-spin' : 'opacity-30'} size={16} />
<Text size="small">Waiting for authorization...</Text>
</Stack>
</Stack>
</Stack>
);
};
Expand Down Expand Up @@ -300,11 +316,37 @@ export const GitHubLoginWithDeviceFlowRoute: FC = () => {
{mainContent}
</Contents>

<Footer justify="space-between">
<Button data-testid="cancel-button" onClick={() => navigate(-1)} variant="default">
Cancel
</Button>
</Footer>
{session ? (
<Footer justify="space-between">
<Button
data-testid="open-browser-button"
leadingVisual={GlobeIcon}
onClick={handleOpenBrowser}
variant="primary"
>
Open {session.hostname === Constants.GITHUB_HOSTNAME ? 'GitHub.com' : session.hostname}
</Button>
<Stack direction="horizontal" gap="condensed">
<Button
data-testid="copy-link-button"
leadingVisual={LinkIcon}
onClick={handleCopyVerificationLink}
variant="default"
>
Copy link
</Button>
<Button data-testid="cancel-button" onClick={() => navigate(-1)} variant="default">
Cancel
</Button>
</Stack>
</Footer>
) : (
<Footer justify="end">
<Button data-testid="cancel-button" onClick={() => navigate(-1)} variant="default">
Cancel
</Button>
</Footer>
)}
</Page>
);
};