Skip to content

Commit 3a0fa2f

Browse files
AppShell: clickable user menu with logout
The topbar avatar was a static <div> — `api.logout()` existed but no UI surface called it, leaving users unable to sign out from the dashboard. Replace the avatar with a UserMenu component: clicking the avatar opens a dropdown anchored bottom-right showing the user email, team + tier, an Account-settings link, and a Log-out button. Click- outside and Escape both close the dropdown. UserMenu only reads `me.user.email`, `me.team.name`, and `me.team.tier` so the just-landed `experiments` field on /auth/me (PR #40) is left untouched. Tests: - 6 new tests cover trigger render, open/close, click-outside, Escape, logout-then-navigate, and settings navigation. - npm test: 290 pass / 3 skipped (was 284 / 3) across 15 files. - npm run build: clean.
1 parent ca7f979 commit 3a0fa2f

3 files changed

Lines changed: 367 additions & 1 deletion

File tree

src/layout/AppShell.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useEffect, useState, type ReactNode } from 'react'
44
import { addEnv, setEnv, useDashboardCtx, type DashboardCtx } from '../hooks/useDashboardCtx'
55
import * as api from '../api'
66
import type { TeamSummary } from '../api'
7+
import { UserMenu } from './UserMenu'
78

89
type Scope = 'read' | 'write' | 'agent'
910

