Skip to content

Commit 97536f1

Browse files
committed
feat(auth): display username in connected status
When a user successfully authenticates with their Bugzilla API key, we now fetch their account info via the /whoami endpoint and display their name in the "Connected as [name]" status message. Changes: - Add WhoAmIResponse type and whoAmI method to BugzillaClient - Add username field to auth slice state - Fetch and store username during API key validation - Display username in ApiKeyStatus authenticated state
1 parent dc6ca27 commit 97536f1

File tree

7 files changed

+130
-9
lines changed

7 files changed

+130
-9
lines changed

src/components/Auth/ApiKeyStatus.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ describe('ApiKeyStatus', () => {
6868
isValid: true,
6969
isValidating: false,
7070
clearApiKey: mockClearApiKey,
71+
username: 'Test User',
7172
}
7273
// @ts-expect-error - Simplified mock for testing
7374
return selector(state)
@@ -80,6 +81,12 @@ describe('ApiKeyStatus', () => {
8081
expect(screen.getByText(/connected/i)).toBeInTheDocument()
8182
})
8283

84+
it('should show username in connected message', () => {
85+
render(<ApiKeyStatus onOpenModal={mockOnOpenModal} />)
86+
87+
expect(screen.getByText(/test user/i)).toBeInTheDocument()
88+
})
89+
8390
it('should show playful authenticated message', () => {
8491
render(<ApiKeyStatus onOpenModal={mockOnOpenModal} />)
8592

src/components/Auth/ApiKeyStatus.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export function ApiKeyStatus({ onOpenModal }: ApiKeyStatusProps) {
99
const isValid = useStore((state) => state.isValid)
1010
const isValidating = useStore((state) => state.isValidating)
1111
const clearApiKey = useStore((state) => state.clearApiKey)
12+
const username = useStore((state) => state.username)
1213

1314
// Determine current state
1415
const hasApiKey = Boolean(apiKey)
@@ -60,7 +61,9 @@ export function ApiKeyStatus({ onOpenModal }: ApiKeyStatusProps) {
6061
<div role="status" className="flex items-center gap-3 rounded-lg bg-bg-secondary px-4 py-2">
6162
<span className="material-icons text-accent-success">check_circle</span>
6263
<div className="flex-1">
63-
<p className="text-sm font-bold text-text-primary">Connected! 🎉</p>
64+
<p className="text-sm font-bold text-text-primary">
65+
Connected as {username || 'Unknown'} 🎉
66+
</p>
6467
<p className="text-xs text-text-secondary">You&apos;re all set to manage bugs</p>
6568
</div>
6669
<button

src/lib/bugzilla/client.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,4 +468,47 @@ describe('BugzillaClient', () => {
468468
)
469469
})
470470
})
471+
472+
describe('whoAmI', () => {
473+
it('should return current user info', async () => {
474+
const mockUser = {
475+
id: 12_345,
476+
real_name: 'Test User',
477+
name: 'testuser@mozilla.com',
478+
}
479+
480+
global.fetch = vi.fn().mockResolvedValue({
481+
ok: true,
482+
json: () => Promise.resolve(mockUser),
483+
})
484+
485+
const result = await client.whoAmI()
486+
487+
expect(result).toEqual(mockUser)
488+
expect(fetch).toHaveBeenCalledWith(
489+
expect.stringContaining(`${baseUrl}/whoami`),
490+
expect.objectContaining({
491+
headers: {
492+
'X-BUGZILLA-API-KEY': mockApiKey,
493+
'Content-Type': 'application/json',
494+
},
495+
}),
496+
)
497+
})
498+
499+
it('should throw error on 401 Unauthorized', async () => {
500+
global.fetch = vi.fn().mockResolvedValue({
501+
ok: false,
502+
status: 401,
503+
json: () =>
504+
Promise.resolve({
505+
error: true,
506+
message: 'Invalid API key',
507+
code: 401,
508+
}),
509+
})
510+
511+
await expect(client.whoAmI()).rejects.toThrow('Unauthorized: Invalid API key')
512+
})
513+
})
471514
})

src/lib/bugzilla/client.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
BugFilters,
88
BugUpdate,
99
BatchUpdateResult,
10+
WhoAmIResponse,
1011
} from './types'
1112

1213
const DEFAULT_TIMEOUT = 30_000 // 30 seconds
@@ -85,6 +86,14 @@ export class BugzillaClient {
8586
})
8687
}
8788

89+
/**
90+
* Get current user info (authenticated user)
91+
*/
92+
async whoAmI(): Promise<WhoAmIResponse> {
93+
const url = `${this.baseUrl}/whoami`
94+
return this.request<WhoAmIResponse>(url)
95+
}
96+
8897
/**
8998
* Update multiple bugs in batch
9099
*/

src/lib/bugzilla/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,9 @@ export interface BatchUpdateResult {
7474
successful: number[]
7575
failed: Array<{ id: number; error: string }>
7676
}
77+
78+
export interface WhoAmIResponse {
79+
id: number
80+
real_name: string
81+
name: string
82+
}

src/store/slices/auth-slice.test.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import { createAuthSlice } from './auth-slice'
44
import type { AuthSlice } from './auth-slice'
55

