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
100 changes: 100 additions & 0 deletions app/api/clerk/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Webhook } from 'svix'
import { headers } from 'next/headers'
import { WebhookEvent } from '@clerk/nextjs/server'
import { db } from '@/lib/db'
import { users } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'

export async function POST(req: Request) {
// You can find this in the Clerk Dashboard -> Webhooks -> choose the endpoint
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET

if (!WEBHOOK_SECRET) {
throw new Error('Please add CLERK_WEBHOOK_SECRET from Clerk Dashboard to .env or .env.local')
}

// Get the headers
const headerPayload = await headers()
const svix_id = headerPayload.get('svix-id')
const svix_timestamp = headerPayload.get('svix-timestamp')
const svix_signature = headerPayload.get('svix-signature')

// If there are no headers, error out
if (!svix_id || !svix_timestamp || !svix_signature) {
return new Response('Error occured -- no svix headers', {
status: 400,
})
}

// Get the body
const payload = await req.json()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Svix verification needs the exact raw request payload. Parsing with req.json() and then JSON.stringify(payload) can change the byte representation, which can cause valid Clerk webhooks to fail signature verification.

Suggested fix: read the raw body first (const body = await req.text()), verify that raw string, and only then parse JSON for downstream logic.

const body = JSON.stringify(payload)

// Create a new Svix instance with your secret.
const wh = new Webhook(WEBHOOK_SECRET)

let evt: WebhookEvent

// Verify the payload with the headers
try {
evt = wh.verify(body, {
'svix-id': svix_id,
'svix-timestamp': svix_timestamp,
'svix-signature': svix_signature,
}) as WebhookEvent
} catch (err) {
console.error('Error verifying webhook:', err)
return new Response('Error occured', {
status: 400,
})
}

// Do something with the payload
// For this guide, you can log the payload to your console
const { id } = evt.data
const eventType = evt.type

if (eventType === 'user.created' || eventType === 'user.updated') {
const { id, email_addresses, first_name, last_name } = evt.data as any
const email = email_addresses[0]?.email_address

if (id) {
// Upsert user
const [existingUser] = await db.select()
.from(users)
.where(eq(users.clerkUserId, id))
.limit(1);

if (existingUser) {
await db.update(users)
.set({ email })
.where(eq(users.clerkUserId, id));
} else {
// Check by email first to link
const [existingEmailUser] = await db.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
Comment on lines +59 to +77

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard against a missing email.

email_addresses[0]?.email_address can be undefined (e.g. phone-only or SSO sign-ups). That flows into the email lookup at Line 76 as eq(users.email, undefined), producing an incorrect query, and into the .set({ email }) update at Line 70. Skip email-based linking / updates when no email is present.

🤖 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/api/clerk/webhook/route.ts` around lines 59 - 77, The webhook user upsert
in the route handler allows `email_addresses[0]?.email_address` to be undefined,
which then gets used in both `db.update(users).set({ email })` and the
`eq(users.email, email)` lookup. Update the `route.ts` logic around the
`existingUser` and `existingEmailUser` checks to branch on whether `email` is
present, so email-based linking and updates are skipped entirely when there is
no email.


if (existingEmailUser) {
await db.update(users)
.set({ clerkUserId: id })
.where(eq(users.id, existingEmailUser.id));
} else {
await db.insert(users).values({
clerkUserId: id,
email,
role: 'viewer'
});
}
}
}
Comment on lines +61 to +91

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 | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File list ==\n'
git ls-files | rg '^(app/api/clerk/webhook/route\.ts|.*syncUser.*|.*resolveClerkUserToDbUser.*|.*getCurrentUserIdOnServer.*|.*users.*)$' || true

printf '\n== Outline: app/api/clerk/webhook/route.ts ==\n'
ast-grep outline app/api/clerk/webhook/route.ts --view expanded || true

printf '\n== Relevant searches ==\n'
rg -n --hidden --glob '!node_modules' --glob '!dist' --glob '!build' \
  'resolveClerkUserToDbUser|syncUser|getCurrentUserIdOnServer|onConflictDoNothing|clerkUserId|email_addresses|email_address|try\s*\{' \
  app api . || true

printf '\n== route.ts excerpt ==\n'
sed -n '1,220p' app/api/clerk/webhook/route.ts || true

Repository: QueueLab/QCX

Length of output: 50368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== route.ts (30-110) ==\n'
sed -n '30,110p' app/api/clerk/webhook/route.ts

printf '\n== lib/actions/users.ts ==\n'
sed -n '1,220p' lib/actions/users.ts

printf '\n== sync-user references ==\n'
rg -n --hidden --glob '!node_modules' --glob '!dist' --glob '!build' 'syncUser|resolveClerkUserToDbUser|getCurrentUserIdOnServer' lib app components

Repository: QueueLab/QCX

Length of output: 11169


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== lib/auth/get-current-user.ts ==\n'
sed -n '1,220p' lib/auth/get-current-user.ts

printf '\n== lib/actions/sync-user.ts ==\n'
sed -n '1,200p' lib/actions/sync-user.ts

printf '\n== user schema ==\n'
rg -n --hidden --glob '!node_modules' --glob '!dist' --glob '!build' 'pgTable\\(|users =|email:|clerkUserId:|selectedModel:' lib db app

Repository: QueueLab/QCX

Length of output: 3937


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== schema files ==\n'
git ls-files | rg '(^|/)(schema|schemas|db/schema)\.(ts|tsx|js|jsx)$|lib/db/schema' || true

printf '\n== schema outline ==\n'
ast-grep outline lib/db/schema.ts --view expanded || true

printf '\n== schema excerpt ==\n'
sed -n '1,260p' lib/db/schema.ts || true

printf '\n== unique/nullability search ==\n'
rg -n --hidden --glob '!node_modules' --glob '!dist' --glob '!build' \
  'uniqueIndex|unique\(|clerkUserId|email:.*(text|varchar|uuid)|notNull\(\)|nullable' \
  lib/db/schema.ts

Repository: QueueLab/QCX

Length of output: 12301


Make the webhook write idempotent
This upsert can still race with the Clerk sync path or a duplicate delivery; if the insert loses, the unique clerk_user_id/email constraint will throw and this handler returns 500 because the DB block is uncaught. Only touch the email column when email is defined, and make the create/link step atomic instead of doing check-then-insert.

🤖 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/api/clerk/webhook/route.ts` around lines 61 - 91, The webhook upsert in
route.ts is still race-prone because it does check-then-insert/link around the
users table. Update the Clerk webhook handler so it only sets email when email
is defined, and make the create/link flow atomic in the existing idempotent path
around the db.select/db.update/db.insert logic on users. Ensure duplicate
deliveries or concurrent syncs cannot surface unique constraint errors from
clerkUserId/email and cause the handler to return 500.

}

if (eventType === 'user.deleted') {
// Optionally handle user deletion
// For safety, we might not want to delete user data entirely
}

return new Response('', { status: 200 })
}
5 changes: 5 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ClerkProvider } from "@clerk/nextjs"
import { UserSync } from "@/components/user-sync"
import type { Metadata, Viewport } from 'next'
import { Inter as FontSans, Poppins } from 'next/font/google'
import './globals.css'
Expand Down Expand Up @@ -75,6 +77,7 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
<ClerkProvider>
<html lang="en" suppressHydrationWarning>
<head>
<script
Expand Down Expand Up @@ -120,6 +123,7 @@ export default function RootLayout({
>
<MapContextProvider>
<MapLoadingProvider>
<UserSync />
<Header />
<ConditionalLottie />
{children}
Expand All @@ -139,5 +143,6 @@ export default function RootLayout({
<SpeedInsights />
</body>
</html>
</ClerkProvider>
)
}
27 changes: 27 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 4 additions & 5 deletions components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ import { cn } from '@/lib/utils'
import HistoryContainer from './history-container'
import { Button } from '@/components/ui/button'
import {
Search,
CircleUserRound,
Map,
CalendarDays,
TentTree
} from 'lucide-react'
Expand Down Expand Up @@ -61,7 +58,10 @@ export const Header = () => {
className="h-5 w-auto"
/>
</Button>
<h1 className="text-2xl font-poppins font-semibold text-primary">
<h1
className="text-2xl font-poppins font-semibold text-primary cursor-pointer select-none"
onClick={toggleHistory}
>
QCX
</h1>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
Expand All @@ -88,7 +88,6 @@ export const Header = () => {

{/* Mobile menu buttons */}
<div className="flex md:hidden gap-2">

<Button variant="ghost" size="icon" onClick={handleUsageToggle}>
<TentTree className="h-[1.2rem] w-[1.2rem]" />
</Button>
Expand Down
56 changes: 45 additions & 11 deletions components/profile-toggle.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
'use client'
import { useState, useEffect } from "react"
import { User, Settings, Paintbrush, Shield, CircleUserRound } from "lucide-react"
import { User, Settings, Shield, CircleUserRound, LogOut } from "lucide-react"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { ProfileToggleEnum, useProfileToggle } from "./profile-toggle-context"
import { useUsageToggle } from "./usage-toggle-context"
import { useClerk, useUser, SignInButton } from "@clerk/nextjs"
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"

export function ProfileToggle() {
const { toggleProfileSection, activeView } = useProfileToggle()
const { isUsageOpen, closeUsage } = useUsageToggle()
const [alignValue, setAlignValue] = useState<'start' | 'end'>("end")
const [isMobile, setIsMobile] = useState(false)

// Call hooks unconditionally
const { isLoaded, isSignedIn, user } = useUser()
const { signOut } = useClerk()

useEffect(() => {
const handleResize = () => {
const mobile = window.innerWidth < 768
setIsMobile(mobile)
if (mobile) {
setAlignValue("start") // Right align on mobile too
setAlignValue("start")
} else {
setAlignValue("start") // Right align on desktop
setAlignValue("start")
}
Comment on lines 25 to 29

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Redundant branch: both paths set "start".

The if (mobile) … else … sets alignValue to "start" in both branches, so the conditional is dead code and the initial "end" state is never actually used. Either drop the conditional or set the intended distinct value per branch.

Proposed change
       const mobile = window.innerWidth < 768
       setIsMobile(mobile)
-      if (mobile) {
-        setAlignValue("start")
-      } else {
-        setAlignValue("start")
-      }
+      setAlignValue("start")
📝 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
if (mobile) {
setAlignValue("start") // Right align on mobile too
setAlignValue("start")
} else {
setAlignValue("start") // Right align on desktop
setAlignValue("start")
}
const mobile = window.innerWidth < 768
setIsMobile(mobile)
setAlignValue("start")
🤖 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/profile-toggle.tsx` around lines 24 - 28, The conditional in the
ProfileToggle alignment logic is redundant because both the mobile and
non-mobile branches in the effect/handler set alignValue to "start", so the
initial "end" state is never used. Update the logic around the alignValue setter
in ProfileToggle to either remove the dead if/else entirely and set the value
directly, or assign the intended distinct value for each branch so the state
change actually depends on mobile.

}
handleResize() // Set initial value
handleResize()

let resizeTimer: NodeJS.Timeout;
const debouncedResize = () => {
Expand All @@ -35,17 +41,34 @@ export function ProfileToggle() {
}, [])

const handleSectionToggle = (section: ProfileToggleEnum) => {
// If we're about to open a profile section and usage is open, close usage first
if (activeView !== section && isUsageOpen) {
closeUsage()
}
toggleProfileSection(section)
}

const handleSignOut = () => {
signOut(() => {
window.location.href = "/"
})
}
Comment on lines +50 to +54

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Clerk useClerk signOut redirectUrl vs callback signature

💡 Result:

In the Clerk authentication library, redirectUrl is the current, standard approach for handling redirection after a user signs out [1][2]. It replaced the older signOutCallback signature, which had previously allowed developers to pass a function to execute after the sign-out process [1]. Key differences and context: 1. redirectUrl (Current Standard): This is a property within the SignOutOptions object passed to the signOut function [3][4][5]. It specifies the destination path or URL to which the user should be redirected automatically after a successful sign-out [6][3][7]. This approach is consistent across Clerk's UI components (like SignOutButton) and the programmatic signOut method [2]. 2. signOutCallback (Deprecated/Legacy): Previously, some Clerk components and methods accepted a callback function (e.g., => { window.location.href = '/path' }) to handle post-sign-out logic [1]. This has been largely deprecated in favor of the declarative redirectUrl option to improve API consistency and simplify usage [1][2]. If you are currently implementing or migrating a sign-out flow, you should use the redirectUrl property within the options object [6][3]: // Programmatic sign-out await signOut({ redirectUrl: '/home' }); // Using SignOutButton component Sign Out For multi-session applications, you can also include a sessionId within the same options object to sign out of a specific session while still utilizing the redirectUrl for navigation [6][3][5].

Citations:


🏁 Script executed:

set -euo pipefail

printf '\n== package references ==\n'
rg -n '"`@clerk/nextjs`"|`@clerk/nextjs`' package.json pnpm-lock.yaml package-lock.json yarn.lock **/package.json 2>/dev/null || true

printf '\n== profile-toggle.tsx context ==\n'
if [ -f components/profile-toggle.tsx ]; then
  cat -n components/profile-toggle.tsx | sed -n '1,120p'
fi

printf '\n== repo signOut usages ==\n'
rg -n 'signOut\(' . --glob '!node_modules' --glob '!dist' --glob '!build' --glob '!coverage' || true

Repository: QueueLab/QCX

Length of output: 1947


🌐 Web query:

Clerk 7.5.13 signOut callback redirectUrl options signature

💡 Result:

In Clerk version 7.5.13, the signOut method and associated components like SignOutButton use a redirectUrl option to control navigation after a successful sign-out [1][2]. The signOut method accepts an optional object with the following signature [1][2]: signOut(options?: { redirectUrl?: string; sessionId?: string }): Promise Parameters: - redirectUrl (string, optional): The full URL or path to navigate to after the sign-out process is complete [3][1][2]. - sessionId (string, optional): The ID of a specific session to sign out of, which is particularly useful for multi-session applications [3][1][2]. For UI components like SignOutButton, the redirectUrl prop is used directly to achieve the same result [3][4]. Note that older versions of Clerk components previously used a signOutCallback prop, which has been replaced by redirectUrl to align with Clerk's standard API surface [5]. If you are seeing a redirect_url query parameter appended to your URL after signing out, this is often part of Clerk's internal redirect handling for protected routes and post-authentication flows [6].

Citations:


Use Clerk’s redirectUrl option here signOut in @clerk/nextjs 7.5.13 takes an options object, so switch to signOut({ redirectUrl: "/" }) and let Clerk handle the redirect instead of setting window.location.href.

🤖 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/profile-toggle.tsx` around lines 49 - 53, The sign-out flow in
handleSignOut is doing a manual browser redirect instead of using Clerk’s
built-in redirect support. Update the signOut call in the ProfileToggle
component to pass the redirectUrl option directly and remove the
window.location.href callback so Clerk handles navigation after sign-out.


const ProfileIcon = () => {
if (isLoaded && isSignedIn && user?.imageUrl) {
return (
<Avatar className="h-[1.2rem] w-[1.2rem]">
<AvatarImage src={user.imageUrl} alt={user.fullName || 'User'} />
<AvatarFallback><CircleUserRound className="h-full w-full" /></AvatarFallback>
</Avatar>
)
}
return <CircleUserRound className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" />
}

if (isMobile) {
return (
<Button variant="ghost" size="icon" className="relative" data-testid="profile-toggle" disabled>
<CircleUserRound className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" />
<ProfileIcon />
<span className="sr-only">Open profile menu</span>
</Button>
)
Expand All @@ -55,7 +78,7 @@ export function ProfileToggle() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative" data-testid="profile-toggle">
<CircleUserRound className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" />
<ProfileIcon />
<span className="sr-only">Open profile menu</span>
</Button>
</DropdownMenuTrigger>
Expand All @@ -68,14 +91,25 @@ export function ProfileToggle() {
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSectionToggle(ProfileToggleEnum.Appearance)} data-testid="profile-appearance">
<Paintbrush className="mr-2 h-4 w-4" />
<span>Appearance</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSectionToggle(ProfileToggleEnum.Security)} data-testid="profile-security">
<Shield className="mr-2 h-4 w-4" />
<span>Security</span>
</DropdownMenuItem>

{isLoaded && isSignedIn && (
<DropdownMenuItem onClick={handleSignOut} className="text-destructive">
<LogOut className="mr-2 h-4 w-4" />
<span>Sign out</span>
</DropdownMenuItem>
)}
{isLoaded && !isSignedIn && (
<SignInButton mode="modal">
<DropdownMenuItem>
<LogOut className="mr-2 h-4 w-4" />
<span>Sign in</span>
</DropdownMenuItem>
</SignInButton>
Comment on lines +105 to +111

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Clerk SignInButton mode modal wrapping custom child asChild onClick

💡 Result:

To use the Clerk SignInButton component with a custom child, the mode set to modal, and an onClick event, you should wrap your custom element in the SignInButton component and configure it as follows [1][2]. When you provide a custom child (such as a custom or

) to SignInButton, you should generally include the asChild prop [2]. The asChild prop tells the component to merge its functionality into the provided child element rather than rendering a wrapper element [2]. For the modal functionality, ensure the mode prop is set to 'modal' [1][3][4]. Regarding the onClick event, because the SignInButton handles the click to trigger the modal, adding an onClick handler to your child element may conflict with Clerk's internal event handling depending on your implementation. If you need custom behavior alongside the modal trigger, you can often define a wrapper function for the onClick event, though it is usually recommended to rely on Clerk's built-in handling to ensure the authentication flow is not interrupted [1][2]. Example implementation: <button onClick={(e) => console.log('Custom logic', e)}> Sign In
Key points: - Mode: Use mode="modal" to open the sign-in interface in a modal instead of redirecting [1][3][4]. - asChild: Set asChild={true} (or simply asChild) to render the SignInButton's behavior onto your provided child component [2]. - Child constraints: The component accepts only one child; if you have multiple elements, wrap them in a single parent (e.g., a

or ) [1][2][3]. - Event handling: When using asChild, Clerk merges its event listeners with your child component's props [2]. Ensure any custom onClick logic does not call event.stopPropagation if you intend for Clerk's sign-in trigger to execute [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== profile-toggle.tsx ==\n'
sed -n '1,180p' components/profile-toggle.tsx

printf '\n== SignInButton usage ==\n'
rg -n "SignInButton|DropdownMenuItem|LogOut|LogIn" components src . -g '!**/node_modules/**'

Repository: QueueLab/QCX

Length of output: 1947


🌐 Web query:

Radix DropdownMenuItem onSelect closes menu before child onClick SignInButton modal

💡 Result:

In Radix UI, the DropdownMenuItem component is designed to close the menu automatically upon selection [1]. When you nest an interactive element (like a SignInButton triggering a modal) inside the item, the Radix menu's built-in closing logic often triggers before or during your child component's interaction, causing conflicts such as immediate closure, loss of focus, or pointer event issues [2][3][4][5]. To resolve this, you generally have two primary approaches: 1. Recommended: Use a Controlled Dialog Pattern (Avoid Nested Overlays) Instead of placing the modal trigger directly inside the menu, keep your modal's state at a parent level [6]. - Use the DropdownMenuItem's onSelect event to update a state variable that controls the modal's open state [6]. - Do not call event.preventDefault in this case; let the menu close normally, and then open the modal based on your state [6]. This ensures proper focus management and avoids conflict between the two portals [6][5]. 2. Alternative: Prevent Menu Closure (for specific use cases) If you must keep the menu open (e.g., for multi-step interactions or specific custom behavior), you can prevent the default closing behavior [7][8]. - Inside your DropdownMenuItem, use onSelect={(e) => e.preventDefault} [7][8]. - This keeps the menu open, allowing your child button's onClick event to fire independently [7]. - Note: This approach requires you to manually manage the menu's closing logic later, as it will no longer close automatically upon selection [7][5]. Common pitfalls to avoid: - Focus Traps/Modal Mode: By default, Radix DropdownMenu uses modal behavior, which can interfere with other overlays [4]. If you experience issues with pointer events or immediate closure, setting modal={false} on the DropdownMenu.Root can sometimes resolve these conflicts, though the state-controlled pattern is generally more robust [4][5]. - Event Propagation: If your child's click event is not firing, ensure that you are not accidentally stopping propagation in a way that interferes with Radix's internal event handling [4][9]. Conversely, if the menu is closing due to bubbling, stopPropagation on the child's onClick can sometimes help [9].

Citations:


🌐 Web query:

Clerk SignInButton asChild custom child modal docs onClick

💡 Result:

To use a custom child element with the Clerk SignInButton component while maintaining control over the click behavior and triggering a modal, you should use the asChild prop [1][2][3]. The asChild prop is the recommended approach for customizing unstyled components like SignInButton [3]. When set to true, it instructs the component to render its children as the underlying element rather than creating a new container (like an extra button or div), effectively merging the Clerk functionality (like the onClick handler) onto your custom element [2][3]. To use it with a modal: 1. Wrap your custom trigger (e.g., a button or a custom styled element) inside the SignInButton component. 2. Set the mode prop to 'modal' [1][4][5]. 3. Add the asChild prop to the SignInButton component [2]. Example:

Sign In Key points: - Mode: Setting mode="modal" ensures the sign-in flow opens as a modal instead of redirecting the user to a separate page [1][2][5]. - Children: The component requires a single child; if you need to render multiple elements, ensure they are wrapped in a single parent element (such as a div or a fragment) [1][2][4]. - Deprecation: The older 'as' prop has been deprecated and replaced by 'asChild' [3]. Using asChild allows for better composition and accessibility by ensuring the Clerk logic is applied directly to your intended UI element [3].

Citations:


Move the sign-in trigger out of the dropdown item and swap the icon. DropdownMenuItem closes on selection, so this nested Clerk modal trigger can interfere with opening the sign-in flow; use asChild/onSelect handling instead. Also replace LogOut with LogIn on the sign-in entry.

🤖 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/profile-toggle.tsx` around lines 96 - 102, The sign-in entry in
profile-toggle currently nests SignInButton inside DropdownMenuItem, which can
conflict with the menu closing behavior; update the dropdown action to trigger
Clerk via asChild or explicit onSelect handling instead of nesting the modal
trigger. Also change the icon used for the sign-in action from LogOut to LogIn
so the UI matches the action, and keep the implementation localized around the
SignInButton and DropdownMenuItem render path.

)}
</DropdownMenuContent>
</DropdownMenu>
)
Expand Down
20 changes: 20 additions & 0 deletions components/user-sync.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use client';

import { useEffect } from 'react';
import { useUser } from '@clerk/nextjs';
import { syncUser } from '@/lib/actions/sync-user';

/**
* Client component that triggers user synchronization on mount if authenticated.
*/
export function UserSync() {
const { isLoaded, isSignedIn } = useUser();

useEffect(() => {
if (isLoaded && isSignedIn) {
syncUser();
}
}, [isLoaded, isSignedIn]);
Comment on lines +13 to +17

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 | 🟡 Minor | ⚡ Quick win

Handle the syncUser() rejection.

The server action result is neither awaited nor caught; a failed sync surfaces as an unhandled promise rejection. Add a .catch to log and swallow it.

♻️ Suggested change
-    if (isLoaded && isSignedIn) {
-      syncUser();
-    }
+    if (isLoaded && isSignedIn) {
+      syncUser().catch((err) => console.error('[UserSync] sync failed:', err));
+    }
📝 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
useEffect(() => {
if (isLoaded && isSignedIn) {
syncUser();
}
}, [isLoaded, isSignedIn]);
useEffect(() => {
if (isLoaded && isSignedIn) {
syncUser().catch((err) => console.error('[UserSync] sync failed:', err));
}
}, [isLoaded, isSignedIn]);
🤖 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/user-sync.tsx` around lines 13 - 17, The sync trigger in the
UserSync effect calls syncUser() without handling its promise, so a failure can
become an unhandled rejection. Update the useEffect in UserSync to attach a
.catch to the syncUser() call, and in that handler log the error and swallow it
so the effect does not leak a rejection.


return null;
}
Loading