Skip to content

Commit 5403861

Browse files
committed
feat: display user's avatar when logged in
1 parent 0eb276c commit 5403861

5 files changed

Lines changed: 70 additions & 4 deletions

File tree

app/components/ConnectorStatus.client.vue

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
<script setup lang="ts">
2-
const { isConnected, isConnecting, npmUser, error, activeOperations, hasPendingOperations } =
3-
useConnector()
2+
const {
3+
isConnected,
4+
isConnecting,
5+
npmUser,
6+
avatar,
7+
error,
8+
activeOperations,
9+
hasPendingOperations,
10+
} = useConnector()
411
512
const showModal = shallowRef(false)
613
const showTooltip = shallowRef(false)
@@ -41,8 +48,16 @@ const ariaLabel = computed(() => {
4148
@focus="showTooltip = true"
4249
@blur="showTooltip = false"
4350
>
44-
<!-- Status dot -->
51+
<!-- Avatar (when connected with avatar) -->
52+
<img
53+
v-if="isConnected && avatar"
54+
:src="avatar"
55+
:alt="`${npmUser}'s avatar`"
56+
class="w-6 h-6 rounded-full"
57+
/>
58+
<!-- Status dot (when not connected or no avatar) -->
4559
<span
60+
v-else
4661
class="w-2.5 h-2.5 rounded-full transition-colors duration-200"
4762
:class="statusColor"
4863
aria-hidden="true"

app/composables/useConnector.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export interface ConnectorState {
2222
connecting: boolean
2323
/** The npm username if connected and authenticated */
2424
npmUser: string | null
25+
/** Base64 data URL of the user's avatar */
26+
avatar: string | null
2527
/** Pending operations queue */
2628
operations: PendingOperation[]
2729
/** Last connection error message */
@@ -34,6 +36,7 @@ interface ConnectResponse {
3436
success: boolean
3537
data?: {
3638
npmUser: string | null
39+
avatar: string | null
3740
connectedAt: number
3841
}
3942
error?: string
@@ -43,6 +46,7 @@ interface StateResponse {
4346
success: boolean
4447
data?: {
4548
npmUser: string | null
49+
avatar: string | null
4650
operations: PendingOperation[]
4751
}
4852
error?: string
@@ -60,6 +64,7 @@ export const useConnector = createSharedComposable(function useConnector() {
6064
connected: false,
6165
connecting: false,
6266
npmUser: null,
67+
avatar: null,
6368
operations: [],
6469
error: null,
6570
lastExecutionTime: null,
@@ -115,6 +120,7 @@ export const useConnector = createSharedComposable(function useConnector() {
115120

116121
state.value.connected = true
117122
state.value.npmUser = response.data.npmUser
123+
state.value.avatar = response.data.avatar
118124
state.value.error = null
119125

120126
// Fetch full state after connecting
@@ -155,6 +161,7 @@ export const useConnector = createSharedComposable(function useConnector() {
155161
connected: false,
156162
connecting: false,
157163
npmUser: null,
164+
avatar: null,
158165
operations: [],
159166
error: null,
160167
lastExecutionTime: null,
@@ -174,6 +181,7 @@ export const useConnector = createSharedComposable(function useConnector() {
174181

175182
if (response.success && response.data) {
176183
state.value.npmUser = response.data.npmUser
184+
state.value.avatar = response.data.avatar
177185
state.value.operations = response.data.operations
178186
state.value.connected = true
179187
}
@@ -380,6 +388,7 @@ export const useConnector = createSharedComposable(function useConnector() {
380388
isConnected: computed(() => state.value.connected),
381389
isConnecting: computed(() => state.value.connecting),
382390
npmUser: computed(() => state.value.npmUser),
391+
avatar: computed(() => state.value.avatar),
383392
error: computed(() => state.value.error),
384393
/** Timestamp of last execution completion (watch this to refresh data) */
385394
lastExecutionTime: computed(() => state.value.lastExecutionTime),

cli/src/npm-client.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import crypto from 'node:crypto'
12
import process from 'node:process'
23
import { execFile } from 'node:child_process'
34
import { promisify } from 'node:util'
@@ -184,6 +185,40 @@ export async function getNpmUser(): Promise<string | null> {
184185
return null
185186
}
186187

188+
/**
189+
* Gets the user's avatar as a base64 data URL from Gravatar.
190+
* Returns null if the user's email cannot be retrieved or avatar fetch fails.
191+
*/
192+
export async function getNpmAvatar(): Promise<string | null> {
193+
const result = await execNpm(['profile', 'get', 'email', '--json'], { silent: true })
194+
if (result.exitCode !== 0 || !result.stdout) {
195+
return null
196+
}
197+
198+
try {
199+
const parsed = JSON.parse(result.stdout) as { email?: string }
200+
if (!parsed.email) {
201+
return null
202+
}
203+
204+
const email = parsed.email.trim().toLowerCase()
205+
const hash = crypto.createHash('md5').update(email).digest('hex')
206+
const gravatarUrl = `https://www.gravatar.com/avatar/${hash}?s=64&d=retro`
207+
208+
const response = await fetch(gravatarUrl)
209+
if (!response.ok) {
210+
return null
211+
}
212+
213+
const contentType = response.headers.get('content-type') || 'image/png'
214+
const buffer = await response.arrayBuffer()
215+
const base64 = Buffer.from(buffer).toString('base64')
216+
return `data:${contentType};base64,${base64}`
217+
} catch {
218+
return null
219+
}
220+
}
221+
187222
export async function orgAddUser(
188223
org: string,
189224
user: string,

cli/src/server.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { ConnectorState, PendingOperation, OperationType, ApiResponse } fro
66
import { logDebug, logError } from './logger.ts'
77
import {
88
getNpmUser,
9+
getNpmAvatar,
910
orgAddUser,
1011
orgRemoveUser,
1112
orgListUsers,
@@ -50,6 +51,7 @@ export function createConnectorApp(expectedToken: string) {
5051
token: expectedToken,
5152
connectedAt: 0,
5253
npmUser: null,
54+
avatar: null,
5355
},
5456
operations: [],
5557
}
@@ -76,14 +78,16 @@ export function createConnectorApp(expectedToken: string) {
7678
throw new HTTPError({ statusCode: 401, message: 'Invalid token' })
7779
}
7880

79-
const npmUser = await getNpmUser()
81+
const [npmUser, avatar] = await Promise.all([getNpmUser(), getNpmAvatar()])
8082
state.session.connectedAt = Date.now()
8183
state.session.npmUser = npmUser
84+
state.session.avatar = avatar
8285

8386
return {
8487
success: true,
8588
data: {
8689
npmUser,
90+
avatar,
8791
connectedAt: state.session.connectedAt,
8892
},
8993
} as ApiResponse
@@ -99,6 +103,7 @@ export function createConnectorApp(expectedToken: string) {
99103
success: true,
100104
data: {
101105
npmUser: state.session.npmUser,
106+
avatar: state.session.avatar,
102107
operations: state.operations,
103108
},
104109
} as ApiResponse

cli/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export interface ConnectorSession {
77
token: string
88
connectedAt: number
99
npmUser: string | null
10+
/** Base64 data URL of the user's avatar */
11+
avatar: string | null
1012
}
1113

1214
export type OperationType =

0 commit comments

Comments
 (0)