Skip to content
Closed
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
8 changes: 7 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getCurrentUserId } from '@/lib/auth/get-current-user'
import { UserProvider } from '@/lib/contexts/user-context'
import { createClient } from '@/lib/supabase/server'
import { cn } from '@/lib/utils'
import { getGravatarUrl } from '@/lib/utils/gravatar.server'

import { SidebarProvider } from '@/components/ui/sidebar'
import { Toaster } from '@/components/ui/sonner'
Expand Down Expand Up @@ -57,6 +58,7 @@ export default async function RootLayout({
children: React.ReactNode
}>) {
let user = null
let avatarUrl: string | undefined
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY

Expand All @@ -66,6 +68,10 @@ export default async function RootLayout({
data: { user: supabaseUser }
} = await supabase.auth.getUser()
user = supabaseUser
avatarUrl =
user?.user_metadata?.avatar_url ||
user?.user_metadata?.picture ||
(user?.email ? getGravatarUrl(user.email) : undefined)
}

const userId = user?.id ?? (await getCurrentUserId())
Expand All @@ -89,7 +95,7 @@ export default async function RootLayout({
{userId && <AppSidebar />}
<KeyboardShortcutHandler />
<div className="flex flex-col flex-1 min-w-0">
<Header user={user} />
<Header user={user} avatarUrl={avatarUrl} />
<main className="flex flex-1 min-h-0 min-w-0 overflow-hidden">
<ArtifactRoot>{children}</ArtifactRoot>
</main>
Expand Down
9 changes: 7 additions & 2 deletions components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ import UserMenu from './user-menu'

interface HeaderProps {
user: User | null
avatarUrl?: string
}

export const Header: React.FC<HeaderProps> = ({ user }) => {
export const Header: React.FC<HeaderProps> = ({ user, avatarUrl }) => {
const { open } = useSidebar()
const pathname = usePathname()
const [feedbackOpen, setFeedbackOpen] = useState(false)
Expand Down Expand Up @@ -48,7 +49,11 @@ export const Header: React.FC<HeaderProps> = ({ user }) => {
Feedback
</Button>
)}
{user ? <UserMenu user={user} /> : <GuestMenu />}
{user ? (
<UserMenu user={user} avatarUrl={avatarUrl} />
) : (
<GuestMenu />
)}
</div>
</header>

Expand Down
5 changes: 2 additions & 3 deletions components/user-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,13 @@ import { ThemeMenuItems } from './theme-menu-items'

interface UserMenuProps {
user: User
avatarUrl?: string
}

export default function UserMenu({ user }: UserMenuProps) {
export default function UserMenu({ user, avatarUrl }: UserMenuProps) {
const router = useRouter()
const userName =
user.user_metadata?.full_name || user.user_metadata?.name || 'User'
const avatarUrl =
user.user_metadata?.avatar_url || user.user_metadata?.picture

const getInitials = (name: string, email: string | undefined) => {
if (name && name !== 'User') {
Expand Down
11 changes: 10 additions & 1 deletion hooks/use-current-user-image.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'

import { createClient } from '@/lib/supabase/client'
import { getGravatarUrl } from '@/lib/utils/gravatar'

export const useCurrentUserImage = () => {
const [image, setImage] = useState<string | null>(null)
Expand All @@ -12,7 +13,15 @@ export const useCurrentUserImage = () => {
if (error) {
console.error(error)
}
setImage(data.session?.user.user_metadata.avatar_url ?? null)
const avatarUrl =
data.session?.user.user_metadata.avatar_url ||
data.session?.user.user_metadata.picture
if (avatarUrl) {
setImage(avatarUrl)
} else if (data.session?.user.email) {
const gravatarUrl = await getGravatarUrl(data.session.user.email)
setImage(gravatarUrl)
}
} catch (error) {
// Supabase not configured, skip silently
}
Expand Down
39 changes: 39 additions & 0 deletions lib/utils/__tests__/gravatar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'

import { getGravatarUrl } from '../gravatar'

describe('getGravatarUrl', () => {
it('generates a valid Gravatar URL with SHA-256 hash', async () => {
const url = await getGravatarUrl('test@example.com')
expect(url).toMatch(
/^https:\/\/www\.gravatar\.com\/avatar\/[a-f0-9]{64}\?d=404$/
)
})

it('normalizes email by trimming and lowercasing', async () => {
const url1 = await getGravatarUrl('Test@Example.com')
const url2 = await getGravatarUrl(' test@example.com ')
const url3 = await getGravatarUrl('test@example.com')
expect(url1).toBe(url3)
expect(url2).toBe(url3)
})

it('produces different hashes for different emails', async () => {
const url1 = await getGravatarUrl('alice@example.com')
const url2 = await getGravatarUrl('bob@example.com')
expect(url1).not.toBe(url2)
})

it('generates correct hash for a known email', async () => {
// SHA-256 of "miurap400@gmail.com" can be verified independently
const url = await getGravatarUrl('miurap400@gmail.com')
expect(url).toContain('https://www.gravatar.com/avatar/')
expect(url).toContain('?d=404')

// Extract hash and verify it's a valid 64-char hex string
const hash = url
.replace('https://www.gravatar.com/avatar/', '')
.replace('?d=404', '')
expect(hash).toMatch(/^[a-f0-9]{64}$/)
})
})
13 changes: 13 additions & 0 deletions lib/utils/gravatar.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import crypto from 'crypto'

/**
* Generate a Gravatar URL from an email address (server-side, synchronous).
* Uses Node.js crypto for SHA-256 hashing.
*/
export function getGravatarUrl(email: string): string {
const hash = crypto
.createHash('sha256')
.update(email.trim().toLowerCase())
.digest('hex')
return `https://www.gravatar.com/avatar/${hash}?d=404`
}
17 changes: 17 additions & 0 deletions lib/utils/gravatar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Generate a Gravatar URL from an email address.
* Uses SHA-256 hashing via Web Crypto API (async, for client-side use).
* Returns the URL with `d=404` so that a 404 is returned for unregistered emails,
* allowing the UI to fall back to initials or a placeholder icon.
*/
export async function getGravatarUrl(email: string): Promise<string> {
const normalized = email.trim().toLowerCase()
const hashBuffer = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(normalized)
)
const hashHex = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
return `https://www.gravatar.com/avatar/${hashHex}?d=404`
}
Loading