@12-apps/payments-frontend 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/eslint.config.js +34 -0
- package/package.json +63 -0
- package/src/__tests__/checkout-confirmation.test.tsx +177 -0
- package/src/__tests__/connection-state.test.tsx +84 -0
- package/src/__tests__/context.test.tsx +88 -0
- package/src/__tests__/controlled-provider.test.tsx +116 -0
- package/src/__tests__/credential-confirm.test.tsx +193 -0
- package/src/__tests__/initial-provider.test.tsx +81 -0
- package/src/__tests__/provider-priority-list.test.tsx +159 -0
- package/src/__tests__/provider-status-bar.test.tsx +152 -0
- package/src/__tests__/verification-slot.test.tsx +125 -0
- package/src/client.ts +200 -0
- package/src/components/CheckoutFlow.tsx +169 -0
- package/src/components/CheckoutPayment.tsx +379 -0
- package/src/components/ConfirmCredentialSave.tsx +106 -0
- package/src/components/CredentialFields.tsx +158 -0
- package/src/components/CredentialFormAlerts.tsx +144 -0
- package/src/components/EnvironmentTabs.tsx +109 -0
- package/src/components/PaymentProviderSettings.tsx +267 -0
- package/src/components/ProviderConnection.tsx +251 -0
- package/src/components/ProviderCredentialForm.tsx +387 -0
- package/src/components/ProviderList.tsx +126 -0
- package/src/components/ProviderPanel.tsx +300 -0
- package/src/components/ProviderPriorityList.tsx +293 -0
- package/src/components/ProviderSetupGuide.tsx +263 -0
- package/src/components/ProviderStatusBar.tsx +192 -0
- package/src/components/SetupGuideSection.tsx +190 -0
- package/src/components/checkout-ack.ts +106 -0
- package/src/components/connection-state.ts +66 -0
- package/src/components/credential-rules.ts +120 -0
- package/src/components/rich-text.tsx +27 -0
- package/src/components/settings-state.ts +211 -0
- package/src/context.tsx +144 -0
- package/src/index.ts +69 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Alert,
|
|
5
|
+
Button,
|
|
6
|
+
Chip,
|
|
7
|
+
CircularProgress,
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogActions,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogContentText,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
Stack,
|
|
14
|
+
Typography,
|
|
15
|
+
} from '@mui/material';
|
|
16
|
+
import { useState } from 'react';
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
MaskedProviderConfig,
|
|
20
|
+
PaymentEnvironment,
|
|
21
|
+
ProviderDescriptor,
|
|
22
|
+
} from '@12-apps/payments-backend';
|
|
23
|
+
|
|
24
|
+
import type { PaymentsSettingsClient } from '../client';
|
|
25
|
+
import { isConnected } from './connection-state';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The `authMode: 'oauth'` half of the settings page: a provider whose
|
|
29
|
+
* connection is a BUTTON, not a form. Replaces the credential fields with a
|
|
30
|
+
* connection card — connect / reconnect / disconnect plus the connection's
|
|
31
|
+
* live state.
|
|
32
|
+
*
|
|
33
|
+
* The `state` parameter is minted and stored server-side by the host and
|
|
34
|
+
* echoed back on the callback; this component only relays it, so a forged
|
|
35
|
+
* connect attempt cannot originate here.
|
|
36
|
+
*/
|
|
37
|
+
export interface ProviderConnectionProps {
|
|
38
|
+
descriptor: ProviderDescriptor;
|
|
39
|
+
config: MaskedProviderConfig | null;
|
|
40
|
+
client: PaymentsSettingsClient;
|
|
41
|
+
/**
|
|
42
|
+
* Host endpoint that mints + persists the CSRF state against the admin
|
|
43
|
+
* session and returns it with the callback URL to come back to.
|
|
44
|
+
*/
|
|
45
|
+
prepareConnect: (
|
|
46
|
+
provider: string,
|
|
47
|
+
environment: PaymentEnvironment,
|
|
48
|
+
) => Promise<{ state: string; redirectUri: string; environment?: PaymentEnvironment }>;
|
|
49
|
+
onChanged: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function expiryNote(expiresAt: string | null): string | null {
|
|
53
|
+
if (!expiresAt) return null;
|
|
54
|
+
const when = new Date(expiresAt);
|
|
55
|
+
return `Autorização válida até ${when.toLocaleString('pt-BR')}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function connectLabel(displayName: string, connected: boolean, busy: string | null) {
|
|
59
|
+
if (busy === 'connect') return <CircularProgress size={18} />;
|
|
60
|
+
return connected ? 'Reconectar' : `Conectar com ${displayName}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Header + explanatory copy + any warning banner for the connection. */
|
|
64
|
+
function ConnectionSummary(props: {
|
|
65
|
+
displayName: string;
|
|
66
|
+
status: string;
|
|
67
|
+
connected: boolean;
|
|
68
|
+
expiresAt: string | null;
|
|
69
|
+
environment: PaymentEnvironment;
|
|
70
|
+
}) {
|
|
71
|
+
return (
|
|
72
|
+
<>
|
|
73
|
+
{/*
|
|
74
|
+
No name and no status chip here: the provider header above this card
|
|
75
|
+
already carries both, and printing them twice produced two "PagBank"
|
|
76
|
+
headings stacked on top of each other with different-looking states.
|
|
77
|
+
Only the environment — which the header cannot know — is shown, and only
|
|
78
|
+
once connected, since the provider sealed it into the grant.
|
|
79
|
+
*/}
|
|
80
|
+
{props.connected ? (
|
|
81
|
+
<Stack direction="row" spacing={1} alignItems="center">
|
|
82
|
+
<Chip
|
|
83
|
+
size="small"
|
|
84
|
+
variant="outlined"
|
|
85
|
+
data-testid="payments-connected-environment"
|
|
86
|
+
label={props.environment === 'PRODUCTION' ? 'Produção' : 'Sandbox (testes)'}
|
|
87
|
+
color={props.environment === 'PRODUCTION' ? 'default' : 'warning'}
|
|
88
|
+
/>
|
|
89
|
+
</Stack>
|
|
90
|
+
) : null}
|
|
91
|
+
<Typography variant="body2" color="text.secondary">
|
|
92
|
+
{props.connected
|
|
93
|
+
? 'Sua conta está conectada. O Future Pay cria as cobranças em seu nome — nenhuma chave precisa ser copiada.'
|
|
94
|
+
: `Conecte sua conta ${props.displayName} autorizando o acesso no site do provedor. Nenhuma chave precisa ser copiada.`}
|
|
95
|
+
</Typography>
|
|
96
|
+
{props.status === 'RECONNECT_REQUIRED' ? (
|
|
97
|
+
<Alert severity="warning">
|
|
98
|
+
A autorização expirou ou foi revogada. Reconecte para voltar a receber pagamentos.
|
|
99
|
+
</Alert>
|
|
100
|
+
) : null}
|
|
101
|
+
{props.expiresAt ? (
|
|
102
|
+
<Typography variant="caption" color="text.secondary">
|
|
103
|
+
{expiryNote(props.expiresAt)}
|
|
104
|
+
</Typography>
|
|
105
|
+
) : null}
|
|
106
|
+
</>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Confirmation for Desconectar, which is destructive and irreversible from
|
|
112
|
+
* here: it revokes the grant at the provider, so the store stops being able to
|
|
113
|
+
* charge immediately and getting back requires the owner to authorize again on
|
|
114
|
+
* the provider's site. It also sat one careless click from "Reconectar".
|
|
115
|
+
*/
|
|
116
|
+
function DisconnectDialog(props: {
|
|
117
|
+
open: boolean;
|
|
118
|
+
displayName: string;
|
|
119
|
+
busy: boolean;
|
|
120
|
+
onCancel: () => void;
|
|
121
|
+
onConfirm: () => void;
|
|
122
|
+
}) {
|
|
123
|
+
return (
|
|
124
|
+
<Dialog open={props.open} onClose={props.onCancel} data-testid="payments-disconnect-confirm">
|
|
125
|
+
<DialogTitle>Desconectar {props.displayName}?</DialogTitle>
|
|
126
|
+
<DialogContent>
|
|
127
|
+
<DialogContentText>
|
|
128
|
+
A autorização será revogada no {props.displayName} e sua loja deixa de conseguir cobrar
|
|
129
|
+
imediatamente. Para voltar a receber, será necessário conectar a conta novamente
|
|
130
|
+
autorizando o acesso no site do provedor.
|
|
131
|
+
</DialogContentText>
|
|
132
|
+
</DialogContent>
|
|
133
|
+
<DialogActions>
|
|
134
|
+
<Button onClick={props.onCancel} disabled={props.busy}>
|
|
135
|
+
Cancelar
|
|
136
|
+
</Button>
|
|
137
|
+
<Button
|
|
138
|
+
color="error"
|
|
139
|
+
variant="contained"
|
|
140
|
+
onClick={props.onConfirm}
|
|
141
|
+
disabled={props.busy}
|
|
142
|
+
data-testid="payments-disconnect-confirm-action"
|
|
143
|
+
>
|
|
144
|
+
{props.busy ? <CircularProgress size={18} /> : 'Desconectar'}
|
|
145
|
+
</Button>
|
|
146
|
+
</DialogActions>
|
|
147
|
+
</Dialog>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function ConnectionActions(props: {
|
|
152
|
+
displayName: string;
|
|
153
|
+
connected: boolean;
|
|
154
|
+
busy: string | null;
|
|
155
|
+
onConnect: () => void;
|
|
156
|
+
onDisconnect: () => void;
|
|
157
|
+
}) {
|
|
158
|
+
const { displayName, connected, busy, onConnect, onDisconnect } = props;
|
|
159
|
+
return (
|
|
160
|
+
<Stack direction="row" spacing={1}>
|
|
161
|
+
<Button variant="contained" disabled={busy !== null} onClick={onConnect}>
|
|
162
|
+
{connectLabel(displayName, connected, busy)}
|
|
163
|
+
</Button>
|
|
164
|
+
{connected ? (
|
|
165
|
+
<Button variant="outlined" color="error" disabled={busy !== null} onClick={onDisconnect}>
|
|
166
|
+
{busy === 'disconnect' ? <CircularProgress size={18} /> : 'Desconectar'}
|
|
167
|
+
</Button>
|
|
168
|
+
) : null}
|
|
169
|
+
</Stack>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** One in-flight action at a time, with its failure surfaced to the owner. */
|
|
174
|
+
function useConnectionAction(onChanged: () => void) {
|
|
175
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
176
|
+
const [error, setError] = useState<string | null>(null);
|
|
177
|
+
|
|
178
|
+
const run = async (kind: string, action: () => Promise<void>) => {
|
|
179
|
+
setBusy(kind);
|
|
180
|
+
setError(null);
|
|
181
|
+
try {
|
|
182
|
+
await action();
|
|
183
|
+
onChanged();
|
|
184
|
+
} catch (err) {
|
|
185
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
186
|
+
} finally {
|
|
187
|
+
setBusy(null);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return { busy, error, run };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function ProviderConnection(props: ProviderConnectionProps) {
|
|
195
|
+
const { descriptor, config, client, prepareConnect, onChanged } = props;
|
|
196
|
+
const { busy, error, run } = useConnectionAction(onChanged);
|
|
197
|
+
const [confirmingDisconnect, setConfirmingDisconnect] = useState(false);
|
|
198
|
+
// Which account the owner is connecting. Follows whatever this provider is
|
|
199
|
+
// already configured for, defaulting to SANDBOX so a live grant is never the
|
|
200
|
+
// accident. Changing it is an ADVANCED action and lives with the manual
|
|
201
|
+
// credentials, not on the one-button connect card.
|
|
202
|
+
const environment: PaymentEnvironment = config?.environment ?? 'SANDBOX';
|
|
203
|
+
const status = config?.status ?? 'UNVERIFIED';
|
|
204
|
+
const connected = isConnected(config);
|
|
205
|
+
|
|
206
|
+
const connect = () =>
|
|
207
|
+
void run('connect', async () => {
|
|
208
|
+
// The host re-reads and seals the environment server-side; sending it
|
|
209
|
+
// here is what stops a SANDBOX choice from authorizing a LIVE account.
|
|
210
|
+
const ctx = await prepareConnect(descriptor.name, environment);
|
|
211
|
+
const { url } = await client.beginOAuth(descriptor.name, { environment, ...ctx });
|
|
212
|
+
// Full-page navigation: the provider's consent screen must be a
|
|
213
|
+
// top-level document, never an iframe.
|
|
214
|
+
window.location.assign(url);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<Stack spacing={2}>
|
|
219
|
+
<ConnectionSummary
|
|
220
|
+
displayName={descriptor.displayName}
|
|
221
|
+
status={status}
|
|
222
|
+
connected={connected}
|
|
223
|
+
expiresAt={config?.expiresAt ?? null}
|
|
224
|
+
environment={environment}
|
|
225
|
+
/>
|
|
226
|
+
|
|
227
|
+
{error ? <Alert severity="error">{error}</Alert> : null}
|
|
228
|
+
|
|
229
|
+
<ConnectionActions
|
|
230
|
+
displayName={descriptor.displayName}
|
|
231
|
+
connected={connected}
|
|
232
|
+
busy={busy}
|
|
233
|
+
onConnect={connect}
|
|
234
|
+
onDisconnect={() => setConfirmingDisconnect(true)}
|
|
235
|
+
/>
|
|
236
|
+
|
|
237
|
+
<DisconnectDialog
|
|
238
|
+
open={confirmingDisconnect}
|
|
239
|
+
displayName={descriptor.displayName}
|
|
240
|
+
busy={busy === 'disconnect'}
|
|
241
|
+
onCancel={() => setConfirmingDisconnect(false)}
|
|
242
|
+
onConfirm={() =>
|
|
243
|
+
void run('disconnect', async () => {
|
|
244
|
+
await client.disconnectOAuth(descriptor.name);
|
|
245
|
+
setConfirmingDisconnect(false);
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
/>
|
|
249
|
+
</Stack>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Alert, Stack } from '@mui/material';
|
|
4
|
+
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
CredentialFieldSpec,
|
|
8
|
+
MaskedProviderConfig,
|
|
9
|
+
PaymentEnvironment,
|
|
10
|
+
ProviderDescriptor,
|
|
11
|
+
} from '@12-apps/payments-backend';
|
|
12
|
+
|
|
13
|
+
import type { PaymentsSettingsClient } from '../client';
|
|
14
|
+
|
|
15
|
+
import { ConfirmCredentialSave, type PendingSave } from './ConfirmCredentialSave';
|
|
16
|
+
import { isConnected } from './connection-state';
|
|
17
|
+
import { CredentialField, DoneRow } from './CredentialFields';
|
|
18
|
+
import {
|
|
19
|
+
allRequiredStored,
|
|
20
|
+
fieldsWellFormed,
|
|
21
|
+
needsConfirmation,
|
|
22
|
+
saveLabel,
|
|
23
|
+
summaryOf,
|
|
24
|
+
} from './credential-rules';
|
|
25
|
+
import {
|
|
26
|
+
FormActions,
|
|
27
|
+
ProbeAlert,
|
|
28
|
+
ReverifyWarning,
|
|
29
|
+
type VerifyProbe,
|
|
30
|
+
} from './CredentialFormAlerts';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The `authMode: 'credentials'` half of the settings page — a provider whose
|
|
34
|
+
* connection is a FORM, rendered entirely from its `credentialSchema` so
|
|
35
|
+
* adding a vendor changes nothing here.
|
|
36
|
+
*
|
|
37
|
+
* Secrets are WRITE-ONLY: a stored value shows as a `••••1234` hint and is
|
|
38
|
+
* never echoed back, and leaving a field blank PRESERVES what is stored
|
|
39
|
+
* (which is what makes rotating one key without re-typing the others work).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** Mutation runner shared by save/verify/enable: busy + error + refresh. */
|
|
43
|
+
function useSettingsAction(
|
|
44
|
+
onChanged: ((c: MaskedProviderConfig) => void) | undefined,
|
|
45
|
+
onSaved: () => void,
|
|
46
|
+
) {
|
|
47
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
48
|
+
const [error, setError] = useState<string | null>(null);
|
|
49
|
+
// Generic in the action's result: `verify` returns a MaskedProviderConfig
|
|
50
|
+
// with the probe outcome attached, and narrowing it here would throw away
|
|
51
|
+
// the only field that reports what the probe actually found.
|
|
52
|
+
const run = useCallback(
|
|
53
|
+
async <T extends MaskedProviderConfig>(
|
|
54
|
+
label: string,
|
|
55
|
+
action: () => Promise<T>,
|
|
56
|
+
): Promise<T | null> => {
|
|
57
|
+
setBusy(label);
|
|
58
|
+
setError(null);
|
|
59
|
+
try {
|
|
60
|
+
const next = await action();
|
|
61
|
+
onChanged?.(next);
|
|
62
|
+
onSaved();
|
|
63
|
+
return next;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
66
|
+
return null;
|
|
67
|
+
} finally {
|
|
68
|
+
setBusy(null);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
[onChanged, onSaved],
|
|
72
|
+
);
|
|
73
|
+
return { busy, error, run };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface ProviderFormProps {
|
|
77
|
+
descriptor: ProviderDescriptor;
|
|
78
|
+
config: MaskedProviderConfig | null;
|
|
79
|
+
client: PaymentsSettingsClient;
|
|
80
|
+
/**
|
|
81
|
+
* Which credential set is being edited.
|
|
82
|
+
*
|
|
83
|
+
* Owned by the PANEL, not by this form: the environment tabs sit at the top
|
|
84
|
+
* of the provider's card, above the walkthrough, because the choice frames
|
|
85
|
+
* everything below it — including which step the guide thinks you are on. A
|
|
86
|
+
* selector rendered halfway down, under the stepper it changes the meaning
|
|
87
|
+
* of, reads as a property of the fields rather than of the whole screen.
|
|
88
|
+
*/
|
|
89
|
+
environment: PaymentEnvironment;
|
|
90
|
+
/** The owner has reopened a finished step. Owned by the panel — see there. */
|
|
91
|
+
editing: boolean;
|
|
92
|
+
onEditingChange: (editing: boolean) => void;
|
|
93
|
+
onChanged?: (config: MaskedProviderConfig) => void;
|
|
94
|
+
onSaved: () => void;
|
|
95
|
+
/**
|
|
96
|
+
* The stored credentials were REPLACED — a different account may now be on
|
|
97
|
+
* record.
|
|
98
|
+
*
|
|
99
|
+
* Distinct from `onSaved`, which merely means "refetch". Everything the owner
|
|
100
|
+
* has told us about the OLD account stops applying: "I switched Checkout
|
|
101
|
+
* Integrado on" was a claim about a specific InfinitePay account, and
|
|
102
|
+
* carrying it across to another one is how a store ends up on step 3 with a
|
|
103
|
+
* setting nobody has enabled there. The server already drops what IT knew
|
|
104
|
+
* (`applySaveCredentials` clears the status and the proof); this is the same
|
|
105
|
+
* rule for the one fact the server does not hold.
|
|
106
|
+
*/
|
|
107
|
+
onCredentialsReplaced?: () => void;
|
|
108
|
+
/**
|
|
109
|
+
* The walkthrough, given this form's two pieces to place.
|
|
110
|
+
*
|
|
111
|
+
* A render prop rather than a rendered node because the two interleave: the
|
|
112
|
+
* finished credential belongs in the row strip above the open card, and the
|
|
113
|
+
* live field belongs INSIDE it, under the instruction that asks for it. Only
|
|
114
|
+
* this component knows which of the two it currently has.
|
|
115
|
+
*/
|
|
116
|
+
renderGuide?: (slots: {
|
|
117
|
+
rows: ReactNode;
|
|
118
|
+
sectionFooter: ReactNode;
|
|
119
|
+
/** The owner reopened a finished step — the guide must go back to it. */
|
|
120
|
+
editing: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* The environment ON SCREEN holds every required credential.
|
|
123
|
+
*
|
|
124
|
+
* Credentials are stored PER environment while `status`, the proof and the
|
|
125
|
+
* walkthrough's stage all describe the ACTIVE one — so a store that
|
|
126
|
+
* connected in Sandbox and then opened the Produção tab was shown a
|
|
127
|
+
* walkthrough reporting steps 1 and 2 done, over an empty field, for an
|
|
128
|
+
* environment that had never been connected at all. On the screen whose
|
|
129
|
+
* subject is which account gets paid, that is the worst possible place to
|
|
130
|
+
* imply work has been done.
|
|
131
|
+
*/
|
|
132
|
+
stored: boolean;
|
|
133
|
+
}) => ReactNode;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* "Salvar InfiniteTag", not "Salvar".
|
|
138
|
+
*
|
|
139
|
+
* A provider whose whole connection is one field can name it on the button, and
|
|
140
|
+
* that is worth doing on a step whose entire content is that field: the label
|
|
141
|
+
* then says what is about to be committed rather than merely that something is.
|
|
142
|
+
* Providers with several fields keep the plain verb — naming one of four would
|
|
143
|
+
* be a lie about what the button does.
|
|
144
|
+
*/
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The write, as the backend's preserve/clear/replace contract expects it.
|
|
148
|
+
*
|
|
149
|
+
* Every schema key is named on every save, and a key the owner did not touch
|
|
150
|
+
* arrives as `undefined` — which PRESERVES what is stored. That is what makes
|
|
151
|
+
* rotating one secret without re-typing the others work, and it is the reason
|
|
152
|
+
* the payload is built from the SCHEMA rather than from the typed values.
|
|
153
|
+
*/
|
|
154
|
+
function savePayload(
|
|
155
|
+
descriptor: ProviderDescriptor,
|
|
156
|
+
environment: PaymentEnvironment,
|
|
157
|
+
values: Record<string, string>,
|
|
158
|
+
) {
|
|
159
|
+
return {
|
|
160
|
+
environment,
|
|
161
|
+
fields: Object.fromEntries(
|
|
162
|
+
descriptor.credentialSchema.map((spec) => [spec.key, values[spec.key]]),
|
|
163
|
+
),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Switching tabs retires the last verdict AND whatever was half-typed.
|
|
170
|
+
*
|
|
171
|
+
* A Sandbox probe says nothing about Produção, and Produção credentials
|
|
172
|
+
* mid-entry say nothing about Sandbox — carrying either across is how a screen
|
|
173
|
+
* comes to display a green result for the environment it is not looking at, on
|
|
174
|
+
* a page whose subject is which account gets paid.
|
|
175
|
+
*/
|
|
176
|
+
function useRetireOnEnvironmentChange(
|
|
177
|
+
environment: PaymentEnvironment,
|
|
178
|
+
onEditingChange: (editing: boolean) => void,
|
|
179
|
+
setProbe: (probe: VerifyProbe | null) => void,
|
|
180
|
+
setValues: (values: Record<string, string>) => void,
|
|
181
|
+
): void {
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
setProbe(null);
|
|
184
|
+
setValues({});
|
|
185
|
+
onEditingChange(false);
|
|
186
|
+
}, [environment, onEditingChange, setProbe, setValues]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function useCredentialForm(props: ProviderFormProps) {
|
|
190
|
+
const {
|
|
191
|
+
descriptor,
|
|
192
|
+
config,
|
|
193
|
+
client,
|
|
194
|
+
environment,
|
|
195
|
+
editing,
|
|
196
|
+
onEditingChange,
|
|
197
|
+
onChanged,
|
|
198
|
+
onSaved,
|
|
199
|
+
onCredentialsReplaced,
|
|
200
|
+
} = props;
|
|
201
|
+
const [values, setValues] = useState<Record<string, string>>({});
|
|
202
|
+
const [probe, setProbe] = useState<VerifyProbe | null>(null);
|
|
203
|
+
const [pending, setPending] = useState<PendingSave | null>(null);
|
|
204
|
+
const { busy, error, run } = useSettingsAction(onChanged, onSaved);
|
|
205
|
+
|
|
206
|
+
const probeNow = () =>
|
|
207
|
+
run('verify', () => client.verify(descriptor.name, environment)).then(
|
|
208
|
+
(verified) => verified && setProbe(verified.probe),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const save = () =>
|
|
212
|
+
run('save', () => client.saveCredentials(descriptor.name, savePayload(descriptor, environment, values)))
|
|
213
|
+
.then((next) => {
|
|
214
|
+
setPending(null);
|
|
215
|
+
if (!next) return undefined;
|
|
216
|
+
// The verdict described the credentials as they WERE; saving retires
|
|
217
|
+
// it, and so does everything the owner told us about the old account.
|
|
218
|
+
setValues({});
|
|
219
|
+
setProbe(null);
|
|
220
|
+
onEditingChange(false);
|
|
221
|
+
onCredentialsReplaced?.();
|
|
222
|
+
// …and the new ones are tested at once, which is the only moment the
|
|
223
|
+
// probe is useful: it reads STORED credentials, so it has nothing to
|
|
224
|
+
// say until a save has happened.
|
|
225
|
+
//
|
|
226
|
+
// Unless there is nothing to test. A save that CLEARS the field leaves
|
|
227
|
+
// no credential to probe, and asking anyway makes the adapter answer
|
|
228
|
+
// "Handle não configurado" — a store that has entered nothing has not
|
|
229
|
+
// failed at anything; it is simply NÃO VERIFICADO.
|
|
230
|
+
return allRequiredStored(descriptor, next, environment) ? probeNow() : undefined;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
useRetireOnEnvironmentChange(environment, onEditingChange, setProbe, setValues);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
values,
|
|
237
|
+
probe,
|
|
238
|
+
pending,
|
|
239
|
+
busy,
|
|
240
|
+
error,
|
|
241
|
+
masked: config?.environments[environment] ?? {},
|
|
242
|
+
// Saving an untouched form is not a free no-op: `saveCredentials` resets
|
|
243
|
+
// the connection to UNVERIFIED, drops the proof and switches it OFF.
|
|
244
|
+
nothingEdited: Object.keys(values).length === 0,
|
|
245
|
+
valid: fieldsWellFormed(descriptor, values),
|
|
246
|
+
summary: summaryOf(descriptor, config, environment),
|
|
247
|
+
edit: (spec: string, value: string) => {
|
|
248
|
+
setValues((v) => ({ ...v, [spec]: value }));
|
|
249
|
+
setProbe(null); // an edited field retires the last verdict
|
|
250
|
+
},
|
|
251
|
+
/** Ask first when the value decides where the money goes, else just write. */
|
|
252
|
+
requestSave: () => {
|
|
253
|
+
const confirm = needsConfirmation(descriptor, config, environment, values);
|
|
254
|
+
if (confirm) setPending(confirm);
|
|
255
|
+
else void save();
|
|
256
|
+
},
|
|
257
|
+
save: () => void save(),
|
|
258
|
+
cancelSave: () => setPending(null),
|
|
259
|
+
reopen: () => onEditingChange(true),
|
|
260
|
+
verify: () => void probeNow(),
|
|
261
|
+
editing,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The summary row to show INSTEAD of the fields, or null to show the fields.
|
|
267
|
+
*
|
|
268
|
+
* Four conditions have to hold at once, so they live together rather than as a
|
|
269
|
+
* boolean assembled beside the JSX: there has to be a summarisable value, the
|
|
270
|
+
* probe has to have reached the account with it, and the owner must be neither
|
|
271
|
+
* mid-edit nor holding unsaved changes — collapsing over either of those last
|
|
272
|
+
* two would silently discard what they had typed.
|
|
273
|
+
*/
|
|
274
|
+
/**
|
|
275
|
+
* Does the environment currently on screen hold every required credential?
|
|
276
|
+
*
|
|
277
|
+
* Read from the MASKED view of that environment specifically — not from
|
|
278
|
+
* `config.status`, which describes the active one and would answer for the
|
|
279
|
+
* other tab.
|
|
280
|
+
*/
|
|
281
|
+
function storedHere(
|
|
282
|
+
descriptor: ProviderDescriptor,
|
|
283
|
+
masked: Record<string, { configured: boolean } | undefined>,
|
|
284
|
+
): boolean {
|
|
285
|
+
return descriptor.credentialSchema
|
|
286
|
+
.filter((spec) => spec.required)
|
|
287
|
+
.every((spec) => masked[spec.key]?.configured === true);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function collapsedSummary(
|
|
291
|
+
form: ReturnType<typeof useCredentialForm>,
|
|
292
|
+
connected: boolean,
|
|
293
|
+
): { spec: CredentialFieldSpec; value: string } | null {
|
|
294
|
+
if (!form.summary || !connected) return null;
|
|
295
|
+
if (form.editing || !form.nothingEdited) return null;
|
|
296
|
+
return form.summary;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The live inputs for the step still owed, plus the one button that commits
|
|
302
|
+
* them — and, on a store that has already proved it can receive, the warning
|
|
303
|
+
* that saving will undo that.
|
|
304
|
+
*
|
|
305
|
+
* Its own component so `ProviderForm` stays about WHICH of the two shapes is on
|
|
306
|
+
* screen (the collapsed row, or this) rather than about what each contains.
|
|
307
|
+
*/
|
|
308
|
+
function CredentialFields({
|
|
309
|
+
descriptor,
|
|
310
|
+
form,
|
|
311
|
+
proven,
|
|
312
|
+
}: {
|
|
313
|
+
descriptor: ProviderDescriptor;
|
|
314
|
+
form: ReturnType<typeof useCredentialForm>;
|
|
315
|
+
/** A real charge has landed through this connection — see `ReverifyWarning`. */
|
|
316
|
+
proven: boolean;
|
|
317
|
+
}) {
|
|
318
|
+
return (
|
|
319
|
+
<Stack spacing={2}>
|
|
320
|
+
{proven ? <ReverifyWarning /> : null}
|
|
321
|
+
{descriptor.credentialSchema.map((spec) => (
|
|
322
|
+
<CredentialField
|
|
323
|
+
key={spec.key}
|
|
324
|
+
spec={spec}
|
|
325
|
+
state={form.masked[spec.key]}
|
|
326
|
+
value={form.values[spec.key]}
|
|
327
|
+
onChange={(value) => form.edit(spec.key, value)}
|
|
328
|
+
/>
|
|
329
|
+
))}
|
|
330
|
+
<FormActions
|
|
331
|
+
busy={form.busy}
|
|
332
|
+
label={saveLabel(descriptor)}
|
|
333
|
+
disabled={form.nothingEdited || !form.valid}
|
|
334
|
+
onSave={form.requestSave}
|
|
335
|
+
/>
|
|
336
|
+
</Stack>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function ProviderForm(props: ProviderFormProps) {
|
|
341
|
+
const { descriptor, config, renderGuide } = props;
|
|
342
|
+
const form = useCredentialForm(props);
|
|
343
|
+
|
|
344
|
+
// Step 1 is finished when the credential is stored AND the probe has reached
|
|
345
|
+
// the account with it. Then the fields fold into one legible line: still
|
|
346
|
+
// checkable at a glance, no longer one keystroke from being changed.
|
|
347
|
+
const summary = collapsedSummary(form, isConnected(config));
|
|
348
|
+
|
|
349
|
+
const rows = summary ? (
|
|
350
|
+
<DoneRow
|
|
351
|
+
testId="payments-credential-summary"
|
|
352
|
+
label={summary.spec.label}
|
|
353
|
+
value={summary.value}
|
|
354
|
+
mono={summary.spec.mono}
|
|
355
|
+
onEdit={form.reopen}
|
|
356
|
+
/>
|
|
357
|
+
) : null;
|
|
358
|
+
|
|
359
|
+
const fields = summary ? null : <CredentialFields descriptor={descriptor} form={form} proven={Boolean(config?.chargeVerifiedAt)} />;
|
|
360
|
+
|
|
361
|
+
return (
|
|
362
|
+
<Stack spacing={2}>
|
|
363
|
+
{/* No guide (the host supplied none): the form stands on its own, which
|
|
364
|
+
is what every caller saw before the walkthrough could hold it. */}
|
|
365
|
+
{renderGuide
|
|
366
|
+
? renderGuide({
|
|
367
|
+
rows,
|
|
368
|
+
sectionFooter: fields,
|
|
369
|
+
editing: form.editing && !summary,
|
|
370
|
+
stored: storedHere(descriptor, form.masked),
|
|
371
|
+
})
|
|
372
|
+
: (rows ?? fields)}
|
|
373
|
+
|
|
374
|
+
{form.error ? <Alert severity="error">{form.error}</Alert> : null}
|
|
375
|
+
{form.probe ? (
|
|
376
|
+
<ProbeAlert probe={form.probe} busy={form.busy !== null} onRetry={form.verify} />
|
|
377
|
+
) : null}
|
|
378
|
+
|
|
379
|
+
<ConfirmCredentialSave
|
|
380
|
+
pending={form.pending}
|
|
381
|
+
busy={form.busy !== null}
|
|
382
|
+
onCancel={form.cancelSave}
|
|
383
|
+
onConfirm={form.save}
|
|
384
|
+
/>
|
|
385
|
+
</Stack>
|
|
386
|
+
);
|
|
387
|
+
}
|