Skip to content

Commit 1b59952

Browse files
replace approval polling with redirect URI callback
1 parent 90ebe54 commit 1b59952

7 files changed

Lines changed: 188 additions & 159 deletions

File tree

packages/cli/src/commands/spend-request/approval-waiting-view.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,10 @@ import type React from 'react';
44
import { AppDownloadQrCodes } from './app-download-qr-codes';
55

66
interface ApprovalWaitingViewProps {
7-
status: 'waiting' | 'polling';
87
approvalUrl: string;
98
}
109

1110
export const ApprovalWaitingView: React.FC<ApprovalWaitingViewProps> = ({
12-
status,
1311
approvalUrl,
1412
}) => (
1513
<Box flexDirection="column" paddingY={1}>
@@ -30,13 +28,9 @@ export const ApprovalWaitingView: React.FC<ApprovalWaitingViewProps> = ({
3028
</Box>
3129
<AppDownloadQrCodes />
3230
<Box marginTop={1}>
33-
{status === 'polling' ? (
34-
<Text color="cyan">
35-
<Spinner type="dots" /> Waiting for approval...
36-
</Text>
37-
) : (
38-
<Text dimColor>Waiting...</Text>
39-
)}
31+
<Text color="cyan">
32+
<Spinner type="dots" /> Waiting for approval...
33+
</Text>
4034
</Box>
4135
</Box>
4236
);

packages/cli/src/commands/spend-request/create.tsx

Lines changed: 72 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@ import type {
33
ISpendRequestResource,
44
SpendRequest,
55
} from '@stripe/link-sdk';
6-
import { Box, Text } from 'ink';
6+
import { Box, Text, useInput } from 'ink';
77
import Spinner from 'ink-spinner';
88
import type React from 'react';
9-
import { useCallback, useEffect, useState } from 'react';
9+
import { useEffect, useState } from 'react';
1010
import { DISPLAY_DELAY_MS } from '../../utils/constants';
1111
import { writeCredentialFile } from '../../utils/credential-output';
12+
import { startCallbackServer } from '../../utils/local-callback-server';
13+
import { openUrl } from '../../utils/open-url';
1214
import { AppDownloadQrCodes } from './app-download-qr-codes';
1315
import { ApprovalWaitingView } from './approval-waiting-view';
14-
import { useApprovalPolling } from './use-approval-polling';
1516

1617
interface CreateSpendRequestProps {
1718
repository: ISpendRequestResource;
@@ -31,52 +32,94 @@ export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
3132
onComplete,
3233
}) => {
3334
const [status, setStatus] = useState<
34-
'creating' | 'waiting' | 'polling' | 'success' | 'error'
35+
'creating' | 'waiting' | 'success' | 'error'
3536
>('creating');
3637
const [request, setRequest] = useState<SpendRequest | null>(null);
38+
const [approvalUrl, setApprovalUrl] = useState<string>('');
3739
const [error, setError] = useState<string>('');
3840
const [outputFilePath, setOutputFilePath] = useState<string | null>(null);
3941
const [fileError, setFileError] = useState<string>('');
4042

41-
const approvalUrl = request?.approval_url ?? '';
42-
43-
const onSuccess = useCallback(
44-
(result: SpendRequest) => setRequest(result),
45-
[],
43+
useInput(
44+
(_input, key) => {
45+
if (key.return && approvalUrl) openUrl(approvalUrl);
46+
},
47+
{ isActive: status === 'waiting' },
4648
);
47-
const onError = useCallback((msg: string) => setError(msg), []);
48-
49-
useApprovalPolling({
50-
status,
51-
setStatus,
52-
approvalUrl,
53-
repository,
54-
requestId: request?.id ?? null,
55-
onComplete,
56-
onSuccess,
57-
onError,
58-
});
5949

6050
useEffect(() => {
51+
let close: (() => void) | null = null;
52+
let cancelled = false;
53+
6154
const create = async () => {
6255
try {
63-
const result = await repository.createSpendRequest(params);
56+
let server: Awaited<ReturnType<typeof startCallbackServer>> | null =
57+
null;
58+
if (requestApproval) {
59+
server = await startCallbackServer();
60+
close = server.close;
61+
}
62+
63+
// Strip request_approval so the spend request starts in `created` state; we call requestApproval explicitly below.
64+
const { request_approval: _, ...createParams } = params;
65+
const result = await repository.createSpendRequest(createParams);
66+
if (cancelled) return;
6467
setRequest(result);
6568

66-
if (requestApproval) {
67-
setStatus('waiting');
68-
} else {
69+
if (!requestApproval || !server) {
6970
setStatus('success');
7071
setTimeout(() => onComplete(result), DISPLAY_DELAY_MS);
72+
return;
73+
}
74+
75+
const approval = await repository.requestApproval(result.id, {
76+
redirect_uri: server.redirectUri,
77+
});
78+
if (cancelled) return;
79+
80+
setApprovalUrl(approval.approval_link);
81+
setStatus('waiting');
82+
83+
const { approved } = await server.waitForCallback();
84+
if (cancelled) return;
85+
86+
const final = await repository.retrieve(result.id);
87+
if (cancelled) return;
88+
89+
if (!final) {
90+
setError('Spend request not found after approval');
91+
setStatus('error');
92+
setTimeout(() => onComplete(null), DISPLAY_DELAY_MS);
93+
return;
94+
}
95+
96+
if (approved && final.status === 'approved') {
97+
setRequest(final);
98+
setStatus('success');
99+
setTimeout(() => onComplete(final), DISPLAY_DELAY_MS);
100+
} else {
101+
setError(
102+
`Spend request did not reach approved (status: ${final.status})`,
103+
);
104+
setStatus('error');
105+
setTimeout(() => onComplete(final), DISPLAY_DELAY_MS);
71106
}
72107
} catch (err) {
73-
setError((err as Error).message);
74-
setStatus('error');
75-
setTimeout(() => onComplete(null), DISPLAY_DELAY_MS);
108+
if (!cancelled) {
109+
setError((err as Error).message);
110+
setStatus('error');
111+
setTimeout(() => onComplete(null), DISPLAY_DELAY_MS);
112+
}
113+
} finally {
114+
close?.();
76115
}
77116
};
78117

79118
create();
119+
return () => {
120+
cancelled = true;
121+
close?.();
122+
};
80123
}, [repository, params, requestApproval, onComplete]);
81124

82125
useEffect(() => {
@@ -206,10 +249,7 @@ export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
206249
✓ Spend request created (ID: <Text bold>{request?.id}</Text>)
207250
</Text>
208251
</Box>
209-
<ApprovalWaitingView
210-
status={status as 'waiting' | 'polling'}
211-
approvalUrl={approvalUrl}
212-
/>
252+
<ApprovalWaitingView approvalUrl={approvalUrl} />
213253
</>
214254
);
215255
};

packages/cli/src/commands/spend-request/request-approval.tsx

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { ISpendRequestResource, SpendRequest } from '@stripe/link-sdk';
2-
import { Box, Text } from 'ink';
2+
import { Box, Text, useInput } from 'ink';
33
import Spinner from 'ink-spinner';
44
import type React from 'react';
5-
import { useCallback, useEffect, useState } from 'react';
5+
import { useEffect, useState } from 'react';
6+
import { DISPLAY_DELAY_MS } from '../../utils/constants';
7+
import { startCallbackServer } from '../../utils/local-callback-server';
8+
import { openUrl } from '../../utils/open-url';
69
import { ApprovalWaitingView } from './approval-waiting-view';
7-
import { useApprovalPolling } from './use-approval-polling';
810

911
interface RequestApprovalProps {
1012
repository: ISpendRequestResource;
@@ -18,40 +20,75 @@ export const RequestApproval: React.FC<RequestApprovalProps> = ({
1820
onComplete,
1921
}) => {
2022
const [status, setStatus] = useState<
21-
'requesting' | 'waiting' | 'polling' | 'success' | 'error'
23+
'requesting' | 'waiting' | 'success' | 'error'
2224
>('requesting');
2325
const [approvalUrl, setApprovalUrl] = useState<string>('');
2426
const [result, setResult] = useState<SpendRequest | null>(null);
2527
const [error, setError] = useState<string>('');
2628

27-
const onSuccess = useCallback((r: SpendRequest) => setResult(r), []);
28-
const onError = useCallback((msg: string) => setError(msg), []);
29-
30-
useApprovalPolling({
31-
status,
32-
setStatus,
33-
approvalUrl,
34-
repository,
35-
requestId: id,
36-
onComplete,
37-
onSuccess,
38-
onError,
39-
});
29+
useInput(
30+
(_input, key) => {
31+
if (key.return && approvalUrl) openUrl(approvalUrl);
32+
},
33+
{ isActive: status === 'waiting' },
34+
);
4035

4136
useEffect(() => {
42-
const request = async () => {
37+
let close: (() => void) | null = null;
38+
let cancelled = false;
39+
40+
const run = async () => {
4341
try {
44-
const res = await repository.requestApproval(id);
42+
const server = await startCallbackServer();
43+
close = server.close;
44+
45+
const res = await repository.requestApproval(id, {
46+
redirect_uri: server.redirectUri,
47+
});
48+
if (cancelled) return;
49+
4550
setApprovalUrl(res.approval_link);
4651
setStatus('waiting');
52+
53+
const { approved } = await server.waitForCallback();
54+
if (cancelled) return;
55+
56+
const final = await repository.retrieve(id);
57+
if (cancelled) return;
58+
59+
if (!final) {
60+
setError('Spend request not found after approval');
61+
setStatus('error');
62+
return;
63+
}
64+
65+
if (approved && final.status === 'approved') {
66+
setResult(final);
67+
setStatus('success');
68+
setTimeout(() => onComplete(final), DISPLAY_DELAY_MS);
69+
} else {
70+
setError(
71+
`Spend request did not reach approved (status: ${final.status})`,
72+
);
73+
setStatus('error');
74+
setTimeout(() => onComplete(final), DISPLAY_DELAY_MS);
75+
}
4776
} catch (err) {
48-
setError((err as Error).message);
49-
setStatus('error');
77+
if (!cancelled) {
78+
setError((err as Error).message);
79+
setStatus('error');
80+
}
81+
} finally {
82+
close?.();
5083
}
5184
};
5285

53-
request();
54-
}, [repository, id]);
86+
run();
87+
return () => {
88+
cancelled = true;
89+
close?.();
90+
};
91+
}, [repository, id, onComplete]);
5592

5693
if (status === 'requesting') {
5794
return (
@@ -105,10 +142,5 @@ export const RequestApproval: React.FC<RequestApprovalProps> = ({
105142
);
106143
}
107144

108-
return (
109-
<ApprovalWaitingView
110-
status={status as 'waiting' | 'polling'}
111-
approvalUrl={approvalUrl}
112-
/>
113-
);
145+
return <ApprovalWaitingView approvalUrl={approvalUrl} />;
114146
};

packages/cli/src/commands/spend-request/use-approval-polling.ts

Lines changed: 0 additions & 87 deletions
This file was deleted.

0 commit comments

Comments
 (0)