diff --git a/packages/cli/src/commands/spend-request/create.tsx b/packages/cli/src/commands/spend-request/create.tsx index 4478380..74892d4 100644 --- a/packages/cli/src/commands/spend-request/create.tsx +++ b/packages/cli/src/commands/spend-request/create.tsx @@ -4,12 +4,13 @@ import type { SpendRequest, } from '@stripe/link-sdk'; import { LinkApiError } from '@stripe/link-sdk'; -import { Box, Text, useApp } from 'ink'; +import { Box, Text, useApp, useInput } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; import { useCallback, useEffect, useState } from 'react'; import { DISPLAY_DELAY_MS } from '../../utils/constants'; import { writeCredentialFile } from '../../utils/credential-output'; +import { openUrl } from '../../utils/open-url'; import { AppDownloadQrCodes } from './app-download-qr-codes'; import { ApprovalWaitingView } from './approval-waiting-view'; import { useApprovalPolling } from './use-approval-polling'; @@ -31,18 +32,26 @@ export const CreateSpendRequest: React.FC = ({ force, onComplete, }) => { + const { exit } = useApp(); + const [status, setStatus] = useState< - 'creating' | 'waiting' | 'polling' | 'success' | 'error' + | 'creating' + | 'waiting' + | 'polling' + | 'success' + | 'error' + | 'verification_required' + | 'opened' >('creating'); const [request, setRequest] = useState(null); const [error, setError] = useState(''); const [verificationUrl, setVerificationUrl] = useState(''); const [supportUrl, setSupportUrl] = useState(''); + const [countdown, setCountdown] = useState(30); const [outputFilePath, setOutputFilePath] = useState(null); const [fileError, setFileError] = useState(''); const approvalUrl = request?.approval_url ?? ''; - const { exit } = useApp(); const completeAndExit = useCallback( (result: SpendRequest | null) => { @@ -69,6 +78,28 @@ export const CreateSpendRequest: React.FC = ({ onError, }); + useInput((_, key) => { + if ( + key.return && + (verificationUrl || supportUrl) && + status === 'verification_required' + ) { + openUrl(verificationUrl || supportUrl); + setStatus('opened'); + setTimeout(() => completeAndExit(null), DISPLAY_DELAY_MS); + } + }); + + useEffect(() => { + if (status !== 'verification_required') return; + if (countdown <= 0) { + completeAndExit(null); + return; + } + const timer = setTimeout(() => setCountdown((c) => c - 1), 1000); + return () => clearTimeout(timer); + }, [status, countdown, completeAndExit]); + useEffect(() => { const create = async () => { try { @@ -91,6 +122,13 @@ export const CreateSpendRequest: React.FC = ({ setVerificationUrl(errDetail.error.verification_url); if (errDetail?.error?.support_url) setSupportUrl(errDetail.error.support_url); + if ( + errDetail?.error?.verification_url || + errDetail?.error?.support_url + ) { + setStatus('verification_required'); + return; + } } setStatus('error'); setTimeout(() => completeAndExit(null), DISPLAY_DELAY_MS); @@ -116,6 +154,43 @@ export const CreateSpendRequest: React.FC = ({ .catch((err) => setFileError((err as Error).message)); }, [status, outputFile, force, request]); + if (status === 'verification_required') { + const url = verificationUrl || supportUrl; + return ( + + ✗ Failed to create spend request + {error} + + + Open:{' '} + + {url} + + + Press Enter to open in browser + Exiting in {countdown}s... + + + ); + } + + if (status === 'opened') { + return ( + + ✗ Failed to create spend request + {error} + ✓ Opened verification URL in browser + + ); + } + if (status === 'creating') { return ( @@ -131,16 +206,6 @@ export const CreateSpendRequest: React.FC = ({ ✗ Failed to create spend request {error} - {verificationUrl && ( - - Complete additional verification at: {verificationUrl} - - )} - {supportUrl && ( - - Identity verification failed. Contact support at: {supportUrl} - - )} ); } diff --git a/packages/cli/src/commands/spend-request/request-approval.tsx b/packages/cli/src/commands/spend-request/request-approval.tsx index 3de8c8d..9dd8dc5 100644 --- a/packages/cli/src/commands/spend-request/request-approval.tsx +++ b/packages/cli/src/commands/spend-request/request-approval.tsx @@ -1,10 +1,11 @@ -import { LinkApiError } from '@stripe/link-sdk'; import type { ISpendRequestResource, SpendRequest } from '@stripe/link-sdk'; -import { Box, Text, useApp } from 'ink'; +import { LinkApiError } from '@stripe/link-sdk'; +import { Box, Text, useApp, useInput } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; import { useCallback, useEffect, useState } from 'react'; import { DISPLAY_DELAY_MS } from '../../utils/constants'; +import { openUrl } from '../../utils/open-url'; import { ApprovalWaitingView } from './approval-waiting-view'; import { useApprovalPolling } from './use-approval-polling'; @@ -19,24 +20,32 @@ export const RequestApproval: React.FC = ({ id, onComplete, }) => { - const [status, setStatus] = useState< - 'requesting' | 'waiting' | 'polling' | 'success' | 'error' - >('requesting'); - const [approvalUrl, setApprovalUrl] = useState(''); - const [result, setResult] = useState(null); - const [error, setError] = useState(''); - const [verificationUrl, setVerificationUrl] = useState(''); - const [supportUrl, setSupportUrl] = useState(''); const { exit } = useApp(); const completeAndExit = useCallback( - (result: SpendRequest) => { + (result: SpendRequest | null) => { onComplete(result); exit(); }, [onComplete, exit], ); + const [status, setStatus] = useState< + | 'requesting' + | 'waiting' + | 'polling' + | 'success' + | 'error' + | 'verification_required' + | 'opened' + >('requesting'); + const [approvalUrl, setApprovalUrl] = useState(''); + const [result, setResult] = useState(null); + const [error, setError] = useState(''); + const [verificationUrl, setVerificationUrl] = useState(''); + const [supportUrl, setSupportUrl] = useState(''); + const [countdown, setCountdown] = useState(30); + const onSuccess = useCallback((r: SpendRequest) => setResult(r), []); const onError = useCallback((msg: string) => setError(msg), []); @@ -51,6 +60,28 @@ export const RequestApproval: React.FC = ({ onError, }); + useInput((_, key) => { + if ( + key.return && + (verificationUrl || supportUrl) && + status === 'verification_required' + ) { + openUrl(verificationUrl || supportUrl); + setStatus('opened'); + setTimeout(() => completeAndExit(null), DISPLAY_DELAY_MS); + } + }); + + useEffect(() => { + if (status !== 'verification_required') return; + if (countdown <= 0) { + completeAndExit(null); + return; + } + const timer = setTimeout(() => setCountdown((c) => c - 1), 1000); + return () => clearTimeout(timer); + }, [status, countdown, completeAndExit]); + useEffect(() => { const request = async () => { try { @@ -67,6 +98,13 @@ export const RequestApproval: React.FC = ({ setVerificationUrl(errDetail.error.verification_url); if (errDetail?.error?.support_url) setSupportUrl(errDetail.error.support_url); + if ( + errDetail?.error?.verification_url || + errDetail?.error?.support_url + ) { + setStatus('verification_required'); + return; + } } setStatus('error'); setTimeout(() => { @@ -77,7 +115,44 @@ export const RequestApproval: React.FC = ({ }; request(); - }, [repository, id, exit, onComplete]); + }, [repository, id, onComplete, exit]); + + if (status === 'verification_required') { + const url = verificationUrl || supportUrl; + return ( + + ✗ Failed to request approval + {error} + + + Open:{' '} + + {url} + + + Press Enter to open in browser + Exiting in {countdown}s... + + + ); + } + + if (status === 'opened') { + return ( + + ✗ Failed to request approval + {error} + ✓ Opened verification URL in browser + + ); + } if (status === 'requesting') { return ( @@ -94,16 +169,6 @@ export const RequestApproval: React.FC = ({ ✗ Failed to request approval {error} - {verificationUrl && ( - - Complete additional verification at: {verificationUrl} - - )} - {supportUrl && ( - - Identity verification failed. Contact support at: {supportUrl} - - )} ); }