feat: add user dropdown menu with sign-out to top bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-22 03:59:28 -08:00
parent 89955f6db8
commit a7c6aae8c6
1 changed files with 90 additions and 6 deletions

View File

@ -1,10 +1,53 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useTenantStore } from '@/stores/zustand/tenant-store';
import { Search, Bell } from 'lucide-react';
import { Search, Bell, LogOut, User, Settings } from 'lucide-react';
interface StoredUser {
id: string;
email: string;
name: string;
roles: string[];
}
export function TopBar() {
const router = useRouter();
const { currentTenant } = useTenantStore();
const [menuOpen, setMenuOpen] = useState(false);
const [user, setUser] = useState<StoredUser | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
try {
const raw = localStorage.getItem('user');
if (raw) setUser(JSON.parse(raw));
} catch { /* ignore */ }
}, []);
// Close menu on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
}
if (menuOpen) document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [menuOpen]);
const handleLogout = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
localStorage.removeItem('current_tenant');
router.replace('/login');
};
const initials = user?.name
? user.name.split(' ').map((w) => w[0]).join('').toUpperCase().slice(0, 2)
: 'U';
return (
<header className="h-12 border-b bg-card/50 backdrop-blur-sm flex items-center justify-between px-4 shrink-0">
@ -37,13 +80,54 @@ export function TopBar() {
{/* Tenant indicator */}
<div className="text-xs">
<span className="text-muted-foreground">Tenant: </span>
<span className="font-medium">{currentTenant?.name || 'Not selected'}</span>
<span className="font-medium">{currentTenant?.name || currentTenant?.id || 'Not selected'}</span>
</div>
{/* User avatar */}
<button className="w-7 h-7 rounded-full bg-primary/15 border border-primary/20 flex items-center justify-center text-xs font-semibold text-primary hover:bg-primary/25 transition-colors">
A
</button>
{/* User avatar + dropdown */}
<div className="relative" ref={menuRef}>
<button
onClick={() => setMenuOpen(!menuOpen)}
className="w-7 h-7 rounded-full bg-primary/15 border border-primary/20 flex items-center justify-center text-xs font-semibold text-primary hover:bg-primary/25 transition-colors"
>
{initials}
</button>
{menuOpen && (
<div className="absolute right-0 top-full mt-1.5 w-56 bg-card border rounded-lg shadow-lg py-1 z-50">
{/* User info */}
<div className="px-3 py-2 border-b">
<p className="text-sm font-medium truncate">{user?.name || 'User'}</p>
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
</div>
{/* Menu items */}
<button
onClick={() => { setMenuOpen(false); router.push('/settings'); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<Settings className="h-3.5 w-3.5" />
Settings
</button>
<button
onClick={() => { setMenuOpen(false); router.push('/users'); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<User className="h-3.5 w-3.5" />
Users
</button>
<div className="border-t my-1" />
<button
onClick={handleLogout}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-500 hover:bg-red-500/10 transition-colors"
>
<LogOut className="h-3.5 w-3.5" />
Sign out
</button>
</div>
)}
</div>
</div>
</header>
);