forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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>