@@ -237,7 +238,7 @@ export function AppShell() {
237238
</div>
238239
<div className="topbar-tools">
239240
<ScopePill scope={meta.scope} />
240-
<div className="avatar" title={ctx.me?.user?.email ?? ''}>A</div>
241+
<UserMenu />
241242
</div>
242243
</header>
243244

src/layout/UserMenu.test.tsx

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/* UserMenu.test.tsx — coverage for the topbar avatar dropdown that lets
2+
* users log out of the dashboard. Bug context: there used to be no UI
3+
* surface that called api.logout() — the avatar was a static <div>. This
4+
* suite locks the new behaviour:
5+
*
6+
* 1. Trigger renders the avatar with the email's first letter.
7+
* 2. Clicking the trigger opens a dropdown (role="menu" visible).
8+
* 3. Clicking outside the dropdown closes it.
9+
* 4. Escape key closes the dropdown.
10+
* 5. "Log out" calls api.logout() + navigates to /login.
11+
* 6. "Account settings" navigates to /app/settings.
12+
*
13+
* Styling is intentionally not asserted — the layout tokens are expected
14+
* to evolve and we don't want to burn tests on every visual tweak. We
15+
* pin the behaviour with financial / auth consequences. */
16+
17+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
18+
import { render, screen, fireEvent, cleanup, act } from '@testing-library/react'
19+
import { MemoryRouter, Routes, Route } from 'react-router-dom'
20+
21+
// ─── Module-level mocks ──────────────────────────────────────────────────
22+
// Mock the api so logout() is a spy we can assert against (and so it never
23+
// touches localStorage during the test run).
24+
vi.mock('../api', async () => {
25+
const actual = await vi.importActual<typeof import('../api')>('../api')
26+
return {
27+
...actual,
28+
logout: vi.fn().mockResolvedValue({ ok: true }),
29+
}
30+
})
31+
32+
// Stub useDashboardCtx so we control what the menu reads. We intentionally
33+
// only populate the fields the component reads — `experiments` and other
34+
// /auth/me extensions must NOT be required by the menu.
35+
const FIXTURE_ME = {
36+
user: { id: 'u_test', email: 'aanya@acme.dev', tier: 'pro', team_id: 't_test', created_at: '' },
37+
team: { id: 't_test', name: 'acme-corp', slug: 'acme-corp', owner_id: 'u_test', member_count: 1, tier: 'pro', created_at: '' },
38+
}
39+
40+
vi.mock('../hooks/useDashboardCtx', () => ({
41+
useDashboardCtx: () => ({
42+
me: FIXTURE_ME,
43+
meErr: null,
44+
meLoading: false,
45+
env: 'production',
46+
envs: ['production'],
47+
counts: { resources: 0, deployments: 0, vault: 0, team: 1 },
48+
resources: [],
49+
billing: null,
50+
billingLoading: false,
51+
}),
52+
}))
53+
54+
import * as api from '../api'
55+
import { UserMenu } from './UserMenu'
56+
57+
// ─── Helpers ─────────────────────────────────────────────────────────────
58+
59+
// Render the menu inside a MemoryRouter with extra routes wired up so we
60+
// can observe navigation by what react-router actually paints. The "*"
61+
// catch-all route renders the current path as text — assertions read
62+
// the route-shadow div instead of mocking useNavigate.
63+
function renderMenu(initialPath = '/app') {
64+
return render(
65+
<MemoryRouter initialEntries={[initialPath]}>
66+
<Routes>
67+
<Route path="/app/*" element={<UserMenu />} />
68+
<Route path="/login" element={<div data-testid="route-login">on-login</div>} />
69+
<Route path="/app/settings" element={<div data-testid="route-settings">on-settings</div>} />
70+
</Routes>
71+
</MemoryRouter>,
72+
)
73+
}
74+
75+
beforeEach(() => {
76+
;(api.logout as unknown as ReturnType<typeof vi.fn>).mockClear()
77+
})
78+
afterEach(() => cleanup())
79+
80+
// ─── Tests ───────────────────────────────────────────────────────────────
81+
82+
describe('UserMenu — trigger', () => {
83+
it('renders the avatar with the first letter of the email', () => {
84+
renderMenu()
85+
const trigger = screen.getByTestId('user-menu-trigger')
86+
expect(trigger.textContent).toBe('A')
87+
// Email is exposed for screen readers via the title attribute so
88+
// users hovering over the avatar see whose account is signed in.
89+
expect(trigger.getAttribute('title')).toBe('aanya@acme.dev')
90+
})
91+
})
92+
93+
describe('UserMenu — open / close', () => {
94+
it('click trigger opens the dropdown', () => {
95+
renderMenu()
96+
expect(screen.queryByTestId('user-menu-dropdown')).toBeNull()
97+
98+
fireEvent.click(screen.getByTestId('user-menu-trigger'))
99+
100+
const dropdown = screen.getByTestId('user-menu-dropdown')
101+
expect(dropdown).toBeTruthy()
102+
expect(dropdown.getAttribute('role')).toBe('menu')
103+
// The user's email + team identity must surface in the dropdown so
104+
// they know which account they're about to log out of.
105+
expect(screen.getByTestId('user-menu-email').textContent).toBe('aanya@acme.dev')
106+
expect(screen.getByTestId('user-menu-team-name').textContent).toBe('acme-corp')
107+
expect(screen.getByTestId('user-menu-tier-badge').textContent).toBe('pro')
108+
})
109+
110+
it('click outside the dropdown closes it', () => {
111+
render(
112+
<MemoryRouter>
113+
<div>
114+
<button data-testid="outside">outside</button>
115+
<Routes>
116+
<Route path="/" element={<UserMenu />} />
117+
</Routes>
118+
</div>
119+
</MemoryRouter>,
120+
)
121+
122+
fireEvent.click(screen.getByTestId('user-menu-trigger'))
123+
expect(screen.getByTestId('user-menu-dropdown')).toBeTruthy()
124+
125+
// mousedown on a node outside the wrapper must close — the click-
126+
// outside listener is wired up on `mousedown` (not `click`) so it
127+
// fires before any other handler can re-open.
128+
fireEvent.mouseDown(screen.getByTestId('outside'))
129+
expect(screen.queryByTestId('user-menu-dropdown')).toBeNull()
130+
})
131+
132+
it('Escape key closes the dropdown', () => {
133+
renderMenu()
134+
fireEvent.click(screen.getByTestId('user-menu-trigger'))
135+
expect(screen.getByTestId('user-menu-dropdown')).toBeTruthy()
136+
137+
fireEvent.keyDown(document, { key: 'Escape' })
138+
expect(screen.queryByTestId('user-menu-dropdown')).toBeNull()
139+
})
140+
})
141+
142+
describe('UserMenu — actions', () => {
143+
it('Log out calls api.logout() and navigates to /login', async () => {
144+
renderMenu()
145+
fireEvent.click(screen.getByTestId('user-menu-trigger'))
146+
147+
await act(async () => {
148+
fireEvent.click(screen.getByTestId('user-menu-logout'))
149+
// Flush microtasks so the awaited api.logout() promise + the
150+
// navigate() that follows commit before assertions.
151+
await Promise.resolve()
152+
})
153+
154+
expect(api.logout).toHaveBeenCalledTimes(1)
155+
// react-router actually unmounts the menu route and mounts the
156+
// login route — proves navigation, not just a spy call.
157+
expect(screen.getByTestId('route-login')).toBeTruthy()
158+
})
159+
160+
it('Account settings navigates to /app/settings', () => {
161+
renderMenu()
162+
fireEvent.click(screen.getByTestId('user-menu-trigger'))
163+
164+
fireEvent.click(screen.getByTestId('user-menu-settings'))
165+
166+
expect(screen.getByTestId('route-settings')).toBeTruthy()
167+
})
168+
})

src/layout/UserMenu.tsx

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// UserMenu — clickable avatar with dropdown for account actions.
2+
//
3+
// Why this exists: the dashboard had a static avatar div in the AppShell
4+
// topbar with no way to log out from the UI. `api.logout()` existed and
5+
// the /auth/logout endpoint was referenced in ContractsPage, but no UI
6+
// surface called it. Users were stuck unless they cleared localStorage
7+
// by hand. This component fixes that gap.
8+
//
9+
// Behaviour:
10+
// - Trigger: circular avatar showing the first letter of the email.
11+
// - Click toggles a dropdown anchored bottom-right of the trigger.
12+
// - Dropdown shows email, team name + tier badge, then a divider,
13+
// "Account settings" link, and a "Log out" button.
14+
// - Click outside or pressing Escape closes the dropdown.
15+
// - Logout calls api.logout() (which clears the token) then navigates
16+
// to /login.
17+
//
18+
// We intentionally read only the fields we need from the dashboard ctx
19+
// (`me.user.email`, `me.team.name`, `me.team.tier`) so unrelated /auth/me
20+
// surface additions like the just-landed `experiments` field don't break
21+
// the menu.
22+
23+
import { useEffect, useRef, useState } from 'react'
24+
import { Link, useNavigate } from 'react-router-dom'
25+
import * as api from '../api'
26+
import { useDashboardCtx } from '../hooks/useDashboardCtx'
27+
28+
const SETTINGS_PATH = '/app/settings'
29+
const LOGIN_PATH = '/login'
30+
31+
export function UserMenu() {
32+
const ctx = useDashboardCtx()
33+
const navigate = useNavigate()
34+
const [open, setOpen] = useState(false)
35+
const wrapRef = useRef<HTMLDivElement | null>(null)
36+
37+
const email = ctx.me?.user?.email ?? ''
38+
const teamName = ctx.me?.team?.name ?? ctx.me?.team?.slug ?? 'workspace'
39+
const tier = ctx.me?.team?.tier ?? '—'
40+
const initial = (email[0] ?? 'A').toUpperCase()
41+
42+
// Click-outside + Escape close. Both listeners are attached only while
43+
// the dropdown is open so we don't pay the cost when it isn't.
44+
useEffect(() => {
45+
if (!open) return
46+
function onMouseDown(e: MouseEvent) {
47+
const node = wrapRef.current
48+
if (node && e.target instanceof Node && !node.contains(e.target)) {
49+
setOpen(false)
50+
}
51+
}
52+
function onKeyDown(e: KeyboardEvent) {
53+
if (e.key === 'Escape') setOpen(false)
54+
}
55+
document.addEventListener('mousedown', onMouseDown)
56+
document.addEventListener('keydown', onKeyDown)
57+
return () => {
58+
document.removeEventListener('mousedown', onMouseDown)
59+
document.removeEventListener('keydown', onKeyDown)
60+
}
61+
}, [open])
62+
63+
async function handleLogout() {
64+
// logout() is best-effort and never throws — see api/index.ts:222.
65+
// Swallow defensively anyway so a hypothetical future change can't
66+
// strand the user on a half-logged-out screen.
67+
try {
68+
await api.logout()
69+
} catch {
70+
/* swallow — token is cleared regardless */
71+
}
72+
setOpen(false)
73+
navigate(LOGIN_PATH)
74+
}
75+
76+
return (
77+
<div ref={wrapRef} className="user-menu-wrap" data-testid="user-menu" style={{ position: 'relative' }}>
78+
<button
79+
type="button"
80+
className="avatar"
81+
title={email}
82+
aria-haspopup="menu"
83+
aria-expanded={open}
84+
aria-label="Open user menu"
85+
data-testid="user-menu-trigger"
86+
onClick={() => setOpen((o) => !o)}
87+
style={{ cursor: 'pointer', border: 0 }}
88+
>
89+
{initial}
90+
</button>
91+
92+
{open && (
93+
<div
94+
role="menu"
95+
data-testid="user-menu-dropdown"
96+
className="user-menu-dropdown"
97+
style={{
98+
position: 'absolute',
99+
top: 'calc(100% + 6px)',
100+
right: 0,
101+
minWidth: 220,
102+
background: 'var(--elevated)',
103+
border: '1px solid var(--border-hi)',
104+
borderRadius: 'var(--radius-sm)',
105+
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
106+
zIndex: 50,
107+
padding: 6,
108+
fontSize: 13,
109+
}}
110+
>
111+
<div
112+
style={{
113+
padding: '8px 10px 10px',
114+
borderBottom: '1px solid var(--border)',
115+
marginBottom: 6,
116+
}}
117+
>
118+
<div
119+
data-testid="user-menu-email"
120+
style={{
121+
color: 'var(--text-dim)',
122+
fontFamily: 'var(--font-mono)',
123+
fontSize: 11,
124+
wordBreak: 'break-all',
125+
}}
126+
>
127+
{email}
128+
</div>
129+
<div
130+
style={{
131+
marginTop: 4,
132+
display: 'flex',
133+
alignItems: 'center',
134+
gap: 6,
135+
color: 'var(--text)',
136+
}}
137+
>
138+
<span data-testid="user-menu-team-name" style={{ fontWeight: 500 }}>
139+
{teamName}
140+
</span>
141+
<span
142+
data-testid="user-menu-tier-badge"
143+
style={{
144+
fontFamily: 'var(--font-mono)',
145+
fontSize: 10,
146+
padding: '1px 6px',
147+
borderRadius: 3,
148+
background: 'var(--accent-soft)',
149+
color: 'var(--accent)',
150+
letterSpacing: '0.02em',
151+
textTransform: 'lowercase',
152+
}}
153+
>
154+
{tier}
155+
</span>
156+
</div>
157+
</div>
158+
159+
<Link
160+
role="menuitem"
161+
data-testid="user-menu-settings"
162+
to={SETTINGS_PATH}
163+
onClick={() => setOpen(false)}
164+
style={{
165+
display: 'block',
166+
padding: '8px 10px',
167+
borderRadius: 'var(--radius-xs)',
168+
color: 'var(--text)',
169+
}}
170+
>
171+
Account settings
172+
</Link>
173+
174+
<button
175+
type="button"
176+
role="menuitem"
177+
data-testid="user-menu-logout"
178+
onClick={handleLogout}
179+
style={{
180+
display: 'block',
181+
width: '100%',
182+
textAlign: 'left',
183+
padding: '8px 10px',
184+
borderRadius: 'var(--radius-xs)',
185+
color: 'var(--rose)',
186+
fontFamily: 'inherit',
187+
fontSize: 13,
188+
cursor: 'pointer',
189+
}}
190+
>
191+
Log out
192+
</button>
193+
</div>
194+
)}
195+
</div>
196+
)
197+
}

0 commit comments

Comments
 (0)