diff --git a/packages/cli/src/commands/spend-request/approval-waiting-view.tsx b/packages/cli/src/commands/spend-request/approval-waiting-view.tsx index c6e68ad..3652690 100644 --- a/packages/cli/src/commands/spend-request/approval-waiting-view.tsx +++ b/packages/cli/src/commands/spend-request/approval-waiting-view.tsx @@ -4,12 +4,10 @@ import type React from 'react'; import { AppDownloadQrCodes } from './app-download-qr-codes'; interface ApprovalWaitingViewProps { - status: 'waiting' | 'polling'; approvalUrl: string; } export const ApprovalWaitingView: React.FC = ({ - status, approvalUrl, }) => ( @@ -30,13 +28,9 @@ export const ApprovalWaitingView: React.FC = ({ - {status === 'polling' ? ( - - Waiting for approval... - - ) : ( - Waiting... - )} + + Waiting for approval... + ); diff --git a/packages/cli/src/commands/spend-request/create.tsx b/packages/cli/src/commands/spend-request/create.tsx index 09dabb0..5fe75fb 100644 --- a/packages/cli/src/commands/spend-request/create.tsx +++ b/packages/cli/src/commands/spend-request/create.tsx @@ -6,9 +6,11 @@ import type { import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { DISPLAY_DELAY_MS } from '../../utils/constants'; import { writeCredentialFile } from '../../utils/credential-output'; +import { tryStartCallbackServer } from '../../utils/local-callback-server'; +import { sanitizeDeep } from '../../utils/sanitize-text'; import { AppDownloadQrCodes } from './app-download-qr-codes'; import { ApprovalWaitingView } from './approval-waiting-view'; import { useApprovalPolling } from './use-approval-polling'; @@ -31,52 +33,129 @@ export const CreateSpendRequest: React.FC = ({ onComplete, }) => { const [status, setStatus] = useState< - 'creating' | 'waiting' | 'polling' | 'success' | 'error' + | 'creating' + | 'waiting' + | 'polling' + | 'success' + | 'denied' + | 'expired' + | 'error' >('creating'); const [request, setRequest] = useState(null); + const [approvalUrl, setApprovalUrl] = useState(''); const [error, setError] = useState(''); const [outputFilePath, setOutputFilePath] = useState(null); const [fileError, setFileError] = useState(''); - - const approvalUrl = request?.approval_url ?? ''; - - const onSuccess = useCallback( - (result: SpendRequest) => setRequest(result), - [], - ); - const onError = useCallback((msg: string) => setError(msg), []); + const [pollingFallback, setPollingFallback] = useState(false); useApprovalPolling({ + enabled: pollingFallback, status, - setStatus, + setStatus: () => setStatus('polling'), approvalUrl, repository, requestId: request?.id ?? null, onComplete, - onSuccess, - onError, + onTerminal: (final, s) => { + setRequest(sanitizeDeep(final)); + if (s === 'approved') { + setStatus('success'); + } else if (s === 'denied') { + setStatus('denied'); + } else if (s === 'expired') { + setStatus('expired'); + } else { + setError('An error occurred during approval'); + setStatus('error'); + } + }, + onPollError: (msg) => { + setError(msg); + setStatus('error'); + }, }); useEffect(() => { + let close: (() => void) | null = null; + let cancelled = false; + const create = async () => { try { - const result = await repository.createSpendRequest(params); - setRequest(result); + const server = requestApproval ? await tryStartCallbackServer() : null; + if (server) close = server.close; - if (requestApproval) { - setStatus('waiting'); - } else { + // Strip request_approval so the spend request starts in `created` state; we call requestApproval explicitly below. + const { request_approval: _, ...createParams } = params; + const result = await repository.createSpendRequest(createParams); + if (cancelled) return; + setRequest(sanitizeDeep(result)); + + if (!requestApproval) { setStatus('success'); setTimeout(() => onComplete(result), DISPLAY_DELAY_MS); + return; } + + const approval = await repository.requestApproval( + result.id, + server ? { redirect_uri: server.redirectUri } : undefined, + ); + if (cancelled) return; + + setApprovalUrl(sanitizeDeep(approval.approval_link)); + setStatus('waiting'); + + if (!server) { + setPollingFallback(true); + return; + } + + const { status: callbackStatus } = await server.waitForCallback(); + if (cancelled) return; + + if (callbackStatus === 'timeout') { + setPollingFallback(true); + return; + } + + const final = await repository.retrieve(result.id); + if (cancelled) return; + + if (!final) { + setError('Spend request not found after approval'); + setStatus('error'); + setTimeout(() => onComplete(null), DISPLAY_DELAY_MS); + return; + } + + setRequest(sanitizeDeep(final)); + if (callbackStatus === 'approved') { + setStatus('success'); + } else if (callbackStatus === 'denied') { + setStatus('denied'); + } else if (callbackStatus === 'expired') { + setStatus('expired'); + } else { + setError('An error occurred during approval'); + setStatus('error'); + } + setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); } catch (err) { - setError((err as Error).message); - setStatus('error'); - setTimeout(() => onComplete(null), DISPLAY_DELAY_MS); + if (!cancelled) { + setError((err as Error).message); + setStatus('error'); + setTimeout(() => onComplete(null), DISPLAY_DELAY_MS); + } + } finally { + close?.(); } }; create(); + return () => { + cancelled = true; + close?.(); + }; }, [repository, params, requestApproval, onComplete]); useEffect(() => { @@ -114,6 +193,41 @@ export const CreateSpendRequest: React.FC = ({ ); } + if (status === 'denied') { + return ( + + ✗ Spend request denied + + + ID: {request?.id} + + + Status:{' '} + + {request?.status} + + + + + ); + } + + if (status === 'expired') { + return ( + + ✗ Spend request expired + + + ID: {request?.id} + + + Status: {request?.status} + + + + ); + } + if (status === 'success') { return ( @@ -206,10 +320,7 @@ export const CreateSpendRequest: React.FC = ({ ✓ Spend request created (ID: {request?.id}) - + ); }; diff --git a/packages/cli/src/commands/spend-request/request-approval.tsx b/packages/cli/src/commands/spend-request/request-approval.tsx index 3c0415c..02b495c 100644 --- a/packages/cli/src/commands/spend-request/request-approval.tsx +++ b/packages/cli/src/commands/spend-request/request-approval.tsx @@ -2,7 +2,10 @@ import type { ISpendRequestResource, SpendRequest } from '@stripe/link-sdk'; import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; +import { DISPLAY_DELAY_MS } from '../../utils/constants'; +import { tryStartCallbackServer } from '../../utils/local-callback-server'; +import { sanitizeDeep } from '../../utils/sanitize-text'; import { ApprovalWaitingView } from './approval-waiting-view'; import { useApprovalPolling } from './use-approval-polling'; @@ -18,40 +21,112 @@ export const RequestApproval: React.FC = ({ onComplete, }) => { const [status, setStatus] = useState< - 'requesting' | 'waiting' | 'polling' | 'success' | 'error' + | 'requesting' + | 'waiting' + | 'polling' + | 'success' + | 'denied' + | 'expired' + | 'error' >('requesting'); const [approvalUrl, setApprovalUrl] = useState(''); const [result, setResult] = useState(null); const [error, setError] = useState(''); - - const onSuccess = useCallback((r: SpendRequest) => setResult(r), []); - const onError = useCallback((msg: string) => setError(msg), []); + const [pollingFallback, setPollingFallback] = useState(false); useApprovalPolling({ + enabled: pollingFallback, status, - setStatus, + setStatus: () => setStatus('polling'), approvalUrl, repository, requestId: id, onComplete, - onSuccess, - onError, + onTerminal: (final, s) => { + setResult(sanitizeDeep(final)); + if (s === 'error') setError('An error occurred during approval'); + setStatus(s === 'approved' ? 'success' : s); + }, + onPollError: (msg) => { + setError(msg); + setStatus('error'); + }, }); useEffect(() => { - const request = async () => { + let close: (() => void) | null = null; + let cancelled = false; + + const run = async () => { try { - const res = await repository.requestApproval(id); - setApprovalUrl(res.approval_link); + const server = await tryStartCallbackServer(); + close = server?.close ?? null; + + const res = await repository.requestApproval( + id, + server ? { redirect_uri: server.redirectUri } : undefined, + ); + if (cancelled) return; + + setApprovalUrl(sanitizeDeep(res.approval_link)); setStatus('waiting'); + + if (!server) { + setPollingFallback(true); + return; + } + + const { status: callbackStatus } = await server.waitForCallback(); + if (cancelled) return; + + if (callbackStatus === 'timeout') { + setPollingFallback(true); + return; + } + + const final = await repository.retrieve(id); + if (cancelled) return; + + if (!final) { + setError('Spend request not found after approval'); + setStatus('error'); + return; + } + + const sanitizedFinal = sanitizeDeep(final); + if (callbackStatus === 'approved') { + setResult(sanitizedFinal); + setStatus('success'); + setTimeout(() => onComplete(sanitizedFinal), DISPLAY_DELAY_MS); + } else if (callbackStatus === 'denied') { + setResult(sanitizedFinal); + setStatus('denied'); + setTimeout(() => onComplete(sanitizedFinal), DISPLAY_DELAY_MS); + } else if (callbackStatus === 'expired') { + setResult(sanitizedFinal); + setStatus('expired'); + setTimeout(() => onComplete(sanitizedFinal), DISPLAY_DELAY_MS); + } else { + setError('An error occurred during approval'); + setStatus('error'); + setTimeout(() => onComplete(sanitizedFinal), DISPLAY_DELAY_MS); + } } catch (err) { - setError((err as Error).message); - setStatus('error'); + if (!cancelled) { + setError((err as Error).message); + setStatus('error'); + } + } finally { + close?.(); } }; - request(); - }, [repository, id]); + run(); + return () => { + cancelled = true; + close?.(); + }; + }, [repository, id, onComplete]); if (status === 'requesting') { return ( @@ -72,6 +147,41 @@ export const RequestApproval: React.FC = ({ ); } + if (status === 'denied') { + return ( + + ✗ Spend request denied + + + ID: {result?.id} + + + Status:{' '} + + {result?.status} + + + + + ); + } + + if (status === 'expired') { + return ( + + ✗ Spend request expired + + + ID: {result?.id} + + + Status: {result?.status} + + + + ); + } + if (status === 'success') { return ( @@ -105,10 +215,5 @@ export const RequestApproval: React.FC = ({ ); } - return ( - - ); + return ; }; diff --git a/packages/cli/src/commands/spend-request/use-approval-polling.ts b/packages/cli/src/commands/spend-request/use-approval-polling.ts index 599a3dd..23fa65b 100644 --- a/packages/cli/src/commands/spend-request/use-approval-polling.ts +++ b/packages/cli/src/commands/spend-request/use-approval-polling.ts @@ -5,28 +5,32 @@ import { DISPLAY_DELAY_MS } from '../../utils/constants'; import { openUrl } from '../../utils/open-url'; import { pollUntilApproved } from '../../utils/poll-until-approved'; -export type ApprovalStatus = 'waiting' | 'polling' | 'success' | 'error'; +export type PollingTerminalStatus = 'approved' | 'denied' | 'expired' | 'error'; interface UseApprovalPollingOptions { + // When false, only useInput is active — polling does not start. Use for the + // callback-server path so the hook stays mounted without racing the server. + enabled: boolean; status: string; - setStatus: (s: 'polling' | 'success' | 'error') => void; + setStatus: (s: 'polling') => void; approvalUrl: string; repository: ISpendRequestResource; requestId: string | null; onComplete: (result: SpendRequest) => void; - onSuccess: (result: SpendRequest) => void; - onError: (msg: string) => void; + onTerminal: (result: SpendRequest, status: PollingTerminalStatus) => void; + onPollError: (msg: string) => void; } export function useApprovalPolling({ + enabled, status, setStatus, approvalUrl, repository, requestId, onComplete, - onSuccess, - onError, + onTerminal, + onPollError, }: UseApprovalPollingOptions): void { const isWaiting = status === 'waiting' || status === 'polling'; @@ -38,13 +42,13 @@ export function useApprovalPolling({ ); useEffect(() => { - if (status !== 'waiting') return; + if (!enabled || status !== 'waiting') return; const timeout = setTimeout(() => setStatus('polling'), 1000); return () => clearTimeout(timeout); - }, [status, setStatus]); + }, [enabled, status, setStatus]); useEffect(() => { - if (status !== 'polling' || !requestId) return; + if (!enabled || status !== 'polling' || !requestId) return; let cancelled = false; @@ -52,21 +56,21 @@ export function useApprovalPolling({ try { const final = await pollUntilApproved(repository, requestId); if (cancelled) return; - if (final.status !== 'approved') { - onError( - `Spend request did not reach approved (status: ${final.status})`, - ); - setStatus('error'); - setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); - return; - } - onSuccess(final); - setStatus('success'); + + const resolvedStatus: PollingTerminalStatus = + final.status === 'approved' + ? 'approved' + : final.status === 'denied' + ? 'denied' + : final.status === 'expired' + ? 'expired' + : 'error'; + + onTerminal(final, resolvedStatus); setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); } catch (err) { if (!cancelled) { - onError((err as Error).message); - setStatus('error'); + onPollError((err as Error).message); } } }; @@ -76,12 +80,12 @@ export function useApprovalPolling({ cancelled = true; }; }, [ + enabled, status, requestId, repository, onComplete, - onSuccess, - onError, - setStatus, + onTerminal, + onPollError, ]); } diff --git a/packages/cli/src/utils/local-callback-server.ts b/packages/cli/src/utils/local-callback-server.ts new file mode 100644 index 0000000..b3d0027 --- /dev/null +++ b/packages/cli/src/utils/local-callback-server.ts @@ -0,0 +1,96 @@ +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +export type CallbackStatus = + | 'approved' + | 'denied' + | 'expired' + | 'error' + | 'timeout'; + +export interface CallbackResult { + status: CallbackStatus; +} + +export interface CallbackServer { + redirectUri: string; + waitForCallback: (timeoutMs?: number) => Promise; + close: () => void; +} + +// If no browser redirect arrives within this window, fall back to polling. +// Shorter than the approval window — polling recovers the result either way. +const DEFAULT_CALLBACK_TIMEOUT_MS = 2 * 60 * 1000; + +const VALID_STATUSES: ReadonlySet = new Set([ + 'approved', + 'denied', + 'expired', + 'error', +]); + +const STATUS_MESSAGES: Record = { + approved: 'Spend request approved. You can close this tab.', + denied: 'Spend request denied. You can close this tab.', + expired: 'Spend request expired. You can close this tab.', +}; + +export async function tryStartCallbackServer(): Promise { + try { + return await startCallbackServer(); + } catch { + return null; + } +} + +export function startCallbackServer(): Promise { + return new Promise((resolve, reject) => { + let resolveCallback: ((result: CallbackResult) => void) | undefined; + const callbackPromise = new Promise((res) => { + resolveCallback = res; + }); + + const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost'); + const raw = url.searchParams.get('status') ?? ''; + const status: CallbackStatus = VALID_STATUSES.has(raw) + ? (raw as CallbackStatus) + : 'error'; + const message = + STATUS_MESSAGES[raw] ?? 'Something went wrong. You can close this tab.'; + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`

${message}

`); + resolveCallback?.({ status }); + }); + + const startListening = (host: string, fallback?: string) => { + server.listen(0, host, () => { + const port = (server.address() as AddressInfo).port; + resolve({ + redirectUri: `http://localhost:${port}/callback`, + waitForCallback: (timeoutMs = DEFAULT_CALLBACK_TIMEOUT_MS) => { + let timerId: ReturnType; + const timeoutPromise = new Promise((res) => { + timerId = setTimeout(() => res({ status: 'timeout' }), timeoutMs); + }); + return Promise.race([callbackPromise, timeoutPromise]).finally(() => + clearTimeout(timerId), + ); + }, + close: () => server.close(), + }); + }); + + server.once('error', (err) => { + if (fallback) { + server.removeAllListeners('error'); + startListening(fallback); + } else { + reject(err); + } + }); + }; + + startListening('::1', '127.0.0.1'); + }); +} diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index b8d2134..90d8b00 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -63,7 +63,10 @@ export interface ISpendRequestResource { id: string, params: UpdateSpendRequestParams, ): Promise; - requestApproval(id: string): Promise; + requestApproval( + id: string, + options?: { redirect_uri?: string }, + ): Promise; cancel(id: string): Promise; cancelSpendRequest(id: string): Promise; retrieve( diff --git a/packages/sdk/src/resources/spend-request.ts b/packages/sdk/src/resources/spend-request.ts index eb43044..988af36 100644 --- a/packages/sdk/src/resources/spend-request.ts +++ b/packages/sdk/src/resources/spend-request.ts @@ -234,10 +234,18 @@ export class SpendRequestResource implements ISpendRequestResource { return normalizeSpendRequest(data); } - async requestApproval(id: string): Promise { + async requestApproval( + id: string, + options?: { redirect_uri?: string }, + ): Promise { + const body = options?.redirect_uri + ? JSON.stringify({ redirect_uri: options.redirect_uri }) + : undefined; const { status, data, rawBody } = await this.apiFetch({ method: 'POST', url: `${this.spendRequestsEndpoint}/${id}/request_approval`, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body, }); if (status < 200 || status >= 300) {