Skip to content

Commit 4db33e9

Browse files
mikouajidanielroe
andauthored
feat: npmx connector allow web auth (#1355)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent e43b8cc commit 4db33e9

24 files changed

+1111
-94
lines changed

app/components/Header/ConnectorModal.vue

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
const { isConnected, isConnecting, npmUser, error, hasOperations, connect, disconnect } =
33
useConnector()
44
5+
const { settings } = useSettings()
6+
57
const tokenInput = shallowRef('')
68
const portInput = shallowRef('31415')
79
const { copied, copy } = useClipboard({ copiedDuring: 2000 })
@@ -61,6 +63,16 @@ function handleDisconnect() {
6163
</div>
6264
</div>
6365

66+
<!-- Connector preferences -->
67+
<div class="flex flex-col gap-2">
68+
<SettingsToggle
69+
:label="$t('connector.modal.auto_open_url')"
70+
v-model="settings.connector.autoOpenURL"
71+
/>
72+
</div>
73+
74+
<div class="border-t border-border my-3" />
75+
6476
<!-- Operations Queue -->
6577
<OrgOperationsQueue />
6678

@@ -194,6 +206,14 @@ function handleDisconnect() {
194206
class="w-full"
195207
size="medium"
196208
/>
209+
210+
<div class="border-t border-border my-3" />
211+
<div class="flex flex-col gap-2">
212+
<SettingsToggle
213+
:label="$t('connector.modal.auto_open_url')"
214+
v-model="settings.connector.autoOpenURL"
215+
/>
216+
</div>
197217
</div>
198218
</details>
199219
</div>

app/components/Org/OperationsQueue.vue

Lines changed: 128 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const {
77
approvedOperations,
88
completedOperations,
99
activeOperations,
10+
operations,
1011
hasOperations,
1112
hasPendingOperations,
1213
hasApprovedOperations,
@@ -23,11 +24,53 @@ const {
2324
2425
const isExecuting = shallowRef(false)
2526
const otpInput = shallowRef('')
27+
const otpError = shallowRef('')
2628
27-
/** Check if any active operation needs OTP */
29+
const authUrl = computed(() => {
30+
const op = operations.value.find(o => o.status === 'running' && o.authUrl)
31+
return op?.authUrl ?? null
32+
})
33+
34+
const authPollTimer = shallowRef<ReturnType<typeof setInterval> | null>(null)
35+
36+
function 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+
53+
function stopAuthPolling() {
54+
if (authPollTimer.value) {
55+
clearInterval(authPollTimer.value)
56+
authPollTimer.value = null
57+
}
58+
}
59+
60+
onUnmounted(stopAuthPolling)
61+
62+
function 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) */
2870
const hasOtpFailures = computed(() =>
2971
activeOperations.value.some(
30-
(op: PendingOperation) => op.status === 'failed' && op.result?.requiresOtp,
72+
(op: PendingOperation) =>
73+
op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure),
3174
),
3275
)
3376
@@ -46,14 +89,25 @@ async function handleExecute(otp?: string) {
4689
4790
/** Retry all OTP-failed operations with the provided OTP */
4891
async function handleRetryWithOtp() {
49-
if (!otpInput.value.trim()) return
50-
5192
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 = ''
52105
otpInput.value = ''
53106
54-
// First, re-approve all OTP-failed operations
107+
// First, re-approve all OTP/auth-failed operations
55108
const otpFailedOps = activeOperations.value.filter(
56-
(op: PendingOperation) => op.status === 'failed' && op.result?.requiresOtp,
109+
(op: PendingOperation) =>
110+
op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure),
57111
)
58112
for (const op of otpFailedOps) {
59113
await retryOperation(op.id)
@@ -63,9 +117,25 @@ async function handleRetryWithOtp() {
63117
await handleExecute(otp)
64118
}
65119
120+
/** Retry failed operations with web auth (no OTP) */
121+
async 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+
66135
async function handleClearAll() {
67136
await clearOperations()
68137
otpInput.value = ''
138+
otpError.value = ''
69139
}
70140
71141
function getStatusColor(status: string): string {
@@ -228,7 +298,7 @@ watch(isExecuting, executing => {
228298
</li>
229299
</ul>
230300

231-
<!-- Inline OTP prompt (appears when operations need OTP) -->
301+
<!-- Inline OTP prompt (appears when web auth fails and OTP is needed as fallback) -->
232302
<div
233303
v-if="hasOtpFailures"
234304
class="p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg"
@@ -240,29 +310,49 @@ watch(isExecuting, executing => {
240310
{{ $t('operations.queue.otp_prompt') }}
241311
</span>
242312
</div>
243-
<form class="flex items-center gap-2" @submit.prevent="handleRetryWithOtp">
244-
<label for="otp-input" class="sr-only">{{ $t('operations.queue.otp_label') }}</label>
245-
<InputBase
246-
id="otp-input"
247-
v-model="otpInput"
248-
type="text"
249-
name="otp-code"
250-
inputmode="numeric"
251-
pattern="[0-9]*"
252-
:placeholder="$t('operations.queue.otp_placeholder')"
253-
autocomplete="one-time-code"
254-
spellcheck="false"
255-
class="flex-1 min-w-25"
256-
size="small"
257-
/>
258-
<button
259-
type="submit"
260-
:disabled="!otpInput.trim() || isExecuting"
261-
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"
262-
>
263-
{{ isExecuting ? $t('operations.queue.retrying') : $t('operations.queue.retry_otp') }}
264-
</button>
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>
265342
</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>
266356
</div>
267357

268358
<!-- Action buttons -->
@@ -288,6 +378,15 @@ watch(isExecuting, executing => {
288378
: `${$t('operations.queue.execute')} (${approvedOperations.length})`
289379
}}
290380
</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-carbon:launch w-4 h-4 inline-block me-1" aria-hidden="true" />
388+
{{ $t('operations.queue.open_web_auth') }}
389+
</button>
291390
</div>
292391

293392
<!-- Completed operations log (collapsed by default) -->

app/composables/useConnector.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ const STORAGE_KEY = 'npmx-connector'
5757
const DEFAULT_PORT = 31415
5858

5959
export const useConnector = createSharedComposable(function useConnector() {
60+
const { settings } = useSettings()
61+
6062
// Persisted connection config
6163
const config = useState<{ token: string; port: number } | null>('connector-config', () => null)
6264

@@ -303,7 +305,11 @@ export const useConnector = createSharedComposable(function useConnector() {
303305
ApiResponse<{ results: unknown[]; otpRequired?: boolean }>
304306
>('/execute', {
305307
method: 'POST',
306-
body: otp ? { otp } : undefined,
308+
body: {
309+
otp,
310+
interactive: !otp,
311+
openUrls: settings.value.connector.autoOpenURL,
312+
},
307313
})
308314
if (response?.success) {
309315
await refreshState()
@@ -371,20 +377,22 @@ export const useConnector = createSharedComposable(function useConnector() {
371377
const approvedOperations = computed(() =>
372378
state.value.operations.filter(op => op.status === 'approved'),
373379
)
374-
/** Operations that are done (completed, or failed without needing OTP retry) */
380+
/** Operations that are done (completed, or failed without needing OTP/auth retry) */
375381
const completedOperations = computed(() =>
376382
state.value.operations.filter(
377-
op => op.status === 'completed' || (op.status === 'failed' && !op.result?.requiresOtp),
383+
op =>
384+
op.status === 'completed' ||
385+
(op.status === 'failed' && !op.result?.requiresOtp && !op.result?.authFailure),
378386
),
379387
)
380-
/** Operations that are still active (pending, approved, running, or failed needing OTP retry) */
388+
/** Operations that are still active (pending, approved, running, or failed needing OTP/auth retry) */
381389
const activeOperations = computed(() =>
382390
state.value.operations.filter(
383391
op =>
384392
op.status === 'pending' ||
385393
op.status === 'approved' ||
386394
op.status === 'running' ||
387-
(op.status === 'failed' && op.result?.requiresOtp),
395+
(op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure)),
388396
),
389397
)
390398
const hasOperations = computed(() => state.value.operations.length > 0)

app/composables/useSettings.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export interface AppSettings {
2929
selectedLocale: LocaleObject['code'] | null
3030
/** Search provider for package search */
3131
searchProvider: SearchProvider
32+
/** Connector preferences */
33+
connector: {
34+
/** Automatically open the web auth page in the browser */
35+
autoOpenURL: boolean
36+
}
3237
sidebar: {
3338
collapsed: string[]
3439
}
@@ -42,6 +47,9 @@ const DEFAULT_SETTINGS: AppSettings = {
4247
selectedLocale: null,
4348
preferredBackgroundTheme: null,
4449
searchProvider: import.meta.test ? 'npm' : 'algolia',
50+
connector: {
51+
autoOpenURL: false,
52+
},
4553
sidebar: {
4654
collapsed: [],
4755
},

cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
},
3232
"dependencies": {
3333
"@clack/prompts": "^1.0.0",
34+
"@lydell/node-pty": "1.2.0-beta.3",
3435
"citty": "^0.2.0",
3536
"h3-next": "npm:h3@^2.0.1-rc.11",
3637
"obug": "^2.1.1",

cli/src/mock-app.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,13 +230,18 @@ function createMockConnectorApp(stateManager: MockConnectorStateManager) {
230230
requireAuth(event)
231231

232232
const body = await event.req.json().catch(() => ({}))
233-
const otp = (body as { otp?: string })?.otp
233+
const { otp } = body as { otp?: string; interactive?: boolean; openUrls?: boolean }
234234

235-
const { results, otpRequired } = stateManager.executeOperations({ otp })
235+
const { results, otpRequired, authFailure, urls } = stateManager.executeOperations({ otp })
236236

237237
return {
238238
success: true,
239-
data: { results, otpRequired },
239+
data: {
240+
results,
241+
otpRequired,
242+
authFailure,
243+
urls,
244+
},
240245
} satisfies ApiResponse<ConnectorEndpoints['POST /execute']['data']>
241246
})
242247

cli/src/mock-state.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export interface ExecuteOptions {
5858
export interface ExecuteResult {
5959
results: Array<{ id: string; result: OperationResult }>
6060
otpRequired?: boolean
61+
authFailure?: boolean
62+
urls?: string[]
6163
}
6264

6365
export function createMockConnectorState(config: MockConnectorConfig): MockConnectorStateData {
@@ -281,6 +283,7 @@ export class MockConnectorStateManager {
281283
exitCode: configuredResult.exitCode ?? 1,
282284
requiresOtp: configuredResult.requiresOtp,
283285
authFailure: configuredResult.authFailure,
286+
urls: configuredResult.urls,
284287
}
285288
op.result = result
286289
op.status = result.exitCode === 0 ? 'completed' : 'failed'
@@ -305,7 +308,15 @@ export class MockConnectorStateManager {
305308
}
306309
}
307310

308-
return { results }
311+
const authFailure = results.some(r => r.result.authFailure)
312+
const allUrls = results.flatMap(r => r.result.urls ?? [])
313+
const urls = [...new Set(allUrls)]
314+
315+
return {
316+
results,
317+
authFailure: authFailure || undefined,
318+
urls: urls.length > 0 ? urls : undefined,
319+
}
309320
}
310321

311322
/** Apply side effects of a completed operation. Param keys match schemas.ts. */

0 commit comments

Comments
 (0)