feat: Add user profile menu with logout functionality

Implemented user profile display in the chat interface:
- Created UserMenu component with avatar, handle display, and dropdown
- Added /api/user/profile endpoint to fetch user data from Bluesky
- Added /api/auth/logout endpoint to clear auth cookie
- Integrated UserMenu into chat page header
- Shows user's ATproto avatar with initials fallback
- Dropdown menu displays user info and logout option
- Fixed JWT import to use verifySurrealJwt

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-09 05:11:54 +00:00
parent b6d0cbd672
commit 2b3cd0c99b
4 changed files with 187 additions and 9 deletions

View File

@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
/**
* POST /api/auth/logout
*
* Logs out the current user by clearing the auth cookie
*/
export async function POST() {
const cookieStore = await cookies();
// Clear the auth cookie
cookieStore.delete('ponderants-auth');
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,59 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { verifySurrealJwt } from '@/lib/auth/jwt';
/**
* GET /api/user/profile
*
* Returns the current user's profile information from ATproto
*/
export async function GET() {
try {
// Check authentication
const cookieStore = await cookies();
const authCookie = cookieStore.get('ponderants-auth');
if (!authCookie) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Verify JWT and get DID
const payload = verifySurrealJwt(authCookie.value);
if (!payload) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const did = payload.did;
// Fetch the user's profile using the public Bluesky API
const response = await fetch(
`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`
);
if (!response.ok) {
console.error('[Profile API] Failed to fetch profile:', response.statusText);
// Return basic info if full profile fetch fails
return NextResponse.json({
did,
handle: did, // fallback to DID
displayName: null,
avatar: null,
});
}
const profile = await response.json();
return NextResponse.json({
did: profile.did,
handle: profile.handle,
displayName: profile.displayName || null,
avatar: profile.avatar || null,
description: profile.description || null,
});
} catch (error) {
console.error('[Profile API] Error:', error);
return NextResponse.json(
{ error: 'Failed to fetch profile' },
{ status: 500 }
);
}
}

View File

@@ -17,6 +17,7 @@ import {
} from '@mantine/core';
import { useRef, useState, useEffect } from 'react';
import { MicrophoneRecorder } from '@/components/MicrophoneRecorder';
import { UserMenu } from '@/components/UserMenu';
export default function ChatPage() {
const viewport = useRef<HTMLDivElement>(null);
@@ -86,15 +87,18 @@ export default function ChatPage() {
<Title order={2}>
Ponderants Interview
</Title>
<Tooltip label="Start a new conversation">
<Button
variant="subtle"
onClick={handleNewConversation}
disabled={status === 'submitted' || status === 'streaming'}
>
New Conversation
</Button>
</Tooltip>
<Group gap="md">
<Tooltip label="Start a new conversation">
<Button
variant="subtle"
onClick={handleNewConversation}
disabled={status === 'submitted' || status === 'streaming'}
>
New Conversation
</Button>
</Tooltip>
<UserMenu />
</Group>
</Group>
<ScrollArea

99
components/UserMenu.tsx Normal file
View File

@@ -0,0 +1,99 @@
'use client';
import { useState, useEffect } from 'react';
import { Menu, Avatar, UnstyledButton, Group, Text } from '@mantine/core';
import { useRouter } from 'next/navigation';
interface UserProfile {
did: string;
handle: string;
displayName: string | null;
avatar: string | null;
}
export function UserMenu() {
const router = useRouter();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch user profile on mount
fetch('/api/user/profile')
.then((res) => res.json())
.then((data) => {
if (!data.error) {
setProfile(data);
}
})
.catch((error) => {
console.error('Failed to fetch profile:', error);
})
.finally(() => {
setLoading(false);
});
}, []);
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
} catch (error) {
console.error('Logout failed:', error);
}
};
if (loading || !profile) {
return (
<Avatar radius="xl" size="md" color="gray">
?
</Avatar>
);
}
// Get display name or handle
const displayText = profile.displayName || profile.handle;
// Get initials for fallback
const initials = profile.displayName
? profile.displayName
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
: profile.handle.slice(0, 2).toUpperCase();
return (
<Menu shadow="md" width={200} position="bottom-end">
<Menu.Target>
<UnstyledButton>
<Group gap="xs">
<Avatar
src={profile.avatar}
alt={displayText}
radius="xl"
size="md"
style={{ cursor: 'pointer' }}
>
{initials}
</Avatar>
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>
<Text size="xs" fw={600}>
{displayText}
</Text>
<Text size="xs" c="dimmed">
@{profile.handle}
</Text>
</Menu.Label>
<Menu.Divider />
<Menu.Item onClick={handleLogout} c="red">
Log out
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}