66
// Use vi.hoisted to create mocks that can be referenced in vi.mock
7-
const { mockSaveApiKey, mockGetApiKey, mockClearApiKey, mockGetBugs } = vi.hoisted(() => ({
8-
mockSaveApiKey: vi.fn(),
9-
mockGetApiKey: vi.fn(),
10-
mockClearApiKey: vi.fn(),
11-
mockGetBugs: vi.fn(),
12-
}))
7+
const { mockSaveApiKey, mockGetApiKey, mockClearApiKey, mockGetBugs, mockWhoAmI } = vi.hoisted(
8+
() => ({
9+
mockSaveApiKey: vi.fn(),
10+
mockGetApiKey: vi.fn(),
11+
mockClearApiKey: vi.fn(),
12+
mockGetBugs: vi.fn(),
13+
mockWhoAmI: vi.fn(),
14+
}),
15+
)
1316

1417
// Mock ApiKeyStorage
1518
vi.mock('@/lib/storage/api-key-storage', () => ({
@@ -24,6 +27,7 @@ vi.mock('@/lib/storage/api-key-storage', () => ({
2427
vi.mock('@/lib/bugzilla/client', () => ({
2528
BugzillaClient: vi.fn().mockImplementation(() => ({
2629
getBugs: mockGetBugs,
30+
whoAmI: mockWhoAmI,
2731
})),
2832
}))
2933

@@ -36,6 +40,7 @@ describe('AuthSlice', () => {
3640
mockSaveApiKey.mockResolvedValue()
3741
mockGetApiKey.mockResolvedValue(null)
3842
mockGetBugs.mockResolvedValue([])
43+
mockWhoAmI.mockResolvedValue({ id: 12345, real_name: 'Test User', name: 'test@mozilla.com' })
3944

4045
useStore = create<AuthSlice>()((...args) => ({
4146
...createAuthSlice(...args),
@@ -64,6 +69,12 @@ describe('AuthSlice', () => {
6469

6570
expect(validationError).toBeNull()
6671
})
72+
73+
it('should have username null initially', () => {
74+
const { username } = useStore.getState()
75+
76+
expect(username).toBeNull()
77+
})
6778
})
6879

6980
describe('setApiKey', () => {
@@ -92,6 +103,15 @@ describe('AuthSlice', () => {
92103
expect(isValid).toBe(true)
93104
})
94105

106+
it('should fetch and store username on successful validation', async () => {
107+
const { setApiKey } = useStore.getState()
108+
await setApiKey('test-api-key-123')
109+
110+
const { username } = useStore.getState()
111+
expect(username).toBe('Test User')
112+
expect(mockWhoAmI).toHaveBeenCalled()
113+
})
114+
95115
it('should set isValidating during validation', async () => {
96116
const { setApiKey } = useStore.getState()
97117
const promise = setApiKey('test-api-key-123')
@@ -178,6 +198,20 @@ describe('AuthSlice', () => {
178198
// Can't easily verify this without exposing storage instance
179199
// Trust that implementation calls storage.clearApiKey()
180200
})
201+
202+
it('should clear username', async () => {
203+
const { setApiKey, clearApiKey } = useStore.getState()
204+
await setApiKey('test-api-key-123')
205+
206+
const { username: usernameBefore } = useStore.getState()
207+
expect(usernameBefore).toBe('Test User')
208+
209+
clearApiKey()
210+
211+
const { username: usernameAfter } = useStore.getState()
212+
213+
expect(usernameAfter).toBeNull()
214+
})
181215
})
182216

183217
describe('loadApiKey', () => {

src/store/slices/auth-slice.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface AuthSlice {
1313
isValid: boolean
1414
isValidating: boolean
1515
validationError: string | null
16+
username: string | null
1617

1718
// Actions
1819
setApiKey: (apiKey: string) => Promise<void>
@@ -29,6 +30,7 @@ export const createAuthSlice: StateCreator<AuthSlice> = (set, get) => ({
2930
isValid: false,
3031
isValidating: false,
3132
validationError: null,
33+
username: null,
3234

3335
// Set API key, save to storage, and validate
3436
setApiKey: async (apiKeyString: string) => {
@@ -51,7 +53,15 @@ export const createAuthSlice: StateCreator<AuthSlice> = (set, get) => ({
5153
const client = new BugzillaClient(apiKey, DEFAULT_BUGZILLA_URL)
5254
await client.getBugs({ status: ['NEW'], limit: 1 })
5355

54-
set({ isValid: true, isValidating: false, validationError: null })
56+
// Fetch user info
57+
const userInfo = await client.whoAmI()
58+
59+
set({
60+
isValid: true,
61+
isValidating: false,
62+
validationError: null,
63+
username: userInfo.real_name || userInfo.name,
64+
})
5565
} catch (error) {
5666
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
5767
set({ isValid: false, isValidating: false, validationError: errorMessage })
@@ -66,6 +76,7 @@ export const createAuthSlice: StateCreator<AuthSlice> = (set, get) => ({
6676
isValid: false,
6777
isValidating: false,
6878
validationError: null,
79+
username: null,
6980
})
7081
},
7182

@@ -86,7 +97,15 @@ export const createAuthSlice: StateCreator<AuthSlice> = (set, get) => ({
8697
const client = new BugzillaClient(apiKey, DEFAULT_BUGZILLA_URL)
8798
await client.getBugs({ status: ['NEW'], limit: 1 })
8899

89-
set({ isValid: true, isValidating: false, validationError: null })
100+
// Fetch user info
101+
const userInfo = await client.whoAmI()
102+
103+
set({
104+
isValid: true,
105+
isValidating: false,
106+
validationError: null,
107+
username: userInfo.real_name || userInfo.name,
108+
})
90109
} catch (error) {
91110
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
92111
set({

0 commit comments

Comments
 (0)