[READ-ONLY] a fast, modern browser for the npm registry
at main 440 lines 15 kB view raw
1<script setup lang="ts"> 2import type { PendingOperation } from '~~/cli/src/types' 3 4const { 5 isConnected, 6 pendingOperations, 7 approvedOperations, 8 completedOperations, 9 activeOperations, 10 operations, 11 hasOperations, 12 hasPendingOperations, 13 hasApprovedOperations, 14 hasActiveOperations, 15 hasCompletedOperations, 16 removeOperation, 17 clearOperations, 18 approveOperation, 19 approveAll, 20 executeOperations, 21 retryOperation, 22 refreshState, 23} = useConnector() 24 25const isExecuting = shallowRef(false) 26const otpInput = shallowRef('') 27const otpError = shallowRef('') 28 29const authUrl = computed(() => { 30 const op = operations.value.find(o => o.status === 'running' && o.authUrl) 31 return op?.authUrl ?? null 32}) 33 34const authPollTimer = shallowRef<ReturnType<typeof setInterval> | null>(null) 35 36function startAuthPolling() { 37 stopAuthPolling() 38 let remaining = 3 39 authPollTimer.value = setInterval(async () => { 40 try { 41 await refreshState() 42 } catch { 43 stopAuthPolling() 44 return 45 } 46 remaining-- 47 if (remaining <= 0) { 48 stopAuthPolling() 49 } 50 }, 20000) 51} 52 53function stopAuthPolling() { 54 if (authPollTimer.value) { 55 clearInterval(authPollTimer.value) 56 authPollTimer.value = null 57 } 58} 59 60onUnmounted(stopAuthPolling) 61 62function handleOpenAuthUrl() { 63 if (authUrl.value) { 64 window.open(authUrl.value, '_blank', 'noopener,noreferrer') 65 startAuthPolling() 66 } 67} 68 69/** Check if any active operation needs OTP (fallback for web auth failures) */ 70const hasOtpFailures = computed(() => 71 activeOperations.value.some( 72 (op: PendingOperation) => 73 op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure), 74 ), 75) 76 77async function handleApproveAll() { 78 await approveAll() 79} 80 81async function handleExecute(otp?: string) { 82 isExecuting.value = true 83 try { 84 await executeOperations(otp) 85 } finally { 86 isExecuting.value = false 87 } 88} 89 90/** Retry all OTP-failed operations with the provided OTP */ 91async function handleRetryWithOtp() { 92 const otp = otpInput.value.trim() 93 94 if (!otp) { 95 otpError.value = 'OTP required' 96 return 97 } 98 99 if (!/^\d{6}$/.test(otp)) { 100 otpError.value = 'OTP must be a 6-digit code' 101 return 102 } 103 104 otpError.value = '' 105 otpInput.value = '' 106 107 // First, re-approve all OTP/auth-failed operations 108 const otpFailedOps = activeOperations.value.filter( 109 (op: PendingOperation) => 110 op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure), 111 ) 112 for (const op of otpFailedOps) { 113 await retryOperation(op.id) 114 } 115 116 // Then execute with OTP 117 await handleExecute(otp) 118} 119 120/** Retry failed operations with web auth (no OTP) */ 121async function handleRetryWebAuth() { 122 // Find all failed operations that need auth retry 123 const failedOps = activeOperations.value.filter( 124 (op: PendingOperation) => 125 op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure), 126 ) 127 128 for (const op of failedOps) { 129 await retryOperation(op.id) 130 } 131 132 await handleExecute() 133} 134 135async function handleClearAll() { 136 await clearOperations() 137 otpInput.value = '' 138 otpError.value = '' 139} 140 141function getStatusColor(status: string): string { 142 switch (status) { 143 case 'pending': 144 return 'bg-yellow-500' 145 case 'approved': 146 return 'bg-blue-500' 147 case 'running': 148 return 'bg-purple-500' 149 case 'completed': 150 return 'bg-green-500' 151 case 'failed': 152 return 'bg-red-500' 153 default: 154 return 'bg-fg-subtle' 155 } 156} 157 158function getStatusIcon(status: string): string { 159 switch (status) { 160 case 'pending': 161 return 'i-lucide:clock' 162 case 'approved': 163 return 'i-lucide:check' 164 case 'running': 165 return 'i-svg-spinners:ring-resize' 166 case 'completed': 167 return 'i-lucide:check' 168 case 'failed': 169 return 'i-lucide:x' 170 default: 171 return 'i-lucide:circle-question-mark' 172 } 173} 174 175// Auto-refresh while executing 176const { pause: pauseRefresh, resume: resumeRefresh } = useIntervalFn(() => refreshState(), 1000, { 177 immediate: false, 178}) 179watch(isExecuting, executing => { 180 if (executing) { 181 resumeRefresh() 182 } else { 183 pauseRefresh() 184 } 185}) 186</script> 187 188<template> 189 <div v-if="isConnected" class="space-y-4"> 190 <!-- Header --> 191 <div class="flex items-center justify-between"> 192 <h3 class="font-mono text-sm font-medium text-fg"> 193 {{ $t('operations.queue.title') }} 194 <span v-if="hasActiveOperations" class="text-fg-muted" 195 >({{ activeOperations.length }})</span 196 > 197 </h3> 198 <div class="flex items-center gap-2"> 199 <button 200 v-if="hasOperations" 201 type="button" 202 class="px-2 py-1 font-mono text-xs text-fg-muted hover:text-fg bg-bg-subtle border border-border rounded transition-colors duration-200 hover:border-border-hover focus-visible:outline-accent/70" 203 :aria-label="$t('operations.queue.clear_all')" 204 @click="handleClearAll" 205 > 206 {{ $t('operations.queue.clear_all') }} 207 </button> 208 <button 209 type="button" 210 class="p-1 text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70" 211 :aria-label="$t('operations.queue.refresh')" 212 @click="refreshState" 213 > 214 <span class="i-lucide:refresh-ccw w-4 h-4" aria-hidden="true" /> 215 </button> 216 </div> 217 </div> 218 219 <!-- Empty state --> 220 <div v-if="!hasActiveOperations && !hasCompletedOperations" class="py-8 text-center"> 221 <p class="font-mono text-sm text-fg-subtle">{{ $t('operations.queue.empty') }}</p> 222 <p class="font-mono text-xs text-fg-subtle mt-1">{{ $t('operations.queue.empty_hint') }}</p> 223 </div> 224 225 <!-- Active operations list --> 226 <ul 227 v-if="hasActiveOperations" 228 class="space-y-2" 229 :aria-label="$t('operations.queue.active_label')" 230 > 231 <li 232 v-for="op in activeOperations" 233 :key="op.id" 234 class="flex items-start gap-3 p-3 bg-bg-subtle border border-border rounded-lg" 235 > 236 <!-- Status indicator --> 237 <span 238 class="flex-shrink-0 w-5 h-5 flex items-center justify-center" 239 :aria-label="op.status" 240 > 241 <span 242 :class="[getStatusIcon(op.status), getStatusColor(op.status).replace('bg-', 'text-')]" 243 class="w-4 h-4" 244 aria-hidden="true" 245 /> 246 </span> 247 248 <!-- Operation details --> 249 <div class="flex-1 min-w-0"> 250 <p class="font-mono text-sm text-fg truncate"> 251 {{ op.description }} 252 </p> 253 <p class="font-mono text-xs text-fg-subtle mt-0.5 truncate"> 254 {{ op.command }} 255 </p> 256 <!-- OTP required indicator (brief, OTP prompt is shown below) --> 257 <p 258 v-if="op.result?.requiresOtp && op.status === 'failed'" 259 class="mt-1 text-xs text-amber-400" 260 > 261 {{ $t('operations.queue.otp_required') }} 262 </p> 263 <!-- Result output for completed/failed --> 264 <div 265 v-else-if="op.result && (op.status === 'completed' || op.status === 'failed')" 266 class="mt-2 p-2 bg-bg-muted border border-border rounded text-xs font-mono" 267 > 268 <pre v-if="op.result.stdout" class="text-fg-muted whitespace-pre-wrap">{{ 269 op.result.stdout 270 }}</pre> 271 <pre v-if="op.result.stderr" class="text-red-400 whitespace-pre-wrap">{{ 272 op.result.stderr 273 }}</pre> 274 </div> 275 </div> 276 277 <!-- Actions --> 278 <div class="flex-shrink-0 flex items-center gap-1"> 279 <button 280 v-if="op.status === 'pending'" 281 type="button" 282 class="p-1 text-fg-muted hover:text-green-400 transition-colors duration-200 rounded focus-visible:outline-accent/70" 283 :aria-label="$t('operations.queue.approve_operation')" 284 @click="approveOperation(op.id)" 285 > 286 <span class="i-lucide:check w-4 h-4" aria-hidden="true" /> 287 </button> 288 <button 289 v-if="op.status !== 'running'" 290 type="button" 291 class="p-1 text-fg-muted hover:text-red-400 transition-colors duration-200 rounded focus-visible:outline-accent/70" 292 :aria-label="$t('operations.queue.remove_operation')" 293 @click="removeOperation(op.id)" 294 > 295 <span class="i-lucide:x w-4 h-4" aria-hidden="true" /> 296 </button> 297 </div> 298 </li> 299 </ul> 300 301 <!-- Inline OTP prompt (appears when web auth fails and OTP is needed as fallback) --> 302 <div 303 v-if="hasOtpFailures" 304 class="p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg" 305 role="alert" 306 > 307 <div class="flex items-center gap-2 mb-2"> 308 <span class="i-lucide:lock w-4 h-4 text-amber-400 shrink-0" aria-hidden="true" /> 309 <span class="font-mono text-sm text-amber-400"> 310 {{ $t('operations.queue.otp_prompt') }} 311 </span> 312 </div> 313 <form class="flex flex-col gap-1" @submit.prevent="handleRetryWithOtp"> 314 <div class="flex items-center gap-2"> 315 <label for="otp-input" class="sr-only">{{ $t('operations.queue.otp_label') }}</label> 316 <InputBase 317 id="otp-input" 318 v-model="otpInput" 319 type="text" 320 name="otp-code" 321 inputmode="numeric" 322 pattern="[0-9]*" 323 maxlength="6" 324 :placeholder="$t('operations.queue.otp_placeholder')" 325 autocomplete="one-time-code" 326 spellcheck="false" 327 :class="['flex-1 min-w-25', otpError ? 'border-red-500 focus:outline-red-500' : '']" 328 size="small" 329 @input="otpError = ''" 330 /> 331 <button 332 type="submit" 333 :disabled="isExecuting" 334 class="px-3 py-2 font-mono text-xs text-bg bg-amber-500 rounded transition-all duration-200 hover:bg-amber-400 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/50" 335 > 336 {{ isExecuting ? $t('operations.queue.retrying') : $t('operations.queue.retry_otp') }} 337 </button> 338 </div> 339 <p v-if="otpError" class="text-xs text-red-400 font-mono"> 340 {{ otpError }} 341 </p> 342 </form> 343 <div class="flex items-center gap-2 my-3"> 344 <div class="flex-1 h-px bg-amber-500/30" /> 345 <span class="text-xs text-amber-400 font-mono uppercase">{{ $t('common.or') }}</span> 346 <div class="flex-1 h-px bg-amber-500/30" /> 347 </div> 348 <button 349 type="button" 350 :disabled="isExecuting" 351 class="w-full px-3 py-2 font-mono text-xs text-fg bg-bg-subtle border border-border rounded transition-all duration-200 hover:text-fg hover:border-border-hover disabled:opacity-50 disabled:cursor-not-allowed" 352 @click="handleRetryWebAuth" 353 > 354 {{ isExecuting ? $t('operations.queue.retrying') : $t('operations.queue.retry_web_auth') }} 355 </button> 356 </div> 357 358 <!-- Action buttons --> 359 <div v-if="hasActiveOperations" class="flex items-center gap-2 pt-2"> 360 <button 361 v-if="hasPendingOperations" 362 type="button" 363 class="flex-1 px-4 py-2 font-mono text-sm text-fg bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:border-border-hover focus-visible:outline-accent/70" 364 @click="handleApproveAll" 365 > 366 {{ $t('operations.queue.approve_all') }} ({{ pendingOperations.length }}) 367 </button> 368 <button 369 v-if="hasApprovedOperations && !hasOtpFailures" 370 type="button" 371 :disabled="isExecuting" 372 class="flex-1 px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-accent/70 focus-visible:ring-offset-2 focus-visible:ring-offset-bg" 373 @click="handleExecute()" 374 > 375 {{ 376 isExecuting 377 ? $t('operations.queue.executing') 378 : `${$t('operations.queue.execute')} (${approvedOperations.length})` 379 }} 380 </button> 381 <button 382 v-if="authUrl" 383 type="button" 384 class="flex-1 px-4 py-2 font-mono text-sm text-accent bg-accent/10 border border-accent/30 rounded-md transition-colors duration-200 hover:bg-accent/20" 385 @click="handleOpenAuthUrl" 386 > 387 <span class="i-lucide:external-link w-4 h-4 inline-block me-1" aria-hidden="true" /> 388 {{ $t('operations.queue.open_web_auth') }} 389 </button> 390 </div> 391 392 <!-- Completed operations log (collapsed by default) --> 393 <details v-if="hasCompletedOperations" class="mt-4 border-t border-border pt-4"> 394 <summary 395 class="flex items-center gap-2 font-mono text-xs text-fg-muted hover:text-fg transition-colors duration-200 select-none" 396 > 397 <span 398 class="i-lucide:chevron-right rtl-flip w-3 h-3 transition-transform duration-200 [[open]>&]:rotate-90" 399 aria-hidden="true" 400 /> 401 {{ $t('operations.queue.log') }} ({{ completedOperations.length }}) 402 </summary> 403 <ul class="mt-2 space-y-1" :aria-label="$t('operations.queue.log_label')"> 404 <li 405 v-for="op in completedOperations" 406 :key="op.id" 407 class="flex items-start gap-2 p-2 text-xs font-mono rounded" 408 :class="op.status === 'completed' ? 'text-fg-muted' : 'text-red-400/80'" 409 > 410 <span 411 :class=" 412 op.status === 'completed' 413 ? 'i-lucide:check text-green-500' 414 : 'i-lucide:x text-red-500' 415 " 416 class="w-3.5 h-3.5 shrink-0 mt-0.5" 417 aria-hidden="true" 418 /> 419 <div class="flex-1 min-w-0"> 420 <span class="truncate block">{{ op.description }}</span> 421 <!-- Show error output for failed operations --> 422 <pre 423 v-if="op.status === 'failed' && op.result?.stderr" 424 class="mt-1 text-red-400/70 whitespace-pre-wrap text-2xs" 425 >{{ op.result.stderr }}</pre 426 > 427 </div> 428 <button 429 type="button" 430 class="p-0.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 rounded focus-visible:outline-accent/70" 431 :aria-label="$t('operations.queue.remove_from_log')" 432 @click="removeOperation(op.id)" 433 > 434 <span class="i-lucide:x w-3 h-3" aria-hidden="true" /> 435 </button> 436 </li> 437 </ul> 438 </details> 439 </div> 440</template>