-
-
Notifications
You must be signed in to change notification settings - Fork 8
Separate Auth Page with Discord Redirect and Google-only Sign-In #742
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
56b514a
bc835dd
9654ff9
79841d5
e324173
8ca8b9f
5c9d02c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,6 @@ | ||
| # POSTGRES_URL must be set in Vercel environment variables for production. | ||
| # For local development, add POSTGRES_URL to .env.local instead. | ||
| # POSTGRES_URL= | ||
|
|
||
| NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in | ||
| NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,3 +59,6 @@ aef_index.csv | |
| *.log | ||
| deno.json | ||
| edge_function/ | ||
|
|
||
| # clerk configuration (can include secrets) | ||
| /.clerk/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| 'use client' | ||
|
|
||
| import { useEffect, Suspense } from 'react' | ||
| import { useSignIn, useUser } from '@clerk/nextjs' | ||
| import { useRouter } from 'next/navigation' | ||
|
|
||
| function DiscordAuthRedirect() { | ||
| const { signIn } = useSignIn() | ||
| const { isSignedIn, isLoaded: isUserLoaded } = useUser() | ||
| const isSignInLoaded = !!signIn | ||
| const router = useRouter() | ||
|
|
||
| useEffect(() => { | ||
| if (isUserLoaded && isSignedIn) { | ||
| router.push('/') | ||
| return | ||
| } | ||
|
|
||
| if (isSignInLoaded && signIn) { | ||
| signIn.sso({ | ||
| strategy: 'oauth_discord', | ||
| redirectCallbackUrl: '/sso-callback', | ||
| redirectUrl: '/', | ||
| }).catch((err) => { | ||
| console.error('Failed to redirect to Discord SSO:', err) | ||
| }) | ||
| } | ||
| }, [isSignInLoaded, signIn, isUserLoaded, isSignedIn, router]) | ||
|
|
||
| return ( | ||
| <div className="flex h-screen w-full items-center justify-center bg-background text-foreground"> | ||
| <div className="flex flex-col items-center gap-4"> | ||
| <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /> | ||
| <p className="text-sm font-medium">Redirecting to Discord SSO...</p> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export default function DiscordAuthAliasPage() { | ||
| return ( | ||
| <Suspense fallback={ | ||
| <div className="flex h-screen w-full items-center justify-center bg-background text-foreground"> | ||
| <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /> | ||
| </div> | ||
| }> | ||
| <DiscordAuthRedirect /> | ||
| </Suspense> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| 'use client' | ||
|
|
||
| import { useEffect, Suspense } from 'react' | ||
| import { useSignIn, useUser } from '@clerk/nextjs' | ||
| import { useRouter } from 'next/navigation' | ||
|
|
||
| function DiscordAuthRedirect() { | ||
| const { signIn } = useSignIn() | ||
| const { isSignedIn, isLoaded: isUserLoaded } = useUser() | ||
| const isSignInLoaded = !!signIn | ||
| const router = useRouter() | ||
|
|
||
| useEffect(() => { | ||
| if (isUserLoaded && isSignedIn) { | ||
| router.push('/') | ||
| return | ||
| } | ||
|
|
||
| if (isSignInLoaded && signIn) { | ||
| signIn.sso({ | ||
| strategy: 'oauth_discord', | ||
| redirectCallbackUrl: '/sso-callback', | ||
| redirectUrl: '/', | ||
| }).catch((err) => { | ||
| console.error('Failed to redirect to Discord SSO:', err) | ||
| }) | ||
| } | ||
| }, [isSignInLoaded, signIn, isUserLoaded, isSignedIn, router]) | ||
|
|
||
| return ( | ||
| <div className="flex h-screen w-full items-center justify-center bg-background text-foreground"> | ||
| <div className="flex flex-col items-center gap-4"> | ||
| <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /> | ||
| <p className="text-sm font-medium">Redirecting to Discord SSO...</p> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export default function DiscordAuthPage() { | ||
| return ( | ||
| <Suspense fallback={ | ||
| <div className="flex h-screen w-full items-center justify-center bg-background text-foreground"> | ||
| <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /> | ||
| </div> | ||
| }> | ||
| <DiscordAuthRedirect /> | ||
| </Suspense> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| 'use client' | ||
|
|
||
| import { useEffect, Suspense } from 'react' | ||
| import { useSignIn, useUser } from '@clerk/nextjs' | ||
| import { useRouter } from 'next/navigation' | ||
|
|
||
| function DiscordAuthRedirect() { | ||
| const { signIn } = useSignIn() | ||
| const { isSignedIn, isLoaded: isUserLoaded } = useUser() | ||
| const isSignInLoaded = !!signIn | ||
| const router = useRouter() | ||
|
|
||
| useEffect(() => { | ||
| if (isUserLoaded && isSignedIn) { | ||
| router.push('/') | ||
| return | ||
| } | ||
|
|
||
| if (isSignInLoaded && signIn) { | ||
| signIn.sso({ | ||
| strategy: 'oauth_discord', | ||
| redirectCallbackUrl: '/sso-callback', | ||
| redirectUrl: '/', | ||
| }).catch((err) => { | ||
| console.error('Failed to redirect to Discord SSO:', err) | ||
| }) | ||
| } | ||
| }, [isSignInLoaded, signIn, isUserLoaded, isSignedIn, router]) | ||
|
|
||
| return ( | ||
| <div className="flex h-screen w-full items-center justify-center bg-background text-foreground"> | ||
| <div className="flex flex-col items-center gap-4"> | ||
| <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /> | ||
| <p className="text-sm font-medium">Redirecting to Discord SSO...</p> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export default function DiscordSignInPage() { | ||
| return ( | ||
| <Suspense fallback={ | ||
| <div className="flex h-screen w-full items-center justify-center bg-background text-foreground"> | ||
| <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /> | ||
| </div> | ||
| }> | ||
| <DiscordAuthRedirect /> | ||
| </Suspense> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| 'use client' | ||
|
|
||
| import { Suspense } from 'react' | ||
| import SignInComponent from './sign-in-component' | ||
|
|
||
| export default function SignInPage() { | ||
| return ( | ||
| <Suspense fallback={ | ||
| <div className="flex h-screen w-full items-center justify-center bg-background text-foreground"> | ||
| <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /> | ||
| </div> | ||
| }> | ||
| <SignInComponent /> | ||
| </Suspense> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| 'use client' | ||
|
|
||
| import { useSignIn, useUser } from '@clerk/nextjs' | ||
| import { useRouter, useSearchParams } from 'next/navigation' | ||
| import { useEffect, useState } from 'react' | ||
| import { Button } from '@/components/ui/button' | ||
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' | ||
| import { FcGoogle } from 'react-icons/fc' | ||
| import Image from 'next/image' | ||
|
|
||
| export default function SignInComponent() { | ||
| const { signIn } = useSignIn() | ||
| const { isSignedIn, isLoaded: isUserLoaded } = useUser() | ||
| const isSignInLoaded = !!signIn | ||
| const router = useRouter() | ||
| const searchParams = useSearchParams() | ||
| const [error, setError] = useState<string | null>(null) | ||
| const [isLoading, setIsLoading] = useState(false) | ||
|
|
||
| // Get redirect url from search params, default to '/' | ||
| const redirectUrl = searchParams?.get('redirect_url') || '/' | ||
|
|
||
| useEffect(() => { | ||
| if (isUserLoaded && isSignedIn) { | ||
| router.push(redirectUrl) | ||
| } | ||
| }, [isUserLoaded, isSignedIn, router, redirectUrl]) | ||
|
Comment on lines
+20
to
+27
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: Yes, Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== app/sign-in/sign-in-component.tsx ==\n'
sed -n '1,120p' app/sign-in/sign-in-component.tsx
printf '\n== ClerkProvider references ==\n'
rg -n "ClerkProvider|allowedRedirectOrigins|allowedRedirectProtocols|redirect_url|redirectUrlComplete" -S . --glob '!node_modules' --glob '!dist' --glob '!build'Repository: QueueLab/QCX Length of output: 4295 🌐 Web query:
💡 Result: In the Next.js App Router, the router.push function supports navigation to external absolute URLs [1][2]. When you provide a fully qualified URL (e.g., 'https://google.com') to router.push, Next.js handles it by navigating to that external destination [2]. Historically, older versions of Next.js or the Pages Router required using window.location for external navigation, as router.push would interpret absolute paths as internal routes, causing them to be appended to the current origin (e.g., localhost:3000/https://google.com) [3][4]. Modern versions of the App Router have improved this behavior to support absolute URLs natively [1][2]. Key considerations when using router.push for external URLs: 1. Security (Open Redirects): If you allow user-provided input to determine the URL passed to router.push, you may expose your application to open redirect vulnerabilities [5]. Always validate and sanitize external URLs to ensure they point to expected or trusted domains before redirecting [5]. 2. Prefetching: Next.js distinguishes between internal and external URLs [6]. Internal routes are often prefetched, whereas external URLs are identified as external (using origin checking) and are not subject to the same prefetching mechanisms [6]. 3. Alternative Methods: While router.push is supported, the native window.location.href approach remains a reliable and standard way to perform external redirects in client-side code [4]. Additionally, for server-side redirects (e.g., in Server Actions or Route Handlers), the redirect function from next/navigation also natively supports external absolute URLs [7]. Citations:
Sanitize 🤖 Prompt for AI Agents |
||
|
|
||
| if (!isSignInLoaded || !isUserLoaded) { | ||
| return ( | ||
| <div className="flex h-screen w-full items-center justify-center bg-background text-foreground"> | ||
| <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| const handleGoogleSignIn = async () => { | ||
| setIsLoading(true) | ||
| setError(null) | ||
| try { | ||
| await signIn.sso({ | ||
| strategy: 'oauth_google', | ||
| redirectCallbackUrl: '/sso-callback', | ||
| redirectUrl: redirectUrl, | ||
| }) | ||
| } catch (err: any) { | ||
| console.error('Google Sign-In Error:', err) | ||
| setError(err?.message || 'An error occurred during Google sign-in.') | ||
| setIsLoading(false) | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <div className="flex min-h-screen w-full items-center justify-center bg-background px-4 py-12"> | ||
| <Card className="w-full max-w-md border-border bg-card/50 backdrop-blur shadow-2xl"> | ||
| <CardHeader className="flex flex-col items-center space-y-2 text-center pb-8"> | ||
| <div className="flex items-center gap-2 mb-2"> | ||
| <Image | ||
| src="/images/logo.svg" | ||
| alt="QCX Logo" | ||
| width={40} | ||
| height={40} | ||
| className="h-10 w-auto" | ||
| /> | ||
| <span className="text-3xl font-poppins font-bold text-primary select-none">QCX</span> | ||
| </div> | ||
| <CardTitle className="text-2xl font-poppins font-bold text-foreground">Welcome to QCX</CardTitle> | ||
| <CardDescription className="text-sm text-muted-foreground"> | ||
| Sign in to access your dashboard, map tools, and history. | ||
| </CardDescription> | ||
| </CardHeader> | ||
| <CardContent className="space-y-4"> | ||
| {error && ( | ||
| <div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive text-center"> | ||
| {error} | ||
| </div> | ||
| )} | ||
|
|
||
| <Button | ||
| variant="outline" | ||
| type="button" | ||
| className="w-full flex items-center justify-center gap-3 py-6 text-base font-semibold hover:bg-muted/80 border-border" | ||
| onClick={handleGoogleSignIn} | ||
| disabled={isLoading} | ||
| > | ||
| {isLoading ? ( | ||
| <div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" /> | ||
| ) : ( | ||
| <FcGoogle className="h-5 w-5" /> | ||
| )} | ||
| <span>Continue with Google</span> | ||
| </Button> | ||
|
|
||
|
|
||
| </CardContent> | ||
| </Card> | ||
| </div> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| 'use client' | ||
|
|
||
| import { Suspense } from 'react' | ||
| import { AuthenticateWithRedirectCallback } from "@clerk/nextjs" | ||
|
|
||
| export default function SSOCallbackPage() { | ||
| return ( | ||
| <Suspense fallback={ | ||
| <div className="flex h-screen w-full items-center justify-center bg-background text-foreground"> | ||
| <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /> | ||
| </div> | ||
| }> | ||
| <AuthenticateWithRedirectCallback /> | ||
| </Suspense> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,10 +5,36 @@ import { useMapLoading } from '@/components/map-loading-context'; | |||||||||
| import { useProfileToggle } from '@/components/profile-toggle-context'; // Added import | ||||||||||
| import { useUsageToggle } from '@/components/usage-toggle-context'; | ||||||||||
|
|
||||||||||
| import { useState, useEffect } from 'react'; | ||||||||||
| import { usePathname } from 'next/navigation'; | ||||||||||
|
|
||||||||||
| const ConditionalLottie = () => { | ||||||||||
| const pathname = usePathname(); | ||||||||||
| const { isMapLoaded } = useMapLoading(); | ||||||||||
| const { activeView } = useProfileToggle(); // Added this line | ||||||||||
| const { isUsageOpen } = useUsageToggle(); | ||||||||||
| const [isPlaywright, setIsPlaywright] = useState(false); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win Initialize state from the environment variable to prevent Lottie flashes. By initializing the state to Initialize the state directly with the build-time environment variable so it renders 💡 Proposed fix- const [isPlaywright, setIsPlaywright] = useState(false);
+ const [isPlaywright, setIsPlaywright] = useState(
+ () => process.env.NEXT_PUBLIC_PLAYWRIGHT_TEST === 'true'
+ );📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| useEffect(() => { | ||||||||||
| if (typeof window !== 'undefined') { | ||||||||||
| const isTest = Boolean( | ||||||||||
| navigator.webdriver || | ||||||||||
| navigator.userAgent.toLowerCase().includes('playwright') || | ||||||||||
| (window as any).isPlaywright || | ||||||||||
| process.env.NEXT_PUBLIC_PLAYWRIGHT_TEST === 'true' | ||||||||||
| ); | ||||||||||
| setIsPlaywright(isTest); | ||||||||||
| } | ||||||||||
| }, []); | ||||||||||
|
|
||||||||||
| const isAuthPage = pathname?.startsWith('/sign-in') || | ||||||||||
| pathname?.startsWith('/discord-auth') || | ||||||||||
| pathname?.startsWith('/auth/discord') || | ||||||||||
| pathname?.startsWith('/sso-callback'); | ||||||||||
|
|
||||||||||
| if (isPlaywright || isAuthPage) { | ||||||||||
| return null; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // Updated isVisible logic | ||||||||||
| return <LottiePlayer isVisible={!isMapLoaded && activeView === null && !isUsageOpen} />; | ||||||||||
|
|
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[blocking/security] is attacker-controlled and is passed directly to both and Clerk's . A link such as can send an already signed-in user immediately, or a newly authenticated user after OAuth, to an external site. Accept only same-origin relative paths (or an explicit allowlist) before using this value.