user-menu-button.tsx334 lines · main
1'use client';
2
3import { useRouter } from 'next/navigation';
4import { useEffect, useRef, useState } from 'react';
5
6import { BookOpenIcon, type BookOpenIconHandle } from './ui/book-open';
7import { ChevronsUpDownIcon, type ChevronsUpDownIconHandle } from './ui/chevrons-up-down';
8import { GlobeIcon, type GlobeIconHandle } from './ui/globe';
9import { LogOutIcon, type LogOutIconHandle } from './ui/log-out';
10import { ShieldCheckIcon, type ShieldCheckIconHandle } from './ui/shield-check';
11
12interface UserInfo {
13 name: string | null;
14 email: string;
15 image: string | null;
16 legalName: string | null;
17}
18
19interface Props {
20 user: UserInfo;
21 collapsed: boolean;
22 isAdmin?: boolean;
23 /** Which way the dropdown opens: 'up' (sidebar footer) or 'down' (admin header). */
24 placement?: 'up' | 'down';
25 /** 'admin' (the admin header) swaps the website row for a dashboard link
26 * and drops the redundant admin row. */
27 variant?: 'default' | 'admin';
28}
29
30/**
31 * Sidebar-anchored user menu. Collapses to a bare avatar when the sidebar
32 * is collapsed; expands to avatar + name + email + chevron when open.
33 * Dropdown opens upward with a website/docs link and sign-out.
34 *
35 * why: the user's own email appears only to themselves within their own
36 * authenticated sidebar — the same trust boundary as CLAUDE.md §5.1's
37 * carve-out for the Settings/Account page.
38 *
39 * Icon animations are driven from each menu row's hover state via the
40 * component handle — hovering anywhere in the row (icon, label, padding)
41 * triggers the animation, not just the icon's own 16px surface.
42 */
43export function UserMenuButton({
44 user,
45 collapsed,
46 isAdmin = false,
47 placement = 'up',
48 variant = 'default',
49}: Props) {
50 const router = useRouter();
51 const [open, setOpen] = useState(false);
52 const [signingOut, setSigningOut] = useState(false);
53 const containerRef = useRef<HTMLDivElement>(null);
54 const iconRef = useRef<ChevronsUpDownIconHandle>(null);
55 const [hover, setHover] = useState(false);
56
57 const displayName = user.legalName ?? user.name ?? user.email.split('@')[0]!;
58
59 useEffect(() => {
60 if (!iconRef.current) return;
61 if (hover) iconRef.current.startAnimation();
62 else iconRef.current.stopAnimation();
63 }, [hover]);
64
65 useEffect(() => {
66 if (!open) return;
67 function onDown(e: MouseEvent) {
68 if (!containerRef.current?.contains(e.target as Node)) setOpen(false);
69 }
70 function onKey(e: KeyboardEvent) {
71 if (e.key === 'Escape') setOpen(false);
72 }
73 document.addEventListener('mousedown', onDown);
74 document.addEventListener('keydown', onKey);
75 return () => {
76 document.removeEventListener('mousedown', onDown);
77 document.removeEventListener('keydown', onKey);
78 };
79 }, [open]);
80
81 async function signOut() {
82 setSigningOut(true);
83 try {
84 await fetch('/api/v1/auth/sign-out', {
85 method: 'POST',
86 credentials: 'include',
87 });
88 } finally {
89 window.location.href = '/signin';
90 }
91 }
92
93 return (
94 <div ref={containerRef} className={collapsed ? 'relative' : 'relative min-w-0 flex-1'}>
95 <button
96 type="button"
97 onClick={() => setOpen((v) => !v)}
98 onMouseEnter={() => setHover(true)}
99 onMouseLeave={() => setHover(false)}
100 onFocus={() => setHover(true)}
101 onBlur={() => setHover(false)}
102 aria-haspopup="menu"
103 aria-expanded={open}
104 aria-label={collapsed ? `account menu for ${displayName}` : undefined}
105 title={collapsed ? displayName : undefined}
106 className={
107 collapsed
108 ? 'flex size-8 items-center justify-center rounded-md border border-[var(--color-border-subtle)] transition-colors hover:border-[var(--color-border)]'
109 : 'flex h-12 w-full items-center gap-2 rounded-md border border-[var(--color-border-subtle)] px-3 text-left transition-colors hover:border-[var(--color-border)]'
110 }
111 >
112 <Avatar user={user} size={collapsed ? 20 : 28} />
113 {!collapsed && (
114 <>
115 <span className="flex min-w-0 flex-1 flex-col leading-tight">
116 <span className="truncate text-xs font-medium text-[var(--color-text)]">
117 {displayName}
118 </span>
119 <span className="truncate font-mono text-[10px] text-[var(--color-text-subtle)]">
120 {user.email}
121 </span>
122 </span>
123 <span className="pointer-events-none text-[var(--color-text-muted)]">
124 <ChevronsUpDownIcon ref={iconRef} size={14} />
125 </span>
126 </>
127 )}
128 </button>
129
130 {open && (
131 <div
132 role="menu"
133 className={`absolute left-0 z-50 w-60 overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] shadow-[var(--shadow-lg)] ${
134 placement === 'down' ? 'top-full mt-2' : 'bottom-full mb-2'
135 }`}
136 >
137 <div className="flex items-center gap-3 border-b border-[var(--color-border-subtle)] px-3 py-3">
138 <Avatar user={user} size={32} />
139 <div className="flex min-w-0 flex-1 flex-col leading-tight">
140 <span className="truncate text-sm font-medium text-[var(--color-text)]">
141 {displayName}
142 </span>
143 <span className="truncate font-mono text-[11px] text-[var(--color-text-subtle)]">
144 {user.email}
145 </span>
146 </div>
147 </div>
148 <ul className="p-1">
149 <li>
150 {/* In the admin header this row pivots back to the user dashboard —
151 as an ABSOLUTE link, because the admin cockpit lives on
152 admin.briven.tech where a relative /dashboard 404s. Everywhere
153 else it links out to the public website. */}
154 {variant === 'admin' ? (
155 <MenuRow
156 as="a"
157 href="https://briven.tech/dashboard"
158 onSelect={() => setOpen(false)}
159 icon={GlobeIcon}
160 label="dashboard"
161 />
162 ) : (
163 <MenuRow
164 as="button"
165 onSelect={() => {
166 setOpen(false);
167 router.push('/');
168 }}
169 icon={GlobeIcon}
170 label="website"
171 />
172 )}
173 </li>
174 <li>
175 <MenuRow
176 as="a"
177 href="https://docs.briven.tech"
178 target="_blank"
179 rel="noreferrer"
180 onSelect={() => setOpen(false)}
181 icon={BookOpenIcon}
182 label="docs"
183 />
184 </li>
185 {isAdmin && variant !== 'admin' && (
186 <li>
187 <MenuRow
188 as="a"
189 href="https://admin.briven.tech"
190 onSelect={() => setOpen(false)}
191 icon={ShieldCheckIcon}
192 label="admin"
193 />
194 </li>
195 )}
196 <li>
197 <MenuRow
198 as="button"
199 onSelect={signOut}
200 disabled={signingOut}
201 icon={LogOutIcon}
202 label={signingOut ? 'signing out…' : 'log out'}
203 destructive
204 />
205 </li>
206 </ul>
207 </div>
208 )}
209 </div>
210 );
211}
212
213type IconHandle = GlobeIconHandle | BookOpenIconHandle | LogOutIconHandle | ShieldCheckIconHandle;
214type IconComponent = typeof GlobeIcon | typeof BookOpenIcon | typeof LogOutIcon;
215
216interface MenuRowBaseProps {
217 onSelect?: () => void;
218 icon: IconComponent;
219 label: string;
220 destructive?: boolean;
221 disabled?: boolean;
222}
223type MenuRowProps =
224 | (MenuRowBaseProps & {
225 as: 'a';
226 href: string;
227 target?: string;
228 rel?: string;
229 })
230 | (MenuRowBaseProps & {
231 as: 'button';
232 });
233
234/**
235 * Single dropdown row. Owns the hover state so the icon's animation starts
236 * the moment the cursor enters the row (not just the icon's 16px box).
237 */
238function MenuRow(props: MenuRowProps) {
239 const { icon: Icon, label, destructive, onSelect, disabled } = props;
240 const ref = useRef<IconHandle>(null);
241 const [hover, setHover] = useState(false);
242
243 useEffect(() => {
244 if (!ref.current) return;
245 if (hover && !disabled) ref.current.startAnimation();
246 else ref.current.stopAnimation();
247 }, [hover, disabled]);
248
249 const base = `flex w-full items-center gap-3 rounded px-3 py-2 font-mono text-sm transition-colors ${
250 destructive
251 ? 'text-[var(--color-error)] hover:bg-[var(--color-surface-overlay)]'
252 : 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-overlay)] hover:text-[var(--color-text)]'
253 } disabled:opacity-50`;
254
255 const sharedHandlers = {
256 onMouseEnter: () => setHover(true),
257 onMouseLeave: () => setHover(false),
258 onFocus: () => setHover(true),
259 onBlur: () => setHover(false),
260 };
261
262 const iconNode = (
263 <span className="pointer-events-none">
264 <Icon ref={ref as never} size={16} />
265 </span>
266 );
267
268 if (props.as === 'a') {
269 return (
270 <a
271 href={props.href}
272 target={props.target}
273 rel={props.rel}
274 role="menuitem"
275 onClick={onSelect}
276 className={base}
277 {...sharedHandlers}
278 >
279 {iconNode}
280 {label}
281 </a>
282 );
283 }
284 return (
285 <button
286 type="button"
287 role="menuitem"
288 onClick={onSelect}
289 disabled={disabled}
290 className={base}
291 {...sharedHandlers}
292 >
293 {iconNode}
294 {label}
295 </button>
296 );
297}
298
299function Avatar({ user, size }: { user: UserInfo; size: number }) {
300 const initials = getInitials(user.legalName ?? user.name ?? user.email);
301 if (user.image) {
302 return (
303 <img
304 src={user.image}
305 alt=""
306 width={size}
307 height={size}
308 className="shrink-0 rounded-full object-cover"
309 style={{ width: size, height: size }}
310 />
311 );
312 }
313 return (
314 <span
315 aria-hidden
316 className="flex shrink-0 items-center justify-center rounded-full bg-[var(--color-primary-subtle)] font-mono text-[var(--color-primary)]"
317 style={{ width: size, height: size, fontSize: Math.round(size * 0.42) }}
318 >
319 {initials}
320 </span>
321 );
322}
323
324function getInitials(source: string): string {
325 const cleaned = source.trim();
326 if (!cleaned) return '·';
327 const parts = cleaned.includes('@') ? [cleaned.split('@')[0]!] : cleaned.split(/\s+/);
328 const letters = parts
329 .slice(0, 2)
330 .map((p) => p[0])
331 .filter(Boolean)
332 .join('');
333 return (letters || cleaned[0] || '·').toUpperCase();
334}