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
3 changes: 3 additions & 0 deletions .env
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
4 changes: 4 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@ NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL_HERE
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY_HERE
SUPABASE_SERVICE_ROLE_KEY=YOUR_SUPABASE_SERVICE_ROLE_KEY_HERE
DATABASE_URL=postgresql://postgres:[YOUR-POSTGRES-PASSWORD]@[YOUR-SUPABASE-DB-HOST]:[PORT]/postgres

# Clerk Custom Sign-In/Up URLs
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,6 @@ aef_index.csv
*.log
deno.json
edge_function/

# clerk configuration (can include secrets)
/.clerk/
50 changes: 50 additions & 0 deletions app/auth/discord/page.tsx
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>
)
}
50 changes: 50 additions & 0 deletions app/discord-auth/page.tsx
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>
)
}
19 changes: 18 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,24 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
<ClerkProvider>
<ClerkProvider
appearance={{
elements: {
socialButtonsBlockButton__discord: {
display: 'none',
},
socialButtonsBlockButton: {
width: '100%',
justifyContent: 'center',
},
socialButtonsBlockButtonContainer: {
width: '100%',
display: 'flex',
justifyContent: 'center',
},
},
}}
>
<html lang="en" suppressHydrationWarning>
<head>
<script
Expand Down
50 changes: 50 additions & 0 deletions app/sign-in/discord/page.tsx
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>
)
}
16 changes: 16 additions & 0 deletions app/sign-in/page.tsx
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>
)
}
99 changes: 99 additions & 0 deletions app/sign-in/sign-in-component.tsx
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') || '/'

Copy link
Copy Markdown

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.


useEffect(() => {
if (isUserLoaded && isSignedIn) {
router.push(redirectUrl)
}
}, [isUserLoaded, isSignedIn, router, redirectUrl])
Comment on lines +20 to +27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does @clerk/nextjs 7.x validate redirect_url against allowedRedirectOrigins by default when no ClerkProvider config is set?

💡 Result:

Yes, @clerk/nextjs 7.x validates redirect URLs against the allowedRedirectOrigins configuration [1][2]. When no specific allowedRedirectOrigins configuration is provided, the Clerk SDK applies default validation rules [3][4]. By default, the SDK allows the following types of URLs: - Relative URLs [3] - Absolute URLs with an 'https' protocol where the domain matches, or is a subdomain of, the eTLD+1 (the effective Top-Level Domain plus one) of your application [3]. If a redirect URL does not match these default allowed origins or any explicitly configured origins, the redirect is considered unsafe [1][5]. In such cases, the redirect is ignored, a warning is logged in the browser console, and the application falls back to the default redirect behavior [1][3][5].

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:

Next.js App Router router.push absolute external URL behavior

💡 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 redirect_url before calling router.push.
Clerk already protects redirectUrlComplete, but the effect still trusts the query param and can send signed-in users to an external URL. Normalize it to a same-origin relative path (or fall back to /) before redirecting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/sign-in/sign-in-component.tsx` around lines 19 - 26, Sanitize the
`redirectUrl` value used by the `useEffect` redirect before passing it to
`router.push`: allow only same-origin relative paths, and fall back to `/` for
absolute or external URLs. Keep the existing signed-in and loaded checks, and
ensure the effect depends on the normalized redirect value.


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>
)
}
16 changes: 16 additions & 0 deletions app/sso-callback/page.tsx
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>
)
}
26 changes: 26 additions & 0 deletions components/conditional-lottie.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 false, the LottiePlayer will still render during the initial client frame before the useEffect hides it. This flash can still cause flakiness or steal focus in visual E2E tests.

Initialize the state directly with the build-time environment variable so it renders null from the very first frame when testing.

💡 Proposed fix
-  const [isPlaywright, setIsPlaywright] = useState(false);
+  const [isPlaywright, setIsPlaywright] = useState(
+    () => process.env.NEXT_PUBLIC_PLAYWRIGHT_TEST === 'true'
+  );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [isPlaywright, setIsPlaywright] = useState(false);
const [isPlaywright, setIsPlaywright] = useState(
() => process.env.NEXT_PUBLIC_PLAYWRIGHT_TEST === 'true'
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/conditional-lottie.tsx` at line 14, Initialize the isPlaywright
state in ConditionalLottie from the build-time environment variable instead of
false, so the component returns null on the first render during Playwright tests
and prevents the LottiePlayer flash. Preserve the existing useEffect
synchronization for subsequent environment changes.


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} />;
Expand Down
Loading