Skip to content

Commit dc3ae56

Browse files
committed
login modal
1 parent f379b3f commit dc3ae56

File tree

14 files changed

+315
-49
lines changed

14 files changed

+315
-49
lines changed

src/auth/client.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,26 @@ import type { OAuthProvider } from './types'
1717
export const authClient = {
1818
signIn: {
1919
/**
20-
* Initiate OAuth sign-in with a social provider
20+
* Initiate OAuth sign-in with a social provider (full page redirect)
2121
*/
2222
social: ({ provider }: { provider: OAuthProvider }) => {
2323
window.location.href = `/auth/${provider}/start`
2424
},
25+
26+
/**
27+
* Initiate OAuth sign-in in a popup window (for modal-based login)
28+
*/
29+
socialPopup: ({ provider }: { provider: OAuthProvider }) => {
30+
const width = 500
31+
const height = 600
32+
const left = window.screenX + (window.outerWidth - width) / 2
33+
const top = window.screenY + (window.outerHeight - height) / 2
34+
window.open(
35+
`/auth/${provider}/start?popup=true`,
36+
'tanstack-oauth',
37+
`width=${width},height=${height},left=${left},top=${top},popup=yes`,
38+
)
39+
},
2540
},
2641

2742
/**

src/auth/index.server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ export {
6464
createOAuthStateCookie,
6565
clearOAuthStateCookie,
6666
getOAuthStateCookie,
67+
createOAuthPopupCookie,
68+
clearOAuthPopupCookie,
69+
isOAuthPopupMode,
6770
SESSION_DURATION_MS,
6871
SESSION_MAX_AGE_SECONDS,
6972
} from './session.server'

src/auth/oauth.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export function buildGitHubAuthUrl(
186186
clientId,
187187
)}&redirect_uri=${encodeURIComponent(
188188
redirectUri,
189-
)}&scope=user:email&state=${state}`
189+
)}&scope=user:email&state=${encodeURIComponent(state)}`
190190
}
191191

192192
/**
@@ -201,7 +201,7 @@ export function buildGoogleAuthUrl(
201201
clientId,
202202
)}&redirect_uri=${encodeURIComponent(
203203
redirectUri,
204-
)}&response_type=code&scope=openid email profile&state=${state}`
204+
)}&response_type=code&scope=openid email profile&state=${encodeURIComponent(state)}`
205205
}
206206

207207
/**

src/auth/session.server.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,19 @@ export function getOAuthStateCookie(request: Request): string | null {
264264
return decodeURIComponent(stateCookie.split('=').slice(1).join('=').trim())
265265
}
266266

267+
export function createOAuthPopupCookie(isProduction: boolean): string {
268+
return `oauth_popup=1; HttpOnly; Path=/; Max-Age=${10 * 60}; SameSite=Lax${isProduction ? '; Secure' : ''}`
269+
}
270+
271+
export function clearOAuthPopupCookie(isProduction: boolean): string {
272+
return `oauth_popup=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax${isProduction ? '; Secure' : ''}`
273+
}
274+
275+
export function isOAuthPopupMode(request: Request): boolean {
276+
const cookies = request.headers.get('cookie') || ''
277+
return cookies.split(';').some((c) => c.trim().startsWith('oauth_popup=1'))
278+
}
279+
267280
// ============================================================================
268281
// Session Constants
269282
// ============================================================================

src/components/DocFeedbackProvider.tsx

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DocFeedbackFloatingButton } from './DocFeedbackFloatingButton'
77
import { getDocFeedbackForPageQueryOptions } from '~/queries/docFeedback'
88
import { createDocFeedback } from '~/utils/docFeedback.functions'
99
import { useCurrentUser } from '~/hooks/useCurrentUser'
10+
import { useLoginModal } from '~/contexts/LoginModalContext'
1011
import {
1112
findReferenceableBlocks,
1213
getBlockIdentifier,
@@ -28,6 +29,7 @@ export function DocFeedbackProvider({
2829
libraryVersion,
2930
}: DocFeedbackProviderProps) {
3031
const user = useCurrentUser()
32+
const { openLoginModal } = useLoginModal()
3133
const containerRef = React.useRef<HTMLDivElement>(null)
3234

3335
const [creatingState, setCreatingState] = React.useState<{
@@ -76,9 +78,9 @@ export function DocFeedbackProvider({
7678
)
7779
}, [feedbackData, user])
7880

79-
// Find blocks and compute selectors after render
81+
// Find blocks and compute selectors after render (runs for all users to show teasers)
8082
React.useEffect(() => {
81-
if (!user || !containerRef.current) return
83+
if (!containerRef.current) return
8284

8385
const container = containerRef.current
8486
const blocks = findReferenceableBlocks(container)
@@ -164,7 +166,7 @@ export function DocFeedbackProvider({
164166
}
165167
})
166168
}
167-
}, [user, children])
169+
}, [children])
168170

169171
// Update block indicators when notes or improvements change
170172
React.useEffect(() => {
@@ -205,6 +207,17 @@ export function DocFeedbackProvider({
205207
const selector = blockSelectors.get(blockId)
206208
if (!selector) return
207209

210+
// If not logged in, show login modal
211+
if (!user) {
212+
openLoginModal({
213+
onSuccess: () => {
214+
// After login, open the creating interface
215+
setCreatingState({ blockId, type })
216+
},
217+
})
218+
return
219+
}
220+
208221
// Check if there's existing feedback for this block
209222
const existingNote = userNotes.find((n) => n.blockSelector === selector)
210223
const existingImprovement = userImprovements.find(
@@ -226,27 +239,25 @@ export function DocFeedbackProvider({
226239
type,
227240
})
228241
},
229-
[blockSelectors, userNotes, userImprovements],
242+
[blockSelectors, userNotes, userImprovements, user, openLoginModal],
230243
)
231244

232-
if (!user) {
233-
return <div ref={containerRef}>{children}</div>
234-
}
235-
236245
return (
237246
<div ref={containerRef} className="relative">
238247
{children}
239248

240-
{/* Render floating buttons for each block */}
249+
{/* Render floating buttons for each block (visible to all users) */}
241250
{Array.from(blockSelectors.keys()).map((blockId) => {
242251
const selector = blockSelectors.get(blockId)
243252
if (!selector) return null
244253

245-
// Check if this block has a note or improvement
246-
const note = userNotes.find((n) => n.blockSelector === selector)
247-
const improvement = userImprovements.find(
248-
(n) => n.blockSelector === selector,
249-
)
254+
// Check if this block has a note or improvement (only for logged-in users)
255+
const note = user
256+
? userNotes.find((n) => n.blockSelector === selector)
257+
: undefined
258+
const improvement = user
259+
? userImprovements.find((n) => n.blockSelector === selector)
260+
: undefined
250261
const isHovered = hoveredBlockId === blockId
251262

252263
return (
@@ -267,7 +278,7 @@ export function DocFeedbackProvider({
267278
)
268279
})}
269280

270-
{/* Render notes inline */}
281+
{/* Render notes inline (only for logged-in users) */}
271282
{userNotes.map((note) => {
272283
// Find the block ID for this note's selector
273284
const blockId = Array.from(blockSelectors.entries()).find(

src/components/LoginModal.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import * as React from 'react'
2+
import * as DialogPrimitive from '@radix-ui/react-dialog'
3+
import { X } from 'lucide-react'
4+
import { GithubIcon } from '~/components/icons/GithubIcon'
5+
import { GoogleIcon } from '~/components/icons/GoogleIcon'
6+
import { authClient } from '~/auth/client'
7+
8+
interface LoginModalProps {
9+
open: boolean
10+
onOpenChange: (open: boolean) => void
11+
}
12+
13+
export function LoginModal({ open, onOpenChange }: LoginModalProps) {
14+
return (
15+
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
16+
<DialogPrimitive.Portal>
17+
<DialogPrimitive.Overlay className="fixed inset-0 z-[999] bg-black/60 backdrop-blur-sm" />
18+
<DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-[1000] w-full max-w-xs -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white dark:bg-gray-900 p-6 shadow-xl">
19+
<div className="flex items-center justify-between mb-4">
20+
<DialogPrimitive.Title className="text-lg font-semibold text-gray-900 dark:text-gray-100">
21+
Sign in to continue
22+
</DialogPrimitive.Title>
23+
<DialogPrimitive.Close className="rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-800">
24+
<X className="w-5 h-5 text-gray-500" />
25+
</DialogPrimitive.Close>
26+
</div>
27+
28+
<div className="space-y-3">
29+
<button
30+
onClick={() =>
31+
authClient.signIn.socialPopup({ provider: 'github' })
32+
}
33+
className="w-full flex items-center justify-center gap-2 bg-gray-900 hover:bg-black text-white dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 font-medium py-2.5 px-4 rounded-lg transition-colors"
34+
>
35+
<GithubIcon className="w-5 h-5" />
36+
Continue with GitHub
37+
</button>
38+
<button
39+
onClick={() =>
40+
authClient.signIn.socialPopup({ provider: 'google' })
41+
}
42+
className="w-full flex items-center justify-center gap-2 bg-[#DB4437] hover:bg-[#c53929] text-white font-medium py-2.5 px-4 rounded-lg transition-colors"
43+
>
44+
<GoogleIcon className="w-5 h-5" />
45+
Continue with Google
46+
</button>
47+
</div>
48+
</DialogPrimitive.Content>
49+
</DialogPrimitive.Portal>
50+
</DialogPrimitive.Root>
51+
)
52+
}

src/components/ShowcaseGallery.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ import type { ShowcaseUseCase } from '~/db/types'
1414
import { Plus } from 'lucide-react'
1515
import { Button } from './Button'
1616
import { useCurrentUser } from '~/hooks/useCurrentUser'
17+
import { useLoginModal } from '~/contexts/LoginModalContext'
1718

1819
export function ShowcaseGallery() {
1920
const navigate = useNavigate({ from: '/showcase/' })
2021
const search = useSearch({ from: '/showcase/' })
2122
const queryClient = useQueryClient()
2223
const currentUser = useCurrentUser()
24+
const { openLoginModal } = useLoginModal()
2325

2426
const { data, isLoading } = useQuery(
2527
getApprovedShowcasesQueryOptions({
@@ -175,8 +177,9 @@ export function ShowcaseGallery() {
175177

176178
const handleVote = (showcaseId: string, value: 1 | -1) => {
177179
if (!currentUser) {
178-
// Redirect to login
179-
navigate({ to: '/login', search: { redirect: '/showcase' } })
180+
openLoginModal({
181+
onSuccess: () => voteMutation.mutate({ showcaseId, value }),
182+
})
180183
return
181184
}
182185
voteMutation.mutate({ showcaseId, value })

src/components/ShowcaseSection.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react'
22
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
3-
import { Link, useNavigate } from '@tanstack/react-router'
3+
import { Link } from '@tanstack/react-router'
44
import {
55
getFeaturedShowcasesQueryOptions,
66
getShowcasesByLibraryQueryOptions,
@@ -11,6 +11,7 @@ import { ShowcaseCard, ShowcaseCardSkeleton } from './ShowcaseCard'
1111
import { buttonStyles } from './Button'
1212
import { ArrowRight, Plus } from 'lucide-react'
1313
import { useCurrentUser } from '~/hooks/useCurrentUser'
14+
import { useLoginModal } from '~/contexts/LoginModalContext'
1415

1516
interface ShowcaseSectionProps {
1617
title?: string
@@ -62,9 +63,9 @@ export function ShowcaseSection({
6263
showViewAll = true,
6364
minItems = 3,
6465
}: ShowcaseSectionProps) {
65-
const navigate = useNavigate()
6666
const queryClient = useQueryClient()
6767
const currentUser = useCurrentUser()
68+
const { openLoginModal } = useLoginModal()
6869

6970
const queryOptions = libraryId
7071
? getShowcasesByLibraryQueryOptions({ libraryId, limit })
@@ -180,7 +181,9 @@ export function ShowcaseSection({
180181

181182
const handleVote = (showcaseId: string, value: 1 | -1) => {
182183
if (!currentUser) {
183-
navigate({ to: '/login', search: { redirect: '/showcase' } })
184+
openLoginModal({
185+
onSuccess: () => voteMutation.mutate({ showcaseId, value }),
186+
})
184187
return
185188
}
186189
voteMutation.mutate({ showcaseId, value })

src/contexts/LoginModalContext.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import * as React from 'react'
2+
import { useQueryClient } from '@tanstack/react-query'
3+
import { LoginModal } from '~/components/LoginModal'
4+
5+
interface LoginModalContextValue {
6+
openLoginModal: (options?: { onSuccess?: () => void }) => void
7+
closeLoginModal: () => void
8+
}
9+
10+
const LoginModalContext = React.createContext<LoginModalContextValue | null>(
11+
null,
12+
)
13+
14+
export function useLoginModal() {
15+
const context = React.useContext(LoginModalContext)
16+
if (!context) {
17+
throw new Error('useLoginModal must be used within a LoginModalProvider')
18+
}
19+
return context
20+
}
21+
22+
interface LoginModalProviderProps {
23+
children: React.ReactNode
24+
}
25+
26+
export function LoginModalProvider({ children }: LoginModalProviderProps) {
27+
const queryClient = useQueryClient()
28+
const [isOpen, setIsOpen] = React.useState(false)
29+
const pendingOnSuccessRef = React.useRef<(() => void) | undefined>(undefined)
30+
31+
const openLoginModal = React.useCallback(
32+
(options?: { onSuccess?: () => void }) => {
33+
pendingOnSuccessRef.current = options?.onSuccess
34+
setIsOpen(true)
35+
},
36+
[],
37+
)
38+
39+
const closeLoginModal = React.useCallback(() => {
40+
setIsOpen(false)
41+
pendingOnSuccessRef.current = undefined
42+
}, [])
43+
44+
React.useEffect(() => {
45+
const handleMessage = (event: MessageEvent) => {
46+
if (event.origin !== window.location.origin) return
47+
if (event.data?.type === 'TANSTACK_AUTH_SUCCESS') {
48+
queryClient.invalidateQueries({ queryKey: ['currentUser'] })
49+
const onSuccess = pendingOnSuccessRef.current
50+
setIsOpen(false)
51+
pendingOnSuccessRef.current = undefined
52+
if (onSuccess) {
53+
setTimeout(onSuccess, 0)
54+
}
55+
}
56+
}
57+
window.addEventListener('message', handleMessage)
58+
return () => window.removeEventListener('message', handleMessage)
59+
}, [queryClient])
60+
61+
const value = React.useMemo(
62+
() => ({ openLoginModal, closeLoginModal }),
63+
[openLoginModal, closeLoginModal],
64+
)
65+
66+
return (
67+
<LoginModalContext.Provider value={value}>
68+
{children}
69+
<LoginModal open={isOpen} onOpenChange={setIsOpen} />
70+
</LoginModalContext.Provider>
71+
)
72+
}

0 commit comments

Comments
 (0)