Skip to content

Commit 89610d6

Browse files
Authorize page logged-in state (#10)
* Update authorize flow for logged-in users Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Extract shared session loader Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 148f9db commit 89610d6

5 files changed

Lines changed: 246 additions & 103 deletions

File tree

client/app.tsx

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@ import {
66
OAuthCallbackRoute,
77
} from './client-routes.tsx'
88
import { Router } from './client-router.tsx'
9+
import {
10+
fetchSessionInfo,
11+
type SessionInfo,
12+
type SessionStatus,
13+
} from './session.ts'
914
import { colors, spacing, typography } from './styles/tokens.ts'
1015

11-
type SessionInfo = {
12-
email: string
13-
}
14-
15-
type SessionStatus = 'idle' | 'loading' | 'ready'
16-
1716
type NavLink = {
1817
href: string
1918
label: string
@@ -27,22 +26,7 @@ export function App(handle: Handle) {
2726
if (sessionStatus !== 'idle') return
2827
sessionStatus = 'loading'
2928

30-
try {
31-
const response = await fetch('/session', {
32-
headers: { Accept: 'application/json' },
33-
credentials: 'include',
34-
})
35-
const payload = await response.json().catch(() => null)
36-
const email =
37-
response.ok &&
38-
payload?.ok &&
39-
typeof payload?.session?.email === 'string'
40-
? payload.session.email.trim()
41-
: ''
42-
session = email ? { email } : null
43-
} catch {
44-
session = null
45-
}
29+
session = await fetchSessionInfo()
4630

4731
sessionStatus = 'ready'
4832
handle.update()

client/client-routes.tsx

Lines changed: 123 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { type Handle } from 'remix/component'
22
import { navigate } from './client-router.tsx'
33
import { Counter } from './counter.tsx'
4+
import {
5+
fetchSessionInfo,
6+
type SessionInfo,
7+
type SessionStatus,
8+
} from './session.ts'
49
import {
510
colors,
611
radius,
@@ -355,6 +360,8 @@ function OAuthAuthorizeForm(handle: Handle) {
355360
let message: OAuthAuthorizeMessage | null = null
356361
let submitting = false
357362
let lastSearch = ''
363+
let session: SessionInfo | null = null
364+
let sessionStatus: SessionStatus = 'idle'
358365

359366
function setMessage(next: OAuthAuthorizeMessage | null) {
360367
message = next
@@ -421,6 +428,16 @@ function OAuthAuthorizeForm(handle: Handle) {
421428
}
422429
}
423430

431+
async function loadSession() {
432+
if (sessionStatus !== 'idle') return
433+
sessionStatus = 'loading'
434+
435+
session = await fetchSessionInfo()
436+
437+
sessionStatus = 'ready'
438+
handle.update()
439+
}
440+
424441
async function submitDecision(
425442
decision: 'approve' | 'deny',
426443
form?: HTMLFormElement,
@@ -487,7 +504,11 @@ function OAuthAuthorizeForm(handle: Handle) {
487504
async function handleSubmit(event: SubmitEvent) {
488505
event.preventDefault()
489506
if (!(event.currentTarget instanceof HTMLFormElement)) return
490-
await submitDecision('approve', event.currentTarget)
507+
const hasSession = Boolean(session?.email)
508+
await submitDecision(
509+
'approve',
510+
hasSession ? undefined : event.currentTarget,
511+
)
491512
}
492513

493514
return () => {
@@ -497,12 +518,26 @@ function OAuthAuthorizeForm(handle: Handle) {
497518
lastSearch = currentSearch
498519
void loadInfo()
499520
}
521+
if (sessionStatus === 'idle') {
522+
void loadSession()
523+
}
500524

501525
const clientLabel = info?.client?.name ?? 'Unknown client'
502526
const scopes = info?.scopes ?? []
503527
const scopeLabel =
504528
scopes.length > 0 ? scopes.join(', ') : 'No scopes requested.'
505-
const actionsDisabled = status !== 'ready' || submitting
529+
const sessionEmail = session?.email ?? ''
530+
const isSessionReady = sessionStatus === 'ready'
531+
const isSessionLoading =
532+
sessionStatus === 'loading' || sessionStatus === 'idle'
533+
const isLoggedIn = isSessionReady && Boolean(sessionEmail)
534+
const actionsDisabled = status !== 'ready' || submitting || isSessionLoading
535+
const formReady = status === 'ready' && !isSessionLoading
536+
const authorizeLabel = submitting
537+
? 'Submitting...'
538+
: isLoggedIn
539+
? 'Approve connection'
540+
: 'Authorize'
506541

507542
return (
508543
<section
@@ -549,6 +584,34 @@ function OAuthAuthorizeForm(handle: Handle) {
549584
</p>
550585
<p css={{ margin: 0, color: colors.textMuted }}>{scopeLabel}</p>
551586
</section>
587+
{isSessionLoading ? (
588+
<p css={{ color: colors.textMuted }}>Checking your session…</p>
589+
) : null}
590+
{isLoggedIn ? (
591+
<section
592+
css={{
593+
padding: spacing.md,
594+
borderRadius: radius.md,
595+
border: `1px solid ${colors.border}`,
596+
backgroundColor: colors.surface,
597+
display: 'grid',
598+
gap: spacing.xs,
599+
}}
600+
>
601+
<p
602+
css={{
603+
margin: 0,
604+
fontWeight: typography.fontWeight.medium,
605+
color: colors.text,
606+
}}
607+
>
608+
Signed in as {sessionEmail}
609+
</p>
610+
<p css={{ margin: 0, color: colors.textMuted }}>
611+
Approve to continue with this account.
612+
</p>
613+
</section>
614+
) : null}
552615
{status === 'loading' ? (
553616
<p css={{ color: colors.textMuted }}>
554617
Loading authorization details…
@@ -574,62 +637,66 @@ function OAuthAuthorizeForm(handle: Handle) {
574637
border: `1px solid ${colors.border}`,
575638
backgroundColor: colors.surface,
576639
boxShadow: shadows.sm,
577-
opacity: status === 'ready' ? 1 : 0.7,
640+
opacity: formReady ? 1 : 0.7,
578641
}}
579642
on={{ submit: handleSubmit }}
580643
>
581-
<label css={{ display: 'grid', gap: spacing.xs }}>
582-
<span
583-
css={{
584-
color: colors.text,
585-
fontWeight: typography.fontWeight.medium,
586-
fontSize: typography.fontSize.sm,
587-
}}
588-
>
589-
Email
590-
</span>
591-
<input
592-
type="email"
593-
name="email"
594-
required
595-
autoComplete="email"
596-
placeholder="you@example.com"
597-
disabled={actionsDisabled}
598-
css={{
599-
padding: spacing.sm,
600-
borderRadius: radius.md,
601-
border: `1px solid ${colors.border}`,
602-
fontSize: typography.fontSize.base,
603-
fontFamily: typography.fontFamily,
604-
}}
605-
/>
606-
</label>
607-
<label css={{ display: 'grid', gap: spacing.xs }}>
608-
<span
609-
css={{
610-
color: colors.text,
611-
fontWeight: typography.fontWeight.medium,
612-
fontSize: typography.fontSize.sm,
613-
}}
614-
>
615-
Password
616-
</span>
617-
<input
618-
type="password"
619-
name="password"
620-
required
621-
autoComplete="current-password"
622-
placeholder="Enter your password"
623-
disabled={actionsDisabled}
624-
css={{
625-
padding: spacing.sm,
626-
borderRadius: radius.md,
627-
border: `1px solid ${colors.border}`,
628-
fontSize: typography.fontSize.base,
629-
fontFamily: typography.fontFamily,
630-
}}
631-
/>
632-
</label>
644+
{!isLoggedIn && isSessionReady ? (
645+
<>
646+
<label css={{ display: 'grid', gap: spacing.xs }}>
647+
<span
648+
css={{
649+
color: colors.text,
650+
fontWeight: typography.fontWeight.medium,
651+
fontSize: typography.fontSize.sm,
652+
}}
653+
>
654+
Email
655+
</span>
656+
<input
657+
type="email"
658+
name="email"
659+
required
660+
autoComplete="email"
661+
placeholder="you@example.com"
662+
disabled={actionsDisabled}
663+
css={{
664+
padding: spacing.sm,
665+
borderRadius: radius.md,
666+
border: `1px solid ${colors.border}`,
667+
fontSize: typography.fontSize.base,
668+
fontFamily: typography.fontFamily,
669+
}}
670+
/>
671+
</label>
672+
<label css={{ display: 'grid', gap: spacing.xs }}>
673+
<span
674+
css={{
675+
color: colors.text,
676+
fontWeight: typography.fontWeight.medium,
677+
fontSize: typography.fontSize.sm,
678+
}}
679+
>
680+
Password
681+
</span>
682+
<input
683+
type="password"
684+
name="password"
685+
required
686+
autoComplete="current-password"
687+
placeholder="Enter your password"
688+
disabled={actionsDisabled}
689+
css={{
690+
padding: spacing.sm,
691+
borderRadius: radius.md,
692+
border: `1px solid ${colors.border}`,
693+
fontSize: typography.fontSize.base,
694+
fontFamily: typography.fontFamily,
695+
}}
696+
/>
697+
</label>
698+
</>
699+
) : null}
633700
<div css={{ display: 'flex', gap: spacing.sm, flexWrap: 'wrap' }}>
634701
<button
635702
type="submit"
@@ -646,7 +713,7 @@ function OAuthAuthorizeForm(handle: Handle) {
646713
opacity: actionsDisabled ? 0.7 : 1,
647714
}}
648715
>
649-
{submitting ? 'Submitting...' : 'Authorize'}
716+
{authorizeLabel}
650717
</button>
651718
<button
652719
type="button"

client/session.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export type SessionInfo = {
2+
email: string
3+
}
4+
5+
export type SessionStatus = 'idle' | 'loading' | 'ready'
6+
7+
export async function fetchSessionInfo(): Promise<SessionInfo | null> {
8+
try {
9+
const response = await fetch('/session', {
10+
headers: { Accept: 'application/json' },
11+
credentials: 'include',
12+
})
13+
const payload = await response.json().catch(() => null)
14+
const email =
15+
response.ok && payload?.ok && typeof payload?.session?.email === 'string'
16+
? payload.session.email.trim()
17+
: ''
18+
return email ? { email } : null
19+
} catch {
20+
return null
21+
}
22+
}

worker/oauth-handlers.test.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import type {
66
CompleteAuthorizationOptions,
77
OAuthHelpers,
88
} from '@cloudflare/workers-oauth-provider'
9+
import {
10+
createAuthCookie,
11+
setAuthSessionSecret,
12+
} from '../server/auth-session.ts'
913
import {
1014
handleAuthorizeInfo,
1115
handleAuthorizeRequest,
@@ -27,6 +31,7 @@ const baseClient: ClientInfo = {
2731
clientName: 'epicflare Demo',
2832
tokenEndpointAuthMethod: 'client_secret_basic',
2933
}
34+
const cookieSecret = 'test-secret'
3035

3136
function createHelpers(overrides: Partial<OAuthHelpers> = {}): OAuthHelpers {
3237
return {
@@ -104,8 +109,16 @@ async function createDatabase(password: string) {
104109
} as unknown as D1Database
105110
}
106111

107-
function createEnv(helpers: OAuthHelpers, appDb?: D1Database) {
108-
return { OAUTH_PROVIDER: helpers, APP_DB: appDb } as unknown as Env
112+
function createEnv(
113+
helpers: OAuthHelpers,
114+
appDb?: D1Database,
115+
cookieSecretValue: string = cookieSecret,
116+
) {
117+
return {
118+
OAUTH_PROVIDER: helpers,
119+
APP_DB: appDb,
120+
COOKIE_SECRET: cookieSecretValue,
121+
} as unknown as Env
109122
}
110123

111124
function createFormRequest(
@@ -186,6 +199,37 @@ test('authorize requires email and password for approval', async () => {
186199
})
187200
})
188201

202+
test('authorize allows approval with an existing session', async () => {
203+
let capturedOptions: CompleteAuthorizationOptions | null = null
204+
const helpers = createHelpers({
205+
async completeAuthorization(options) {
206+
capturedOptions = options
207+
return { redirectTo: 'https://example.com/callback?code=session' }
208+
},
209+
})
210+
setAuthSessionSecret(cookieSecret)
211+
const cookie = await createAuthCookie(
212+
{ id: 'session-id', email: 'user@example.com' },
213+
false,
214+
)
215+
216+
const response = await handleAuthorizeRequest(
217+
createFormRequest(
218+
{ decision: 'approve' },
219+
{ Accept: 'application/json', Cookie: cookie },
220+
),
221+
createEnv(helpers),
222+
)
223+
224+
expect(response.status).toBe(200)
225+
const payload = await response.json()
226+
expect(payload).toEqual({
227+
ok: true,
228+
redirectTo: 'https://example.com/callback?code=session',
229+
})
230+
expect(capturedOptions).not.toBeNull()
231+
})
232+
189233
test('authorize uses default scopes when none requested', async () => {
190234
let resolveCapturedOptions:
191235
| ((value: CompleteAuthorizationOptions) => void)

0 commit comments

Comments
 (0)