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:
16
app/api/auth/logout/route.ts
Normal file
16
app/api/auth/logout/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
59
app/api/user/profile/route.ts
Normal file
59
app/api/user/profile/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useRef, useState, useEffect } from 'react';
|
import { useRef, useState, useEffect } from 'react';
|
||||||
import { MicrophoneRecorder } from '@/components/MicrophoneRecorder';
|
import { MicrophoneRecorder } from '@/components/MicrophoneRecorder';
|
||||||
|
import { UserMenu } from '@/components/UserMenu';
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const viewport = useRef<HTMLDivElement>(null);
|
const viewport = useRef<HTMLDivElement>(null);
|
||||||
@@ -86,15 +87,18 @@ export default function ChatPage() {
|
|||||||
<Title order={2}>
|
<Title order={2}>
|
||||||
Ponderants Interview
|
Ponderants Interview
|
||||||
</Title>
|
</Title>
|
||||||
<Tooltip label="Start a new conversation">
|
<Group gap="md">
|
||||||
<Button
|
<Tooltip label="Start a new conversation">
|
||||||
variant="subtle"
|
<Button
|
||||||
onClick={handleNewConversation}
|
variant="subtle"
|
||||||
disabled={status === 'submitted' || status === 'streaming'}
|
onClick={handleNewConversation}
|
||||||
>
|
disabled={status === 'submitted' || status === 'streaming'}
|
||||||
New Conversation
|
>
|
||||||
</Button>
|
New Conversation
|
||||||
</Tooltip>
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<UserMenu />
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
|
|||||||
99
components/UserMenu.tsx
Normal file
99
components/UserMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user