Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/components/Header/ConnectorModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
const { isConnected, isConnecting, npmUser, error, hasOperations, connect, disconnect } =
useConnector()

const { settings } = useSettings()

const tokenInput = shallowRef('')
const portInput = shallowRef('31415')
const { copied, copy } = useClipboard({ copiedDuring: 2000 })
Expand Down Expand Up @@ -61,6 +63,16 @@ function handleDisconnect() {
</div>
</div>

<!-- Connector preferences -->
<div class="flex flex-col gap-2">
<SettingsToggle
:label="$t('connector.modal.auto_open_url')"
v-model="settings.connector.autoOpenURL"
/>
</div>

<div class="border-t border-border my-3" />

<!-- Operations Queue -->
<OrgOperationsQueue />

Expand Down Expand Up @@ -194,6 +206,14 @@ function handleDisconnect() {
class="w-full"
size="medium"
/>

<div class="border-t border-border my-3" />
<div class="flex flex-col gap-2">
<SettingsToggle
:label="$t('connector.modal.auto_open_url')"
v-model="settings.connector.autoOpenURL"
/>
</div>
</div>
</details>
</div>
Expand Down
157 changes: 128 additions & 29 deletions app/components/Org/OperationsQueue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const {
approvedOperations,
completedOperations,
activeOperations,
operations,
hasOperations,
hasPendingOperations,
hasApprovedOperations,
Expand All @@ -23,11 +24,53 @@ const {

const isExecuting = shallowRef(false)
const otpInput = shallowRef('')
const otpError = shallowRef('')

/** Check if any active operation needs OTP */
const authUrl = computed(() => {
const op = operations.value.find(o => o.status === 'running' && o.authUrl)
return op?.authUrl ?? null
})

const authPollTimer = shallowRef<ReturnType<typeof setInterval> | null>(null)

function startAuthPolling() {
stopAuthPolling()
let remaining = 3
authPollTimer.value = setInterval(async () => {
try {
await refreshState()
} catch {
stopAuthPolling()
return
}
remaining--
if (remaining <= 0) {
stopAuthPolling()
}
}, 20000)
}

function stopAuthPolling() {
if (authPollTimer.value) {
clearInterval(authPollTimer.value)
authPollTimer.value = null
}
}

onUnmounted(stopAuthPolling)

function handleOpenAuthUrl() {
if (authUrl.value) {
window.open(authUrl.value, '_blank', 'noopener,noreferrer')
startAuthPolling()
}
}

/** Check if any active operation needs OTP (fallback for web auth failures) */
const hasOtpFailures = computed(() =>
activeOperations.value.some(
(op: PendingOperation) => op.status === 'failed' && op.result?.requiresOtp,
(op: PendingOperation) =>
op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure),
),
)

Expand All @@ -46,14 +89,25 @@ async function handleExecute(otp?: string) {

/** Retry all OTP-failed operations with the provided OTP */
async function handleRetryWithOtp() {
if (!otpInput.value.trim()) return

const otp = otpInput.value.trim()

if (!otp) {
otpError.value = 'OTP required'
return
}

if (!/^\d{6}$/.test(otp)) {
otpError.value = 'OTP must be a 6-digit code'
return
}

otpError.value = ''
otpInput.value = ''

// First, re-approve all OTP-failed operations
// First, re-approve all OTP/auth-failed operations
const otpFailedOps = activeOperations.value.filter(
(op: PendingOperation) => op.status === 'failed' && op.result?.requiresOtp,
(op: PendingOperation) =>
op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure),
)
for (const op of otpFailedOps) {
await retryOperation(op.id)
Expand All @@ -63,9 +117,25 @@ async function handleRetryWithOtp() {
await handleExecute(otp)
}

/** Retry failed operations with web auth (no OTP) */
async function handleRetryWebAuth() {
// Find all failed operations that need auth retry
const failedOps = activeOperations.value.filter(
(op: PendingOperation) =>
op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure),
)

for (const op of failedOps) {
await retryOperation(op.id)
}

await handleExecute()
}

async function handleClearAll() {
await clearOperations()
otpInput.value = ''
otpError.value = ''
}

function getStatusColor(status: string): string {
Expand Down Expand Up @@ -228,7 +298,7 @@ watch(isExecuting, executing => {
</li>
</ul>

<!-- Inline OTP prompt (appears when operations need OTP) -->
<!-- Inline OTP prompt (appears when web auth fails and OTP is needed as fallback) -->
<div
v-if="hasOtpFailures"
class="p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg"
Expand All @@ -240,29 +310,49 @@ watch(isExecuting, executing => {
{{ $t('operations.queue.otp_prompt') }}
</span>
</div>
<form class="flex items-center gap-2" @submit.prevent="handleRetryWithOtp">
<label for="otp-input" class="sr-only">{{ $t('operations.queue.otp_label') }}</label>
<InputBase
id="otp-input"
v-model="otpInput"
type="text"
name="otp-code"
inputmode="numeric"
pattern="[0-9]*"
:placeholder="$t('operations.queue.otp_placeholder')"
autocomplete="one-time-code"
spellcheck="false"
class="flex-1 min-w-25"
size="small"
/>
<button
type="submit"
:disabled="!otpInput.trim() || isExecuting"
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"
>
{{ isExecuting ? $t('operations.queue.retrying') : $t('operations.queue.retry_otp') }}
</button>
<form class="flex flex-col gap-1" @submit.prevent="handleRetryWithOtp">
<div class="flex items-center gap-2">
<label for="otp-input" class="sr-only">{{ $t('operations.queue.otp_label') }}</label>
<InputBase
id="otp-input"
v-model="otpInput"
type="text"
name="otp-code"
inputmode="numeric"
pattern="[0-9]*"
maxlength="6"
:placeholder="$t('operations.queue.otp_placeholder')"
autocomplete="one-time-code"
spellcheck="false"
:class="['flex-1 min-w-25', otpError ? 'border-red-500 focus:outline-red-500' : '']"
size="small"
@input="otpError = ''"
/>
<button
type="submit"
:disabled="isExecuting"
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"
>
{{ isExecuting ? $t('operations.queue.retrying') : $t('operations.queue.retry_otp') }}
</button>
</div>
<p v-if="otpError" class="text-xs text-red-400 font-mono">
{{ otpError }}
</p>
</form>
<div class="flex items-center gap-2 my-3">
<div class="flex-1 h-px bg-amber-500/30" />
<span class="text-xs text-amber-400 font-mono uppercase">{{ $t('common.or') }}</span>
<div class="flex-1 h-px bg-amber-500/30" />
</div>
<button
type="button"
:disabled="isExecuting"
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"
@click="handleRetryWebAuth"
>
{{ isExecuting ? $t('operations.queue.retrying') : $t('operations.queue.retry_web_auth') }}
</button>
</div>

<!-- Action buttons -->
Expand All @@ -288,6 +378,15 @@ watch(isExecuting, executing => {
: `${$t('operations.queue.execute')} (${approvedOperations.length})`
}}
</button>
<button
v-if="authUrl"
type="button"
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"
@click="handleOpenAuthUrl"
>
<span class="i-carbon:launch w-4 h-4 inline-block me-1" aria-hidden="true" />
{{ $t('operations.queue.open_web_auth') }}
</button>
</div>

<!-- Completed operations log (collapsed by default) -->
Expand Down
18 changes: 13 additions & 5 deletions app/composables/useConnector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const STORAGE_KEY = 'npmx-connector'
const DEFAULT_PORT = 31415

export const useConnector = createSharedComposable(function useConnector() {
const { settings } = useSettings()

// Persisted connection config
const config = useState<{ token: string; port: number } | null>('connector-config', () => null)

Expand Down Expand Up @@ -303,7 +305,11 @@ export const useConnector = createSharedComposable(function useConnector() {
ApiResponse<{ results: unknown[]; otpRequired?: boolean }>
>('/execute', {
method: 'POST',
body: otp ? { otp } : undefined,
body: {
otp,
interactive: !otp,
openUrls: settings.value.connector.autoOpenURL,
},
})
if (response?.success) {
await refreshState()
Expand Down Expand Up @@ -371,20 +377,22 @@ export const useConnector = createSharedComposable(function useConnector() {
const approvedOperations = computed(() =>
state.value.operations.filter(op => op.status === 'approved'),
)
/** Operations that are done (completed, or failed without needing OTP retry) */
/** Operations that are done (completed, or failed without needing OTP/auth retry) */
const completedOperations = computed(() =>
state.value.operations.filter(
op => op.status === 'completed' || (op.status === 'failed' && !op.result?.requiresOtp),
op =>
op.status === 'completed' ||
(op.status === 'failed' && !op.result?.requiresOtp && !op.result?.authFailure),
),
)
/** Operations that are still active (pending, approved, running, or failed needing OTP retry) */
/** Operations that are still active (pending, approved, running, or failed needing OTP/auth retry) */
const activeOperations = computed(() =>
state.value.operations.filter(
op =>
op.status === 'pending' ||
op.status === 'approved' ||
op.status === 'running' ||
(op.status === 'failed' && op.result?.requiresOtp),
(op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure)),
),
)
const hasOperations = computed(() => state.value.operations.length > 0)
Expand Down
8 changes: 8 additions & 0 deletions app/composables/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export interface AppSettings {
selectedLocale: LocaleObject['code'] | null
/** Search provider for package search */
searchProvider: SearchProvider
/** Connector preferences */
connector: {
/** Automatically open the web auth page in the browser */
autoOpenURL: boolean
}
sidebar: {
collapsed: string[]
}
Expand All @@ -42,6 +47,9 @@ const DEFAULT_SETTINGS: AppSettings = {
selectedLocale: null,
preferredBackgroundTheme: null,
searchProvider: import.meta.test ? 'npm' : 'algolia',
connector: {
autoOpenURL: false,
},
sidebar: {
collapsed: [],
},
Expand Down
1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@lydell/node-pty": "1.2.0-beta.3",
"citty": "^0.2.0",
"h3-next": "npm:h3@^2.0.1-rc.11",
"obug": "^2.1.1",
Expand Down
11 changes: 8 additions & 3 deletions cli/src/mock-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,13 +230,18 @@ function createMockConnectorApp(stateManager: MockConnectorStateManager) {
requireAuth(event)

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

const { results, otpRequired } = stateManager.executeOperations({ otp })
const { results, otpRequired, authFailure, urls } = stateManager.executeOperations({ otp })

return {
success: true,
data: { results, otpRequired },
data: {
results,
otpRequired,
authFailure,
urls,
},
} satisfies ApiResponse<ConnectorEndpoints['POST /execute']['data']>
})

Expand Down
13 changes: 12 additions & 1 deletion cli/src/mock-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export interface ExecuteOptions {
export interface ExecuteResult {
results: Array<{ id: string; result: OperationResult }>
otpRequired?: boolean
authFailure?: boolean
urls?: string[]
}

export function createMockConnectorState(config: MockConnectorConfig): MockConnectorStateData {
Expand Down Expand Up @@ -281,6 +283,7 @@ export class MockConnectorStateManager {
exitCode: configuredResult.exitCode ?? 1,
requiresOtp: configuredResult.requiresOtp,
authFailure: configuredResult.authFailure,
urls: configuredResult.urls,
}
op.result = result
op.status = result.exitCode === 0 ? 'completed' : 'failed'
Expand All @@ -305,7 +308,15 @@ export class MockConnectorStateManager {
}
}

return { results }
const authFailure = results.some(r => r.result.authFailure)
const allUrls = results.flatMap(r => r.result.urls ?? [])
const urls = [...new Set(allUrls)]

return {
results,
authFailure: authFailure || undefined,
urls: urls.length > 0 ? urls : undefined,
}
}

/** Apply side effects of a completed operation. Param keys match schemas.ts. */
Expand Down
Loading
Loading