Skip to content

Commit f227956

Browse files
committed
feat: oauth scopes choice
Signed-off-by: Adam Setch <adam.setch@outlook.com>
1 parent a44edb2 commit f227956

1 file changed

Lines changed: 142 additions & 116 deletions

File tree

src/renderer/routes/LoginWithDeviceFlow.tsx

Lines changed: 142 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { type FC, useCallback, useEffect, useState } from 'react';
1+
import {
2+
type FC,
3+
type ReactNode,
4+
useCallback,
5+
useEffect,
6+
useState,
7+
} from 'react';
28
import { useLocation, useNavigate } from 'react-router-dom';
39

410
import { CopyIcon, SignInIcon, SyncIcon } from '@primer/octicons-react';
@@ -148,6 +154,140 @@ export const LoginWithDeviceFlowRoute: FC = () => {
148154
}
149155
}, [session?.userCode]);
150156

157+
// Render UI states as separate functions for clarity
158+
const renderSessionUI = () => {
159+
if (!session) {
160+
return null;
161+
}
162+
163+
return (
164+
<Stack direction="vertical" gap="normal">
165+
<Stack direction="vertical" gap="condensed">
166+
<Text as="p">
167+
Go to{' '}
168+
<PrimerLink
169+
data-testid="device-verification-link"
170+
href={session.verificationUri}
171+
>
172+
<code>{session.verificationUri}</code>
173+
</PrimerLink>
174+
</Text>
175+
<Text as="p">and enter your device code when prompted:</Text>
176+
</Stack>
177+
178+
<Stack
179+
align="center"
180+
direction="horizontal"
181+
justify="space-between"
182+
padding="condensed"
183+
>
184+
<Text
185+
as="div"
186+
data-testid="device-user-code"
187+
style={{
188+
fontSize: '32px',
189+
fontWeight: 'bold',
190+
fontFamily: 'monospace',
191+
}}
192+
>
193+
{session.userCode}
194+
</Text>
195+
<IconButton
196+
aria-label="Copy device code"
197+
data-testid="copy-device-code"
198+
icon={CopyIcon}
199+
onClick={handleCopyUserCode}
200+
size="small"
201+
variant="default"
202+
/>
203+
</Stack>
204+
205+
<Text as="p" size="small">
206+
We're waiting for authorization...
207+
</Text>
208+
{isPolling && (
209+
<Stack align="center" gap="normal">
210+
<IconButton
211+
aria-label="Polling"
212+
className="animate-spin"
213+
icon={SyncIcon}
214+
size="small"
215+
variant="invisible"
216+
/>
217+
<Text as="em" size="small">
218+
Polling for authorization
219+
</Text>
220+
</Stack>
221+
)}
222+
</Stack>
223+
);
224+
};
225+
226+
const renderScopeChoiceUI = () => (
227+
<Stack direction="vertical" gap="normal">
228+
<Text as="p">Receive notifications for:</Text>
229+
230+
<Stack align="center" direction="vertical">
231+
<Button
232+
block
233+
data-testid="device-scope-full"
234+
labelWrap
235+
onClick={() => setScopeChoice('full')}
236+
variant="primary"
237+
>
238+
<Stack gap="none">
239+
<Text as="strong">Public and Private</Text>
240+
<Text size="small">
241+
Best experience, but requires broader permissions.
242+
</Text>
243+
<Text as="em" size="small">
244+
Scopes: {getRecommendedScopeNames().join(', ')}
245+
</Text>
246+
</Stack>
247+
</Button>
248+
249+
<Button
250+
block
251+
data-testid="device-scope-public"
252+
labelWrap
253+
onClick={() => setScopeChoice('public')}
254+
>
255+
<Stack gap="none">
256+
<Text>Public</Text>
257+
<Text size="small">
258+
Limited experience with least privilege permissions.
259+
</Text>
260+
<Text as="em" size="small">
261+
Scopes: {getAlternateScopeNames().join(', ')}
262+
</Text>
263+
</Stack>
264+
</Button>
265+
</Stack>
266+
</Stack>
267+
);
268+
269+
const renderInitializingUI = () => (
270+
<Stack align="center" direction="vertical" gap="normal">
271+
<IconButton
272+
aria-label="Initializing"
273+
className={'animate-spin'}
274+
icon={SyncIcon}
275+
size="large"
276+
variant="invisible"
277+
/>
278+
<Text>Initializing authentication...</Text>
279+
</Stack>
280+
);
281+
282+
let mainContent: ReactNode;
283+
if (session) {
284+
mainContent = renderSessionUI();
285+
} else if (!scopeChoice) {
286+
mainContent = renderScopeChoiceUI();
287+
} else {
288+
mainContent = renderInitializingUI();
289+
}
290+
151291
return (
152292
<Page testId="Login With Device Flow">
153293
<Header icon={SignInIcon}>Authorize with GitHub</Header>
@@ -168,121 +308,7 @@ export const LoginWithDeviceFlowRoute: FC = () => {
168308
variant="critical"
169309
/>
170310
)}
171-
172-
{/** GitHub Device Code Flow session */}
173-
{session ? (
174-
<Stack direction="vertical" gap="normal">
175-
<Stack direction="vertical" gap="condensed">
176-
<Text as="p">
177-
Go to{' '}
178-
<PrimerLink
179-
data-testid="device-verification-link"
180-
href={session.verificationUri}
181-
>
182-
<code>{session.verificationUri}</code>
183-
</PrimerLink>
184-
</Text>
185-
<Text as="p">and enter your device code when prompted:</Text>
186-
</Stack>
187-
188-
<Stack
189-
align="center"
190-
direction="horizontal"
191-
justify="space-between"
192-
padding="condensed"
193-
>
194-
<Text
195-
as="div"
196-
data-testid="device-user-code"
197-
style={{
198-
fontSize: '32px',
199-
fontWeight: 'bold',
200-
fontFamily: 'monospace',
201-
}}
202-
>
203-
{session.userCode}
204-
</Text>
205-
<IconButton
206-
aria-label="Copy device code"
207-
data-testid="copy-device-code"
208-
icon={CopyIcon}
209-
onClick={handleCopyUserCode}
210-
size="small"
211-
variant="default"
212-
/>
213-
</Stack>
214-
215-
<Text as="p" size="small">
216-
We're waiting for authorization...
217-
</Text>
218-
{isPolling && (
219-
<Stack align="center" gap="normal">
220-
<IconButton
221-
aria-label="Polling"
222-
className="animate-spin"
223-
icon={SyncIcon}
224-
size="small"
225-
variant="invisible"
226-
/>
227-
<Text as="em" size="small">
228-
Polling for authorization
229-
</Text>
230-
</Stack>
231-
)}
232-
</Stack>
233-
) : !scopeChoice ? (
234-
<Stack direction="vertical" gap="normal">
235-
<Text as="p">Receive notifications for:</Text>
236-
237-
<Stack align="center" direction="vertical">
238-
<Button
239-
block
240-
data-testid="device-scope-full"
241-
labelWrap
242-
onClick={() => setScopeChoice('full')}
243-
variant="primary"
244-
>
245-
<Stack gap="none">
246-
<Text as="strong">Public and Private</Text>
247-
<Text size="small">
248-
Best experience, but requires broader permissions.
249-
</Text>
250-
<Text as="em" size="small">
251-
Scopes: {getRecommendedScopeNames().join(', ')}
252-
</Text>
253-
</Stack>
254-
</Button>
255-
256-
<Button
257-
block
258-
data-testid="device-scope-public"
259-
labelWrap
260-
onClick={() => setScopeChoice('public')}
261-
>
262-
<Stack gap="none">
263-
<Text>Public</Text>
264-
<Text size="small">
265-
Limited experience with least privilege permissions.
266-
</Text>
267-
<Text as="em" size="small">
268-
Scopes: {getAlternateScopeNames().join(', ')}
269-
</Text>
270-
</Stack>
271-
</Button>
272-
</Stack>
273-
</Stack>
274-
) : (
275-
<Stack align="center" direction="vertical" gap="normal">
276-
<IconButton
277-
aria-label="Initializing"
278-
className={'animate-spin'}
279-
icon={SyncIcon}
280-
size="large"
281-
variant="invisible"
282-
/>
283-
<Text>Initializing authentication...</Text>
284-
</Stack>
285-
)}
311+
{mainContent}
286312
</Contents>
287313

288314
<Footer justify="space-between">

0 commit comments

Comments
 (0)