Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 8 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,13 @@ 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
if (avatarUrl) {
setImage(avatarUrl)
} else if (data.session?.user.email) {
const gravatarUrl = await getGravatarUrl(data.session.user.email)
Comment thread
miurla marked this conversation as resolved.
Outdated
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