@12-apps/payments-frontend 3.41.0 → 3.43.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/package.json +2 -2
- package/src/activation/use-redirect-activation.ts +4 -3
- package/src/card/pagbank-test-cards.ts +50 -0
- package/src/card/tokenize.ts +26 -0
- package/src/components/checkout/poll-live.ts +7 -1
- package/src/components/platform/ConnectApplicationPanel.tsx +23 -5
- package/src/components/platform/ConnectEnvironmentCard.tsx +127 -29
- package/src/components/platform/HomologacaoEvidenceCard.tsx +277 -0
- package/src/components/platform/PastedCredentialsCard.tsx +227 -0
- package/src/components/platform/copy.ts +51 -0
- package/src/components/platform/en-US.ts +57 -3
- package/src/components/platform/pt-BR.ts +61 -3
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.43.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"storybook:build": "storybook build"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@12-apps/payments-backend": "^4.
|
|
26
|
+
"@12-apps/payments-backend": "^4.33.0",
|
|
27
27
|
"react-qr-code": "^2.2.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
@@ -143,9 +143,10 @@ interface ActivationCallbacks {
|
|
|
143
143
|
* provider's page and gives up at `pollTimeoutMs` (ten minutes by default).
|
|
144
144
|
*
|
|
145
145
|
* Where it is recorded: the origin host's realtime doc ("Allowed exceptions")
|
|
146
|
-
* and
|
|
147
|
-
*
|
|
148
|
-
*
|
|
146
|
+
* and a decision record that amends ADR 0071 with it. This bounded third-party
|
|
147
|
+
* wait is an amendment to that ADR, not a supersession: 0071 still stands for
|
|
148
|
+
* every other wait. If activation ever becomes webhook-only, this relaxes to
|
|
149
|
+
* the channel exactly as the checkout's waits did and the exception goes away.
|
|
149
150
|
*
|
|
150
151
|
* The give-up is a `failed` state carrying the host's own sentence, because the
|
|
151
152
|
* wording is load-bearing: the charge is STILL valid, and an owner who has
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PagBank's OFFICIAL sandbox test cards (developer.pagbank.com.br, "Cartões de
|
|
3
|
+
* teste") — the only cards the homologação evidence may use (FUT-3696).
|
|
4
|
+
*
|
|
5
|
+
* Public test data, published by PagBank for exactly this: they authorize
|
|
6
|
+
* nothing outside the sandbox. They are never sent to our server — the browser
|
|
7
|
+
* encrypts them and only the encrypted blob leaves it.
|
|
8
|
+
*/
|
|
9
|
+
interface PagBankTestCard {
|
|
10
|
+
scenario: "approved" | "declined";
|
|
11
|
+
brand: string;
|
|
12
|
+
number: string;
|
|
13
|
+
holder: string;
|
|
14
|
+
expMonth: string;
|
|
15
|
+
expYear: string;
|
|
16
|
+
securityCode: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* December of NEXT year. PagBank's page prints 12/2026 as the example expiry,
|
|
21
|
+
* and the sandbox accepts any future date; a literal would turn every run
|
|
22
|
+
* after that month into a "card expired" evidence for a reason nobody chose.
|
|
23
|
+
*/
|
|
24
|
+
const TEST_EXPIRY_YEAR = String(new Date().getFullYear() + 1);
|
|
25
|
+
|
|
26
|
+
export const PAGBANK_TEST_CARDS: readonly PagBankTestCard[] = [
|
|
27
|
+
{
|
|
28
|
+
scenario: "approved",
|
|
29
|
+
brand: "VISA",
|
|
30
|
+
number: "4539620659922097",
|
|
31
|
+
holder: "Jose da Silva",
|
|
32
|
+
expMonth: "12",
|
|
33
|
+
expYear: TEST_EXPIRY_YEAR,
|
|
34
|
+
securityCode: "123",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
scenario: "declined",
|
|
38
|
+
brand: "VISA",
|
|
39
|
+
number: "4929291898380766",
|
|
40
|
+
holder: "Jose da Silva",
|
|
41
|
+
expMonth: "12",
|
|
42
|
+
expYear: TEST_EXPIRY_YEAR,
|
|
43
|
+
securityCode: "123",
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/** `4539 62** **** 2097` — the first six and last four, as a receipt prints them. */
|
|
48
|
+
export function maskTestCard(number: string): string {
|
|
49
|
+
return `${number.slice(0, 4)} ${number.slice(4, 6)}** **** ${number.slice(-4)}`;
|
|
50
|
+
}
|
package/src/card/tokenize.ts
CHANGED
|
@@ -335,3 +335,29 @@ function mintToken(prefix: string): string {
|
|
|
335
335
|
tokenCounter += 1;
|
|
336
336
|
return `${prefix}_${Date.now().toString(36)}${tokenCounter.toString(36)}`;
|
|
337
337
|
}
|
|
338
|
+
|
|
339
|
+
/** Why {@link encryptPagBankCard} produced no blob. */
|
|
340
|
+
type PagBankEncryptFailure = "SDK_UNAVAILABLE" | "NOT_PROCESSED";
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Encrypt a card with PagBank's own browser SDK — the same script and the same
|
|
344
|
+
* `PagSeguro.encryptCard` call the checkout's {@link tokenizeCard} makes, minus
|
|
345
|
+
* the buyer-form validation and copy. For the homologação evidence (FUT-3696),
|
|
346
|
+
* which encrypts PagBank's official TEST cards and must prove the flow the
|
|
347
|
+
* checkout really uses: the PAN never leaves the browser, only the blob does.
|
|
348
|
+
*/
|
|
349
|
+
export async function encryptPagBankCard(params: {
|
|
350
|
+
publicKey: string;
|
|
351
|
+
holder: string;
|
|
352
|
+
number: string;
|
|
353
|
+
expMonth: string;
|
|
354
|
+
expYear: string;
|
|
355
|
+
securityCode: string;
|
|
356
|
+
}): Promise<{ encrypted: string } | { failure: PagBankEncryptFailure }> {
|
|
357
|
+
if (!(await ensurePagBankSdk()) || !window.PagSeguro?.encryptCard) {
|
|
358
|
+
return { failure: "SDK_UNAVAILABLE" };
|
|
359
|
+
}
|
|
360
|
+
const result = window.PagSeguro.encryptCard(params);
|
|
361
|
+
if (result.hasErrors || !result.encryptedCard) return { failure: "NOT_PROCESSED" };
|
|
362
|
+
return { encrypted: result.encryptedCard };
|
|
363
|
+
}
|
|
@@ -23,7 +23,13 @@
|
|
|
23
23
|
* - A wait given {@link LiveCadence.liveIntervalMs} keeps that floor. The hook
|
|
24
24
|
* gives it one only on a hosted-checkout return carrying the settlement pair
|
|
25
25
|
* (`carriesSettlementPair`), where the buyer's own read is what confirms the
|
|
26
|
-
* payment and no server sweep can stand in for it.
|
|
26
|
+
* payment and no server sweep can stand in for it. That floor is a timed
|
|
27
|
+
* read on a live channel — a bounded wait on a THIRD PARTY, the provider the
|
|
28
|
+
* buyer just paid — and it is recorded as one: the origin host's realtime
|
|
29
|
+
* doc lists it under "Allowed exceptions", beside the redirect activation's
|
|
30
|
+
* verify (`../../activation/use-redirect-activation.ts`), with the decision
|
|
31
|
+
* that admits the class. If the provider's webhook ever settles every
|
|
32
|
+
* return, the floor goes and the return waits on the channel like the rest.
|
|
27
33
|
*
|
|
28
34
|
* Split out of `poll-loop.ts` so the loop stays one question: WHEN to ask. This
|
|
29
35
|
* file answers only how being live changes that answer.
|
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
import { Box, Button, Stack, Typography } from '@mui/material';
|
|
4
4
|
import type { ReactNode } from 'react';
|
|
5
5
|
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
ConnectApplicationReport,
|
|
8
|
+
ConnectApplicationStatus,
|
|
9
|
+
PastedConnectCredentials,
|
|
10
|
+
PaymentEnvironment,
|
|
11
|
+
} from '@12-apps/payments-backend';
|
|
7
12
|
|
|
8
|
-
import { CARD_SX, ConnectEnvironmentCard } from './ConnectEnvironmentCard';
|
|
13
|
+
import { CARD_SX, ConnectEnvironmentCard, TAP_SX } from './ConnectEnvironmentCard';
|
|
9
14
|
import type { PlatformHomologacaoCopy } from './copy';
|
|
10
15
|
import { PlatformCopyProvider, usePlatformCopy } from './copy-context';
|
|
16
|
+
import { PastedCredentialsCard } from './PastedCredentialsCard';
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* The platform's PagBank Connect application, per environment (FUT-479,
|
|
@@ -41,9 +47,17 @@ export interface ConnectApplicationPanelProps {
|
|
|
41
47
|
/**
|
|
42
48
|
* Which host-side variables feed one environment's application — the host's
|
|
43
49
|
* own configuration surface, rendered as a collapsible per-environment help
|
|
44
|
-
* when provided.
|
|
50
|
+
* when provided. ORDER MATTERS: the first three name the client id, the
|
|
51
|
+
* client secret and the account token, in that order — the credential
|
|
52
|
+
* preview and the paste fields are labelled by position.
|
|
45
53
|
*/
|
|
46
54
|
configVarsFor?: (environment: PaymentEnvironment) => string[];
|
|
55
|
+
/**
|
|
56
|
+
* Consult with PASTED credentials — the host's mounted call to the backend's
|
|
57
|
+
* `consultPastedConnectApplication`, resolving to the status it answered.
|
|
58
|
+
* Omitted, the paste block is not rendered.
|
|
59
|
+
*/
|
|
60
|
+
onConsultPasted?: (pasted: PastedConnectCredentials) => Promise<ConnectApplicationStatus>;
|
|
47
61
|
/** Every word this panel and its environment cards render. REQUIRED. */
|
|
48
62
|
copy: PlatformHomologacaoCopy;
|
|
49
63
|
}
|
|
@@ -57,12 +71,12 @@ export function ConnectApplicationPanel(props: ConnectApplicationPanelProps): Re
|
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
function ConnectApplicationBody(props: ConnectApplicationPanelProps): ReactNode {
|
|
60
|
-
const { report, onRefresh, configVarsFor } = props;
|
|
74
|
+
const { report, onRefresh, configVarsFor, onConsultPasted } = props;
|
|
61
75
|
const copy = usePlatformCopy().connect;
|
|
62
76
|
return (
|
|
63
77
|
<Stack spacing={2} data-testid="connect-application-panel">
|
|
64
78
|
<Stack spacing={0.5} data-testid="connect-expected-redirect" sx={CARD_SX}>
|
|
65
|
-
<Typography variant="caption"
|
|
79
|
+
<Typography variant="caption" fontWeight={600}>
|
|
66
80
|
{copy.expectedRedirectHeading}
|
|
67
81
|
</Typography>
|
|
68
82
|
<Box
|
|
@@ -79,6 +93,9 @@ function ConnectApplicationBody(props: ConnectApplicationPanelProps): ReactNode
|
|
|
79
93
|
configVars={configVarsFor?.(status.environment)}
|
|
80
94
|
/>
|
|
81
95
|
))}
|
|
96
|
+
{onConsultPasted ? (
|
|
97
|
+
<PastedCredentialsCard onConsult={onConsultPasted} configVarsFor={configVarsFor} />
|
|
98
|
+
) : null}
|
|
82
99
|
{onRefresh ? (
|
|
83
100
|
<Box>
|
|
84
101
|
<Button
|
|
@@ -86,6 +103,7 @@ function ConnectApplicationBody(props: ConnectApplicationPanelProps): ReactNode
|
|
|
86
103
|
size="small"
|
|
87
104
|
onClick={() => onRefresh()}
|
|
88
105
|
data-testid="connect-refresh"
|
|
106
|
+
sx={TAP_SX}
|
|
89
107
|
>
|
|
90
108
|
{copy.consultAgain}
|
|
91
109
|
</Button>
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { Alert, Box, Button, Stack, Typography } from '@mui/material';
|
|
4
|
-
import { useState, type ReactNode } from 'react';
|
|
4
|
+
import { Fragment, useState, type ReactNode } from 'react';
|
|
5
5
|
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
ConnectApplicationStatus,
|
|
8
|
+
ConnectCredentialPreview,
|
|
9
|
+
PaymentEnvironment,
|
|
10
|
+
} from '@12-apps/payments-backend';
|
|
7
11
|
import { usePlatformCopy } from './copy-context';
|
|
8
12
|
|
|
9
13
|
/**
|
|
@@ -11,17 +15,15 @@ import { usePlatformCopy } from './copy-context';
|
|
|
11
15
|
* Sandbox and production are SEPARATE applications with separate id/secret
|
|
12
16
|
* pairs, so each card stands on its own: configured or not, what PagBank says
|
|
13
17
|
* is registered, and whether the registered redirect URI matches the
|
|
14
|
-
* deployment's callback
|
|
18
|
+
* deployment's callback — plus the first characters of each value it was
|
|
19
|
+
* consulted with, so an operator can tell WHICH id, secret and token are in play.
|
|
15
20
|
*
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
21
|
+
* The same card renders the answer to a PASTED consult, so its test ids key on
|
|
22
|
+
* a `slot` — the environment for the host's two cards, `PASTED` for that one —
|
|
23
|
+
* rather than on the environment, which the pasted answer shares with one of
|
|
24
|
+
* them.
|
|
18
25
|
*/
|
|
19
26
|
|
|
20
|
-
const ENV_LABEL: Record<PaymentEnvironment, string> = {
|
|
21
|
-
SANDBOX: 'Sandbox',
|
|
22
|
-
PRODUCTION: 'Production',
|
|
23
|
-
};
|
|
24
|
-
|
|
25
27
|
/** The bordered-card look every block of these screens shares. */
|
|
26
28
|
export const CARD_SX = {
|
|
27
29
|
border: 1,
|
|
@@ -30,13 +32,32 @@ export const CARD_SX = {
|
|
|
30
32
|
p: 2,
|
|
31
33
|
} as const;
|
|
32
34
|
|
|
35
|
+
/** A small button that is still a 40px tap target on a phone. */
|
|
36
|
+
export const TAP_SX = { minHeight: { xs: 40, sm: 'auto' } } as const;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A label like `PAGBANK_OAUTH_CLIENT_SECRET` is one token, so a narrow card
|
|
40
|
+
* would break it anywhere ("…SECRE / T"). A `<wbr>` after each underscore lets
|
|
41
|
+
* it wrap at the word joints instead.
|
|
42
|
+
*/
|
|
43
|
+
function breakAfterUnderscores(label: string): ReactNode {
|
|
44
|
+
return label.split(/(?<=_)/).map((part, index) => (
|
|
45
|
+
// The parts are positional by construction; the label never reorders.
|
|
46
|
+
<Fragment key={index}>
|
|
47
|
+
{part}
|
|
48
|
+
<wbr />
|
|
49
|
+
</Fragment>
|
|
50
|
+
));
|
|
51
|
+
}
|
|
52
|
+
|
|
33
53
|
function Field({ label, children }: { label: string; children: ReactNode }): ReactNode {
|
|
34
54
|
return (
|
|
35
55
|
<Box
|
|
36
|
-
|
|
56
|
+
// Whole words stay whole; only an unbroken id or URL breaks mid-run.
|
|
57
|
+
sx={{ display: 'flex', flexDirection: 'column', gap: 0.25, minWidth: 180, overflowWrap: 'anywhere' }}
|
|
37
58
|
>
|
|
38
|
-
<Typography variant="caption"
|
|
39
|
-
{label}
|
|
59
|
+
<Typography variant="caption" fontWeight={600}>
|
|
60
|
+
{breakAfterUnderscores(label)}
|
|
40
61
|
</Typography>
|
|
41
62
|
<Typography component="span" variant="body2">
|
|
42
63
|
{children}
|
|
@@ -46,32 +67,32 @@ function Field({ label, children }: { label: string; children: ReactNode }): Rea
|
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
/** The verdict the screen exists for: does the registered callback match ours? */
|
|
49
|
-
function MismatchAlert({ status }: { status: ConnectApplicationStatus }): ReactNode {
|
|
70
|
+
function MismatchAlert({ status, slot }: { status: ConnectApplicationStatus; slot: string }): ReactNode {
|
|
50
71
|
const copy = usePlatformCopy().connect;
|
|
51
72
|
if (status.application === null) return null;
|
|
52
73
|
if (status.redirectUriMismatch === true) {
|
|
53
74
|
return (
|
|
54
|
-
<Alert severity="error" data-testid={`connect-mismatch-${
|
|
75
|
+
<Alert severity="error" data-testid={`connect-mismatch-${slot}`}>
|
|
55
76
|
{copy.redirectDiffers}
|
|
56
77
|
</Alert>
|
|
57
78
|
);
|
|
58
79
|
}
|
|
59
80
|
if (status.redirectUriMismatch === false) {
|
|
60
81
|
return (
|
|
61
|
-
<Alert severity="success" data-testid={`connect-match-${
|
|
82
|
+
<Alert severity="success" data-testid={`connect-match-${slot}`}>
|
|
62
83
|
{copy.redirectMatches}
|
|
63
84
|
</Alert>
|
|
64
85
|
);
|
|
65
86
|
}
|
|
66
87
|
return (
|
|
67
|
-
<Alert severity="warning" data-testid={`connect-unknown-${
|
|
88
|
+
<Alert severity="warning" data-testid={`connect-unknown-${slot}`}>
|
|
68
89
|
{copy.redirectUnreported}
|
|
69
90
|
</Alert>
|
|
70
91
|
);
|
|
71
92
|
}
|
|
72
93
|
|
|
73
94
|
/** What PagBank reports as registered, plus whatever extra keys came back. */
|
|
74
|
-
function ApplicationFields({ status }: { status: ConnectApplicationStatus }): ReactNode {
|
|
95
|
+
function ApplicationFields({ status, slot }: { status: ConnectApplicationStatus; slot: string }): ReactNode {
|
|
75
96
|
const copy = usePlatformCopy().connect;
|
|
76
97
|
const app = status.application;
|
|
77
98
|
if (app === null) return null;
|
|
@@ -88,8 +109,8 @@ function ApplicationFields({ status }: { status: ConnectApplicationStatus }): Re
|
|
|
88
109
|
</Field>
|
|
89
110
|
</Box>
|
|
90
111
|
{extraKeys.length > 0 ? (
|
|
91
|
-
<Box data-testid={`connect-extra-${
|
|
92
|
-
<Typography variant="caption"
|
|
112
|
+
<Box data-testid={`connect-extra-${slot}`}>
|
|
113
|
+
<Typography variant="caption" fontWeight={600}>
|
|
93
114
|
{copy.extraKeys}
|
|
94
115
|
</Typography>
|
|
95
116
|
<Box component="pre" sx={{ m: 0, fontSize: 12, overflowX: 'auto' }}>
|
|
@@ -101,6 +122,69 @@ function ApplicationFields({ status }: { status: ConnectApplicationStatus }): Re
|
|
|
101
122
|
);
|
|
102
123
|
}
|
|
103
124
|
|
|
125
|
+
const EMPTY_PREVIEW: ConnectCredentialPreview = {
|
|
126
|
+
clientId: null,
|
|
127
|
+
clientSecret: null,
|
|
128
|
+
accountToken: null,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** One previewed value: unset, inherited from a fallback variable, or its own. */
|
|
132
|
+
function PreviewValue({ value, inherited }: { value: string | null; inherited: boolean }): ReactNode {
|
|
133
|
+
const copy = usePlatformCopy().connect;
|
|
134
|
+
if (value === null) return copy.previewUnset;
|
|
135
|
+
const code = (
|
|
136
|
+
<Box component="code" sx={{ fontFamily: 'monospace', fontSize: 13 }}>
|
|
137
|
+
{`${value}…`}
|
|
138
|
+
</Box>
|
|
139
|
+
);
|
|
140
|
+
if (!inherited) return code;
|
|
141
|
+
return (
|
|
142
|
+
<>
|
|
143
|
+
{copy.previewInherited} {code}
|
|
144
|
+
</>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The first characters of each value the application was consulted with —
|
|
150
|
+
* never the value. Each is named by the host's own variable when it supplies
|
|
151
|
+
* them (`configVars`, in id / secret / token order), by the copy's names when not.
|
|
152
|
+
*/
|
|
153
|
+
function CredentialPreview({
|
|
154
|
+
status,
|
|
155
|
+
slot,
|
|
156
|
+
configVars,
|
|
157
|
+
}: {
|
|
158
|
+
status: ConnectApplicationStatus;
|
|
159
|
+
slot: string;
|
|
160
|
+
configVars?: string[];
|
|
161
|
+
}): ReactNode {
|
|
162
|
+
const copy = usePlatformCopy().connect;
|
|
163
|
+
// A payload from a backend older than the preview carries none — render it unset, never crash.
|
|
164
|
+
const preview = status.credentialPreview ?? EMPTY_PREVIEW;
|
|
165
|
+
const names = copy.credentialNames;
|
|
166
|
+
const fields = ['clientId', 'clientSecret', 'accountToken'] as const;
|
|
167
|
+
const rows = fields.map((field, index) => ({
|
|
168
|
+
name: configVars?.[index] ?? names[field],
|
|
169
|
+
value: preview[field],
|
|
170
|
+
inherited: preview.inherited?.includes(field) ?? false,
|
|
171
|
+
}));
|
|
172
|
+
return (
|
|
173
|
+
<Stack spacing={1} data-testid={`connect-preview-${slot}`}>
|
|
174
|
+
<Typography variant="caption" fontWeight={600}>
|
|
175
|
+
{copy.previewHeading}
|
|
176
|
+
</Typography>
|
|
177
|
+
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2.5 }}>
|
|
178
|
+
{rows.map(({ name, value, inherited }) => (
|
|
179
|
+
<Field key={name} label={name}>
|
|
180
|
+
<PreviewValue value={value} inherited={inherited} />
|
|
181
|
+
</Field>
|
|
182
|
+
))}
|
|
183
|
+
</Box>
|
|
184
|
+
</Stack>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
104
188
|
/**
|
|
105
189
|
* The collapsible "what feeds this environment" help. The variable names are
|
|
106
190
|
* the HOST's own configuration surface, so they arrive via `configVars`; with
|
|
@@ -123,6 +207,7 @@ function ConfigHelp({
|
|
|
123
207
|
size="small"
|
|
124
208
|
onClick={() => setOpen((value) => !value)}
|
|
125
209
|
data-testid={`connect-config-toggle-${environment}`}
|
|
210
|
+
sx={TAP_SX}
|
|
126
211
|
>
|
|
127
212
|
{open ? copy.hideConfig : copy.showConfig}
|
|
128
213
|
</Button>
|
|
@@ -149,18 +234,30 @@ function ConfigHelp({
|
|
|
149
234
|
export function ConnectEnvironmentCard({
|
|
150
235
|
status,
|
|
151
236
|
configVars,
|
|
237
|
+
slot = status.environment,
|
|
238
|
+
showConfigHelp = true,
|
|
152
239
|
}: {
|
|
153
240
|
status: ConnectApplicationStatus;
|
|
154
241
|
configVars?: string[];
|
|
242
|
+
/** What the test ids key on — see the module doc. */
|
|
243
|
+
slot?: string;
|
|
244
|
+
/** The variable help belongs to a host-configured environment, not a pasted answer. */
|
|
245
|
+
showConfigHelp?: boolean;
|
|
155
246
|
}): ReactNode {
|
|
156
247
|
const copy = usePlatformCopy().connect;
|
|
157
248
|
return (
|
|
158
|
-
<Stack spacing={1.5} data-testid={`connect-env-${
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
249
|
+
<Stack spacing={1.5} data-testid={`connect-env-${slot}`} sx={CARD_SX}>
|
|
250
|
+
{/* The label never yields to a long client_id: the id wraps beneath it. */}
|
|
251
|
+
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', columnGap: 1.5 }}>
|
|
252
|
+
<Typography variant="body2" fontWeight={600} sx={{ whiteSpace: 'nowrap' }}>
|
|
253
|
+
{copy.environmentLabels[status.environment]}
|
|
162
254
|
</Typography>
|
|
163
|
-
<Typography
|
|
255
|
+
<Typography
|
|
256
|
+
component="span"
|
|
257
|
+
variant="caption"
|
|
258
|
+
color="text.secondary"
|
|
259
|
+
sx={{ wordBreak: 'break-all', minWidth: 0 }}
|
|
260
|
+
>
|
|
164
261
|
{status.clientId !== null ? `client_id: ${status.clientId}` : ''}
|
|
165
262
|
</Typography>
|
|
166
263
|
</Box>
|
|
@@ -168,13 +265,14 @@ export function ConnectEnvironmentCard({
|
|
|
168
265
|
<Typography variant="body2" color="text.secondary">{copy.noApplication}</Typography>
|
|
169
266
|
) : null}
|
|
170
267
|
{status.error !== null ? (
|
|
171
|
-
<Alert severity="warning" data-testid={`connect-error-${
|
|
268
|
+
<Alert severity="warning" data-testid={`connect-error-${slot}`}>
|
|
172
269
|
{status.error}
|
|
173
270
|
</Alert>
|
|
174
271
|
) : null}
|
|
175
|
-
<MismatchAlert status={status} />
|
|
176
|
-
<ApplicationFields status={status} />
|
|
177
|
-
<
|
|
272
|
+
<MismatchAlert status={status} slot={slot} />
|
|
273
|
+
<ApplicationFields status={status} slot={slot} />
|
|
274
|
+
<CredentialPreview status={status} slot={slot} configVars={configVars} />
|
|
275
|
+
{showConfigHelp ? <ConfigHelp environment={status.environment} configVars={configVars} /> : null}
|
|
178
276
|
</Stack>
|
|
179
277
|
);
|
|
180
278
|
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Alert, Box, Button, LinearProgress, Stack, Typography } from '@mui/material';
|
|
4
|
+
import { useState, type ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
BrowserCardResult,
|
|
8
|
+
EvidenceSummary,
|
|
9
|
+
PaymentEnvironment,
|
|
10
|
+
} from '@12-apps/payments-backend';
|
|
11
|
+
|
|
12
|
+
import { maskTestCard, PAGBANK_TEST_CARDS } from '../../card/pagbank-test-cards';
|
|
13
|
+
import { encryptPagBankCard } from '../../card/tokenize';
|
|
14
|
+
import { CARD_SX, TAP_SX } from './ConnectEnvironmentCard';
|
|
15
|
+
import type { PlatformHomologacaoCopy } from './copy';
|
|
16
|
+
import { PlatformCopyProvider, usePlatformCopy } from './copy-context';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The homologação EVIDENCE card (FUT-3696): connect a sandbox test seller,
|
|
20
|
+
* then run the real scenarios and download the TXT PagBank's form takes.
|
|
21
|
+
*
|
|
22
|
+
* The one thing this card does itself is the part that has to happen in a
|
|
23
|
+
* browser: encrypting PagBank's official TEST cards with the seller's public
|
|
24
|
+
* key, through the same SDK call the checkout makes. Everything else is the
|
|
25
|
+
* host's — each callback is a call to its own mounted routes — and what the
|
|
26
|
+
* file says is decided by the server, from what was recorded. This card shows
|
|
27
|
+
* the counts it answered and never infers a result of its own.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The test seller's connection, as the host reads it back. */
|
|
31
|
+
export interface HomologacaoSellerView {
|
|
32
|
+
connected: boolean;
|
|
33
|
+
environment: PaymentEnvironment | null;
|
|
34
|
+
/** UNVERIFIED | VERIFIED | FAILED | RECONNECT_REQUIRED. */
|
|
35
|
+
status: string | null;
|
|
36
|
+
connectedAt: string | null;
|
|
37
|
+
accountId: string | null;
|
|
38
|
+
/** Why the latest authorization did not complete, as the evidence recorded it. */
|
|
39
|
+
lastConnectProblem?: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface HomologacaoEvidenceCardProps {
|
|
43
|
+
copy: PlatformHomologacaoCopy;
|
|
44
|
+
/** Null while the host is still reading it. */
|
|
45
|
+
seller: HomologacaoSellerView | null;
|
|
46
|
+
/** How the last return from PagBank ended, when the page was opened by one. */
|
|
47
|
+
connectOutcome?: 'connected' | 'failed' | null;
|
|
48
|
+
/** Start the authorization: the host mints the state and navigates away. */
|
|
49
|
+
onConnect: () => Promise<void>;
|
|
50
|
+
/** Phase 1 — the seller's public key, null when it could not be minted. */
|
|
51
|
+
onPrepare: () => Promise<{ runId: string; publicKey: string | null }>;
|
|
52
|
+
/** Phase 2 — raise the orders with the encrypted cards. */
|
|
53
|
+
onRun: (runId: string, cards: BrowserCardResult[]) => Promise<void>;
|
|
54
|
+
/** Build and deliver the file; resolves to the counts it holds. */
|
|
55
|
+
onDownload: () => Promise<EvidenceSummary>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Phase = 'idle' | 'preparing' | 'encrypting' | 'running' | 'downloading';
|
|
59
|
+
type ActivePhase = Exclude<Phase, 'idle'>;
|
|
60
|
+
|
|
61
|
+
/** Each official test card, encrypted — or the code saying why it is not. */
|
|
62
|
+
async function encryptTestCards(publicKey: string | null): Promise<BrowserCardResult[]> {
|
|
63
|
+
const results: BrowserCardResult[] = [];
|
|
64
|
+
for (const card of PAGBANK_TEST_CARDS) {
|
|
65
|
+
const base = { scenario: card.scenario, brand: card.brand, masked: maskTestCard(card.number) };
|
|
66
|
+
if (!publicKey) {
|
|
67
|
+
results.push({ ...base, encrypted: null, failure: 'NO_PUBLIC_KEY' });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const encrypted = await encryptPagBankCard({
|
|
71
|
+
publicKey,
|
|
72
|
+
holder: card.holder,
|
|
73
|
+
number: card.number,
|
|
74
|
+
expMonth: card.expMonth,
|
|
75
|
+
expYear: card.expYear,
|
|
76
|
+
securityCode: card.securityCode,
|
|
77
|
+
});
|
|
78
|
+
results.push(
|
|
79
|
+
'encrypted' in encrypted
|
|
80
|
+
? { ...base, encrypted: encrypted.encrypted }
|
|
81
|
+
: { ...base, encrypted: null, failure: encrypted.failure },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function HomologacaoEvidenceCard(props: HomologacaoEvidenceCardProps): ReactNode {
|
|
88
|
+
return (
|
|
89
|
+
<PlatformCopyProvider copy={props.copy}>
|
|
90
|
+
<EvidenceBody {...props} />
|
|
91
|
+
</PlatformCopyProvider>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The seller's one-line state — null while the host is still reading it. */
|
|
96
|
+
function sellerLine(copy: PlatformHomologacaoCopy['evidence'], seller: HomologacaoSellerView | null): string | null {
|
|
97
|
+
if (!seller) return null;
|
|
98
|
+
if (seller.status === 'RECONNECT_REQUIRED') return copy.sellerReconnect;
|
|
99
|
+
return seller.connected ? copy.sellerConnected(seller.connectedAt, seller.accountId) : copy.sellerNotConnected;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function SellerBlock({
|
|
103
|
+
seller,
|
|
104
|
+
onConnect,
|
|
105
|
+
}: Pick<HomologacaoEvidenceCardProps, 'seller' | 'onConnect'>): ReactNode {
|
|
106
|
+
const copy = usePlatformCopy().evidence;
|
|
107
|
+
const [failed, setFailed] = useState(false);
|
|
108
|
+
const [busy, setBusy] = useState(false);
|
|
109
|
+
const line = sellerLine(copy, seller);
|
|
110
|
+
const offerReconnect = Boolean(seller?.connected) && seller?.status !== 'RECONNECT_REQUIRED';
|
|
111
|
+
const connect = (): void => {
|
|
112
|
+
setBusy(true);
|
|
113
|
+
setFailed(false);
|
|
114
|
+
onConnect()
|
|
115
|
+
.catch(() => setFailed(true))
|
|
116
|
+
.finally(() => setBusy(false));
|
|
117
|
+
};
|
|
118
|
+
return (
|
|
119
|
+
<Stack spacing={1} data-testid="evidence-seller">
|
|
120
|
+
<Typography variant="body2" fontWeight={600}>
|
|
121
|
+
{copy.sellerHeading}
|
|
122
|
+
</Typography>
|
|
123
|
+
{line ? (
|
|
124
|
+
<Typography variant="body2" data-testid="evidence-seller-state">
|
|
125
|
+
{line}
|
|
126
|
+
</Typography>
|
|
127
|
+
) : null}
|
|
128
|
+
<Typography variant="caption" component="p" color="text.secondary">
|
|
129
|
+
{copy.connectHint}
|
|
130
|
+
</Typography>
|
|
131
|
+
<Box>
|
|
132
|
+
<Button
|
|
133
|
+
// The next step until a seller is connected; secondary once one is.
|
|
134
|
+
variant={offerReconnect ? 'outlined' : 'contained'}
|
|
135
|
+
size="small"
|
|
136
|
+
disabled={!seller || busy}
|
|
137
|
+
onClick={connect}
|
|
138
|
+
data-testid="evidence-connect"
|
|
139
|
+
sx={TAP_SX}
|
|
140
|
+
>
|
|
141
|
+
{offerReconnect ? copy.reconnect : copy.connect}
|
|
142
|
+
</Button>
|
|
143
|
+
</Box>
|
|
144
|
+
{failed ? (
|
|
145
|
+
<Alert severity="error" data-testid="evidence-connect-failed">
|
|
146
|
+
{copy.connectFailed}
|
|
147
|
+
</Alert>
|
|
148
|
+
) : null}
|
|
149
|
+
</Stack>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** One run at a time: which phase it is in, what it answered, where it failed. */
|
|
154
|
+
function useEvidenceRun({ onPrepare, onRun, onDownload }: Pick<HomologacaoEvidenceCardProps, 'onPrepare' | 'onRun' | 'onDownload'>) {
|
|
155
|
+
const [phase, setPhase] = useState<Phase>('idle');
|
|
156
|
+
const [summary, setSummary] = useState<EvidenceSummary | null>(null);
|
|
157
|
+
const [failed, setFailed] = useState<ActivePhase | null>(null);
|
|
158
|
+
const run = async (full: boolean): Promise<void> => {
|
|
159
|
+
setFailed(null);
|
|
160
|
+
setSummary(null);
|
|
161
|
+
// The phase that throws is the one the operator is told about.
|
|
162
|
+
let current: ActivePhase = full ? 'preparing' : 'downloading';
|
|
163
|
+
const enter = (next: ActivePhase): void => {
|
|
164
|
+
current = next;
|
|
165
|
+
setPhase(next);
|
|
166
|
+
};
|
|
167
|
+
try {
|
|
168
|
+
if (full) {
|
|
169
|
+
enter('preparing');
|
|
170
|
+
const prepared = await onPrepare();
|
|
171
|
+
enter('encrypting');
|
|
172
|
+
const cards = await encryptTestCards(prepared.publicKey);
|
|
173
|
+
enter('running');
|
|
174
|
+
await onRun(prepared.runId, cards);
|
|
175
|
+
}
|
|
176
|
+
enter('downloading');
|
|
177
|
+
setSummary(await onDownload());
|
|
178
|
+
} catch {
|
|
179
|
+
setFailed(current);
|
|
180
|
+
} finally {
|
|
181
|
+
setPhase('idle');
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
return { phase, busy: phase !== 'idle', summary, failed, run };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** How the last return from PagBank ended — with the recorded reason when it failed. */
|
|
188
|
+
function ConnectOutcome({ seller, connectOutcome }: Pick<HomologacaoEvidenceCardProps, 'seller' | 'connectOutcome'>): ReactNode {
|
|
189
|
+
const copy = usePlatformCopy().evidence;
|
|
190
|
+
// The URL only says PagBank came back; the seller's own row says whether it
|
|
191
|
+
// connected. A "connected" the row does not confirm is not announced.
|
|
192
|
+
const outcome = connectOutcome === 'connected' && !seller?.connected ? null : connectOutcome;
|
|
193
|
+
if (!outcome) return null;
|
|
194
|
+
return (
|
|
195
|
+
<Alert severity={outcome === 'connected' ? 'success' : 'warning'} data-testid="evidence-connect-outcome">
|
|
196
|
+
{outcome === 'connected' ? copy.returnedConnected : (seller?.lastConnectProblem ?? copy.returnedFailed)}
|
|
197
|
+
</Alert>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function EvidenceBody(props: HomologacaoEvidenceCardProps): ReactNode {
|
|
202
|
+
const { seller, onConnect } = props;
|
|
203
|
+
const copy = usePlatformCopy().evidence;
|
|
204
|
+
const { phase, busy, summary, failed, run } = useEvidenceRun(props);
|
|
205
|
+
return (
|
|
206
|
+
<Stack spacing={1.5} data-testid="homologacao-evidence-card" sx={CARD_SX}>
|
|
207
|
+
<Typography variant="body2" fontWeight={600}>
|
|
208
|
+
{copy.heading}
|
|
209
|
+
</Typography>
|
|
210
|
+
<Typography variant="caption" component="p">
|
|
211
|
+
{copy.lede}
|
|
212
|
+
</Typography>
|
|
213
|
+
<ConnectOutcome seller={seller} connectOutcome={props.connectOutcome} />
|
|
214
|
+
<SellerBlock seller={seller} onConnect={onConnect} />
|
|
215
|
+
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ alignItems: { sm: 'center' } }}>
|
|
216
|
+
<Button
|
|
217
|
+
variant={seller?.connected ? 'contained' : 'outlined'}
|
|
218
|
+
size="small"
|
|
219
|
+
disabled={busy}
|
|
220
|
+
onClick={() => void run(true)}
|
|
221
|
+
data-testid="evidence-generate"
|
|
222
|
+
sx={TAP_SX}
|
|
223
|
+
>
|
|
224
|
+
{copy.generate}
|
|
225
|
+
</Button>
|
|
226
|
+
<Button
|
|
227
|
+
variant="text"
|
|
228
|
+
size="small"
|
|
229
|
+
disabled={busy}
|
|
230
|
+
onClick={() => void run(false)}
|
|
231
|
+
data-testid="evidence-download-latest"
|
|
232
|
+
sx={TAP_SX}
|
|
233
|
+
>
|
|
234
|
+
{copy.downloadLatest}
|
|
235
|
+
</Button>
|
|
236
|
+
</Stack>
|
|
237
|
+
{busy ? (
|
|
238
|
+
<Stack spacing={0.5} role="status" aria-busy="true" data-testid="evidence-phase">
|
|
239
|
+
<Typography variant="body2">{copy.phases[phase as ActivePhase]}</Typography>
|
|
240
|
+
<LinearProgress />
|
|
241
|
+
</Stack>
|
|
242
|
+
) : null}
|
|
243
|
+
<EvidenceResult summary={summary} failed={failed} />
|
|
244
|
+
</Stack>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The server's counts — never inferred here — or the failure line. */
|
|
249
|
+
function EvidenceResult({
|
|
250
|
+
summary,
|
|
251
|
+
failed,
|
|
252
|
+
}: {
|
|
253
|
+
summary: EvidenceSummary | null;
|
|
254
|
+
failed: ActivePhase | null;
|
|
255
|
+
}): ReactNode {
|
|
256
|
+
const copy = usePlatformCopy().evidence;
|
|
257
|
+
if (failed) {
|
|
258
|
+
return (
|
|
259
|
+
<Alert severity="error" data-testid="evidence-generate-failed">
|
|
260
|
+
{copy.generateFailed(failed)}
|
|
261
|
+
</Alert>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (!summary) return null;
|
|
265
|
+
return (
|
|
266
|
+
<Stack spacing={0.5} role="status" data-testid="evidence-summary">
|
|
267
|
+
<Typography variant="body2" fontWeight={600} data-testid="evidence-summary-counts">
|
|
268
|
+
{copy.summary(summary)}
|
|
269
|
+
</Typography>
|
|
270
|
+
{summary.failed + summary.pending > 0 ? (
|
|
271
|
+
<Typography variant="caption" component="p" data-testid="evidence-incomplete">
|
|
272
|
+
{copy.incomplete}
|
|
273
|
+
</Typography>
|
|
274
|
+
) : null}
|
|
275
|
+
</Stack>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Alert, Box, Button, Stack, TextField, Typography } from '@mui/material';
|
|
4
|
+
import { useRef, useState, type ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
ConnectApplicationStatus,
|
|
8
|
+
PastedConnectCredentials,
|
|
9
|
+
PaymentEnvironment,
|
|
10
|
+
} from '@12-apps/payments-backend';
|
|
11
|
+
|
|
12
|
+
import { CARD_SX, ConnectEnvironmentCard, TAP_SX } from './ConnectEnvironmentCard';
|
|
13
|
+
import { usePlatformCopy } from './copy-context';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Consult a Connect application with credentials PASTED here rather than the
|
|
17
|
+
* ones the host holds — the backend's `consultPastedConnectApplication`.
|
|
18
|
+
*
|
|
19
|
+
* The host's two cards answer "what is configured?"; this answers "is the
|
|
20
|
+
* application I am about to configure the right one, and is its redirect_uri
|
|
21
|
+
* ours?" before anyone edits a secret and redeploys to find out. The answer
|
|
22
|
+
* comes back in the same card the environments use, previews included, so the
|
|
23
|
+
* three blocks read the same way.
|
|
24
|
+
*
|
|
25
|
+
* Whatever `onConsult` sends is the host's to keep out of storage and logs —
|
|
26
|
+
* this card holds the values in component state and nowhere else.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
type PastedValues = Omit<PastedConnectCredentials, 'environment'>;
|
|
30
|
+
|
|
31
|
+
const EMPTY: PastedValues = { clientId: '', clientSecret: '', accountToken: '' };
|
|
32
|
+
|
|
33
|
+
/** The three paste fields; the secret two are masked as they are typed. */
|
|
34
|
+
function CredentialInputs({
|
|
35
|
+
names,
|
|
36
|
+
values,
|
|
37
|
+
onChange,
|
|
38
|
+
}: {
|
|
39
|
+
names: [string, string, string];
|
|
40
|
+
values: PastedValues;
|
|
41
|
+
onChange: (values: PastedValues) => void;
|
|
42
|
+
}): ReactNode {
|
|
43
|
+
const fields: [keyof PastedValues, string, boolean][] = [
|
|
44
|
+
['clientId', names[0], false],
|
|
45
|
+
['clientSecret', names[1], true],
|
|
46
|
+
['accountToken', names[2], true],
|
|
47
|
+
];
|
|
48
|
+
return fields.map(([key, label, secret]) => (
|
|
49
|
+
<TextField
|
|
50
|
+
key={key}
|
|
51
|
+
size="small"
|
|
52
|
+
type={secret ? 'password' : 'text'}
|
|
53
|
+
label={label}
|
|
54
|
+
value={values[key]}
|
|
55
|
+
// "off", never "new-password": that value is the hint password managers use to
|
|
56
|
+
// offer saving what was typed, and these values are never to be stored.
|
|
57
|
+
autoComplete="off"
|
|
58
|
+
onChange={(event) => onChange({ ...values, [key]: event.target.value })}
|
|
59
|
+
// Shrunk for good: the names share a long prefix, so an empty field's
|
|
60
|
+
// placeholder-sized label is cut to the same "PAGBANK_OAUTH_CLIENT…" twice.
|
|
61
|
+
slotProps={{
|
|
62
|
+
inputLabel: { shrink: true },
|
|
63
|
+
htmlInput: {
|
|
64
|
+
'data-testid': `connect-pasted-${key}`,
|
|
65
|
+
// Password managers that ignore autocomplete honour these.
|
|
66
|
+
'data-1p-ignore': true,
|
|
67
|
+
'data-lpignore': 'true',
|
|
68
|
+
},
|
|
69
|
+
}}
|
|
70
|
+
/>
|
|
71
|
+
));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The input names: the host's own variables when it names them, the copy's when not. */
|
|
75
|
+
function useCredentialNames(
|
|
76
|
+
environment: PaymentEnvironment,
|
|
77
|
+
configVarsFor?: (environment: PaymentEnvironment) => string[],
|
|
78
|
+
): [string, string, string] {
|
|
79
|
+
const fallback = usePlatformCopy().connect.credentialNames;
|
|
80
|
+
const vars = configVarsFor?.(environment) ?? [];
|
|
81
|
+
return [
|
|
82
|
+
vars[0] ?? fallback.clientId,
|
|
83
|
+
vars[1] ?? fallback.clientSecret,
|
|
84
|
+
vars[2] ?? fallback.accountToken,
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One consult at a time: what it answered, whether it failed, whether it is running. */
|
|
89
|
+
function usePastedConsult(
|
|
90
|
+
onConsult: (pasted: PastedConnectCredentials) => Promise<ConnectApplicationStatus>,
|
|
91
|
+
): {
|
|
92
|
+
run: (pasted: PastedConnectCredentials) => void;
|
|
93
|
+
reset: () => void;
|
|
94
|
+
pending: boolean;
|
|
95
|
+
failed: boolean;
|
|
96
|
+
result: ConnectApplicationStatus | null;
|
|
97
|
+
} {
|
|
98
|
+
const [pending, setPending] = useState(false);
|
|
99
|
+
const [failed, setFailed] = useState(false);
|
|
100
|
+
const [result, setResult] = useState<ConnectApplicationStatus | null>(null);
|
|
101
|
+
// Which call the screen still wants an answer to. `reset` and every new `run`
|
|
102
|
+
// move it on, so a consult that settles after the values changed is dropped
|
|
103
|
+
// instead of landing beside values it was not given.
|
|
104
|
+
const current = useRef(0);
|
|
105
|
+
const run = (pasted: PastedConnectCredentials): void => {
|
|
106
|
+
const id = ++current.current;
|
|
107
|
+
const live = (): boolean => id === current.current;
|
|
108
|
+
setPending(true);
|
|
109
|
+
setFailed(false);
|
|
110
|
+
onConsult(pasted)
|
|
111
|
+
.then((status) => live() && setResult(status))
|
|
112
|
+
.catch(() => live() && setFailed(true))
|
|
113
|
+
.finally(() => live() && setPending(false));
|
|
114
|
+
};
|
|
115
|
+
// An answer belongs to the values it was given: editing any of them retires it.
|
|
116
|
+
const reset = (): void => {
|
|
117
|
+
current.current += 1;
|
|
118
|
+
setPending(false);
|
|
119
|
+
setFailed(false);
|
|
120
|
+
setResult(null);
|
|
121
|
+
};
|
|
122
|
+
return { run, reset, pending, failed, result };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function PastedCredentialsCard({
|
|
126
|
+
onConsult,
|
|
127
|
+
configVarsFor,
|
|
128
|
+
}: {
|
|
129
|
+
onConsult: (pasted: PastedConnectCredentials) => Promise<ConnectApplicationStatus>;
|
|
130
|
+
configVarsFor?: (environment: PaymentEnvironment) => string[];
|
|
131
|
+
}): ReactNode {
|
|
132
|
+
const copy = usePlatformCopy().connect;
|
|
133
|
+
const [environment, setEnvironment] = useState<PaymentEnvironment>('SANDBOX');
|
|
134
|
+
const [values, setValues] = useState<PastedValues>(EMPTY);
|
|
135
|
+
const names = useCredentialNames(environment, configVarsFor);
|
|
136
|
+
const consult = usePastedConsult(onConsult);
|
|
137
|
+
const complete = Object.values(values).every((value) => value.trim() !== '');
|
|
138
|
+
const trimmed = {
|
|
139
|
+
environment,
|
|
140
|
+
clientId: values.clientId.trim(),
|
|
141
|
+
clientSecret: values.clientSecret.trim(),
|
|
142
|
+
accountToken: values.accountToken.trim(),
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<Stack spacing={1.5} data-testid="connect-pasted" sx={CARD_SX}>
|
|
147
|
+
<Typography variant="body2" fontWeight={600}>
|
|
148
|
+
{copy.pasted.heading}
|
|
149
|
+
</Typography>
|
|
150
|
+
<Typography variant="caption" component="p">
|
|
151
|
+
{copy.pasted.lede}
|
|
152
|
+
</Typography>
|
|
153
|
+
<Stack spacing={1.5} sx={{ maxWidth: 560 }}>
|
|
154
|
+
<TextField
|
|
155
|
+
select
|
|
156
|
+
size="small"
|
|
157
|
+
label={copy.pasted.environment}
|
|
158
|
+
value={environment}
|
|
159
|
+
onChange={(event) => {
|
|
160
|
+
setEnvironment(event.target.value as PaymentEnvironment);
|
|
161
|
+
consult.reset();
|
|
162
|
+
}}
|
|
163
|
+
slotProps={{
|
|
164
|
+
select: { native: true },
|
|
165
|
+
htmlInput: { 'data-testid': 'connect-pasted-environment' },
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
<option value="SANDBOX">{copy.environmentLabels.SANDBOX}</option>
|
|
169
|
+
<option value="PRODUCTION">{copy.environmentLabels.PRODUCTION}</option>
|
|
170
|
+
</TextField>
|
|
171
|
+
<CredentialInputs
|
|
172
|
+
names={names}
|
|
173
|
+
values={values}
|
|
174
|
+
onChange={(next) => {
|
|
175
|
+
setValues(next);
|
|
176
|
+
consult.reset();
|
|
177
|
+
}}
|
|
178
|
+
/>
|
|
179
|
+
<Box>
|
|
180
|
+
<Button
|
|
181
|
+
variant="contained"
|
|
182
|
+
size="small"
|
|
183
|
+
disabled={!complete || consult.pending}
|
|
184
|
+
onClick={() => consult.run(trimmed)}
|
|
185
|
+
data-testid="connect-pasted-submit"
|
|
186
|
+
sx={TAP_SX}
|
|
187
|
+
>
|
|
188
|
+
{copy.pasted.submit}
|
|
189
|
+
</Button>
|
|
190
|
+
</Box>
|
|
191
|
+
</Stack>
|
|
192
|
+
<PastedAnswer consult={consult} configVarsFor={configVarsFor} />
|
|
193
|
+
</Stack>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The failure line, or the answer in the same card the environments use. */
|
|
198
|
+
function PastedAnswer({
|
|
199
|
+
consult,
|
|
200
|
+
configVarsFor,
|
|
201
|
+
}: {
|
|
202
|
+
consult: ReturnType<typeof usePastedConsult>;
|
|
203
|
+
configVarsFor?: (environment: PaymentEnvironment) => string[];
|
|
204
|
+
}): ReactNode {
|
|
205
|
+
const copy = usePlatformCopy().connect;
|
|
206
|
+
if (consult.failed) {
|
|
207
|
+
return (
|
|
208
|
+
<Alert severity="error" data-testid="connect-pasted-failed">
|
|
209
|
+
{copy.pasted.failed}
|
|
210
|
+
</Alert>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (!consult.result) return null;
|
|
214
|
+
return (
|
|
215
|
+
<Stack spacing={1}>
|
|
216
|
+
<Alert severity="info" data-testid="connect-pasted-secret-not-checked">
|
|
217
|
+
{copy.pasted.secretNotChecked}
|
|
218
|
+
</Alert>
|
|
219
|
+
<ConnectEnvironmentCard
|
|
220
|
+
status={consult.result}
|
|
221
|
+
slot="PASTED"
|
|
222
|
+
configVars={configVarsFor?.(consult.result.environment)}
|
|
223
|
+
showConfigHelp={false}
|
|
224
|
+
/>
|
|
225
|
+
</Stack>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
@@ -102,6 +102,56 @@ export interface ConnectApplicationCopy {
|
|
|
102
102
|
hideConfig: string;
|
|
103
103
|
/** The heading above whatever extra keys PagBank's response carried. */
|
|
104
104
|
extraKeys: string;
|
|
105
|
+
/** Each card's heading — the environment, in the operator's words. */
|
|
106
|
+
environmentLabels: Readonly<Record<'SANDBOX' | 'PRODUCTION', string>>;
|
|
107
|
+
/**
|
|
108
|
+
* The preview of the values an application was consulted with: a heading,
|
|
109
|
+
* what an unset value reads as, and each value's name when the host names no
|
|
110
|
+
* configuration variables of its own (`configVarsFor`).
|
|
111
|
+
*/
|
|
112
|
+
previewHeading: string;
|
|
113
|
+
previewUnset: string;
|
|
114
|
+
/** A value read from a fallback variable (production's token from the unsuffixed name). */
|
|
115
|
+
previewInherited: string;
|
|
116
|
+
credentialNames: { clientId: string; clientSecret: string; accountToken: string };
|
|
117
|
+
/** The block that consults with PASTED credentials — rendered only when the host wires it. */
|
|
118
|
+
pasted: {
|
|
119
|
+
heading: string;
|
|
120
|
+
/** Must say the values are not stored: the operator is pasting secrets. */
|
|
121
|
+
lede: string;
|
|
122
|
+
environment: string;
|
|
123
|
+
submit: string;
|
|
124
|
+
failed: string;
|
|
125
|
+
/**
|
|
126
|
+
* The consult authenticates with the account token and looks up the client
|
|
127
|
+
* id; the secret is never sent. The answer must not read as "all three are good".
|
|
128
|
+
*/
|
|
129
|
+
secretNotChecked: string;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The homologação evidence card (FUT-3696) — the sandbox test seller and the TXT. */
|
|
134
|
+
export interface HomologacaoEvidenceCardCopy {
|
|
135
|
+
heading: string;
|
|
136
|
+
lede: string;
|
|
137
|
+
sellerHeading: string;
|
|
138
|
+
/** `connectedAt` is ISO-8601; the pack formats it in its own locale. */
|
|
139
|
+
sellerConnected: (connectedAt: string | null, accountId: string | null) => string;
|
|
140
|
+
sellerNotConnected: string;
|
|
141
|
+
sellerReconnect: string;
|
|
142
|
+
connectHint: string;
|
|
143
|
+
connect: string;
|
|
144
|
+
reconnect: string;
|
|
145
|
+
connectFailed: string;
|
|
146
|
+
returnedConnected: string;
|
|
147
|
+
returnedFailed: string;
|
|
148
|
+
generate: string;
|
|
149
|
+
downloadLatest: string;
|
|
150
|
+
phases: { preparing: string; encrypting: string; running: string; downloading: string };
|
|
151
|
+
summary: (counts: { executed: number; failed: number; pending: number }) => string;
|
|
152
|
+
incomplete: string;
|
|
153
|
+
/** Which phase failed — and that the latest recorded file can still be downloaded. */
|
|
154
|
+
generateFailed: (phase: 'preparing' | 'encrypting' | 'running' | 'downloading') => string;
|
|
105
155
|
}
|
|
106
156
|
|
|
107
157
|
/** The whole platform surface, in one object a host passes at the mount. */
|
|
@@ -110,4 +160,5 @@ export interface PlatformHomologacaoCopy {
|
|
|
110
160
|
guide: HomologacaoGuideCopy;
|
|
111
161
|
anexo: HomologacaoAnexoCopy;
|
|
112
162
|
connect: ConnectApplicationCopy;
|
|
163
|
+
evidence: HomologacaoEvidenceCardCopy;
|
|
113
164
|
}
|
|
@@ -72,7 +72,7 @@ export const EN_US_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
|
|
|
72
72
|
// operator compares the two strings character by character.
|
|
73
73
|
redirectMatches: 'The registered redirect_uri matches the callback this deploy uses.',
|
|
74
74
|
redirectDiffers:
|
|
75
|
-
'The redirect_uri registered with PagBank differs from the callback this deploy uses.',
|
|
75
|
+
'The redirect_uri registered with PagBank differs from the callback this deploy uses. The OAuth authorization flow fails silently until the registration is fixed at PagBank.',
|
|
76
76
|
redirectUnreported:
|
|
77
77
|
"PagBank's response carried no redirect_uri, so it could not be compared with the callback.",
|
|
78
78
|
fields: {
|
|
@@ -85,9 +85,63 @@ export const EN_US_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
|
|
|
85
85
|
fieldEmpty: '—',
|
|
86
86
|
redirectNotReported: 'not reported',
|
|
87
87
|
resolvedFrom:
|
|
88
|
-
"This environment's application is resolved
|
|
88
|
+
"This environment's application is resolved from these variables. The client_id and client_secret are strict, with no fallback between environments; the others, such as the token, fall back to the unsuffixed variable when this environment's own is unset:",
|
|
89
89
|
showConfig: 'Show environment variables',
|
|
90
90
|
hideConfig: 'Hide environment variables',
|
|
91
|
-
extraKeys: 'Other fields returned',
|
|
91
|
+
extraKeys: 'Other fields returned (undocumented schema)',
|
|
92
|
+
environmentLabels: { SANDBOX: 'Sandbox', PRODUCTION: 'Production' },
|
|
93
|
+
previewHeading: 'First 5 characters of each variable',
|
|
94
|
+
previewUnset: 'not set',
|
|
95
|
+
previewInherited: 'not set — uses the unsuffixed variable:',
|
|
96
|
+
// `client_id` / `client_secret` are PagBank's own parameter names.
|
|
97
|
+
credentialNames: { clientId: 'client_id', clientSecret: 'client_secret', accountToken: 'account token' },
|
|
98
|
+
pasted: {
|
|
99
|
+
heading: 'Test other credentials',
|
|
100
|
+
lede:
|
|
101
|
+
"Paste an application's credentials to look it up at PagBank before changing the environment variables. The values are used for this lookup only and are not saved.",
|
|
102
|
+
environment: 'Environment',
|
|
103
|
+
submit: 'Look up with these credentials',
|
|
104
|
+
failed: 'The pasted credentials could not be looked up.',
|
|
105
|
+
secretNotChecked:
|
|
106
|
+
'PagBank does not check the client_secret in this lookup — only the client_id and the token. A wrong client_secret only shows at the first store authorization.',
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
evidence: {
|
|
110
|
+
heading: 'Homologation evidence (sandbox)',
|
|
111
|
+
lede: "Runs the real scenarios in PagBank's sandbox and builds the TXT for the form. Nothing is charged.",
|
|
112
|
+
sellerHeading: 'Test seller',
|
|
113
|
+
sellerConnected: (connectedAt, accountId) =>
|
|
114
|
+
`Connected${connectedAt ? ` on ${new Date(connectedAt).toLocaleString('en-US')}` : ''}` +
|
|
115
|
+
(accountId ? ` · account ${accountId}` : ''),
|
|
116
|
+
sellerNotConnected:
|
|
117
|
+
'No test seller connected. Without one, the Connect, Pix and card scenarios stay pending in the file.',
|
|
118
|
+
sellerReconnect: "The test seller's connection must be authorized again.",
|
|
119
|
+
connectHint:
|
|
120
|
+
"You will sign in to PagBank's sandbox with a test seller account and approve the consent. Sandbox only.",
|
|
121
|
+
connect: 'Connect test seller',
|
|
122
|
+
reconnect: 'Reconnect test seller',
|
|
123
|
+
connectFailed: 'Could not start the test seller connection.',
|
|
124
|
+
returnedConnected: 'Test seller connected. The callback and the code exchange were recorded in the evidence.',
|
|
125
|
+
returnedFailed: 'The test seller connection did not complete. The reason was recorded in the evidence file.',
|
|
126
|
+
generate: 'Generate evidence file\u00a0(TXT)',
|
|
127
|
+
downloadLatest: 'Download again (no run)',
|
|
128
|
+
phases: {
|
|
129
|
+
preparing: 'Consulting the application, renewing the token and minting the public key…',
|
|
130
|
+
encrypting: 'Encrypting the test cards in the browser…',
|
|
131
|
+
running: 'Creating the Pix and card orders in the sandbox — this can take up to a minute…',
|
|
132
|
+
downloading: 'Building the file…',
|
|
133
|
+
},
|
|
134
|
+
summary: ({ executed, failed, pending }) => `✓ ${executed} executed · ✗ ${failed} failed · ⧗ ${pending} pending`,
|
|
135
|
+
incomplete:
|
|
136
|
+
'The file lists what is missing under Pendências. Do not submit it as complete. Notifications that arrive later show up when you download again.',
|
|
137
|
+
generateFailed: (phase) =>
|
|
138
|
+
`${
|
|
139
|
+
{
|
|
140
|
+
preparing: 'Consulting the application and preparing the test seller failed.',
|
|
141
|
+
encrypting: 'Encrypting the test cards in the browser failed.',
|
|
142
|
+
running: 'Creating the orders in the sandbox failed.',
|
|
143
|
+
downloading: 'Building the file failed.',
|
|
144
|
+
}[phase]
|
|
145
|
+
} Try again; "Download again" still returns what was recorded.`,
|
|
92
146
|
},
|
|
93
147
|
};
|
|
@@ -52,7 +52,7 @@ export const PT_BR_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
|
|
|
52
52
|
noApplication: 'Nenhuma aplicação configurada neste ambiente.',
|
|
53
53
|
redirectMatches: 'O redirect_uri registrado confere com o callback que este deploy usa.',
|
|
54
54
|
redirectDiffers:
|
|
55
|
-
'O redirect_uri registrado no PagBank é diferente do callback que este deploy usa.',
|
|
55
|
+
'O redirect_uri registrado no PagBank é diferente do callback que este deploy usa. O fluxo de autorização OAuth falha silenciosamente até o cadastro ser corrigido no PagBank.',
|
|
56
56
|
redirectUnreported:
|
|
57
57
|
'A resposta do PagBank não trouxe redirect_uri, então não foi possível comparar com o callback.',
|
|
58
58
|
fields: {
|
|
@@ -65,9 +65,67 @@ export const PT_BR_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
|
|
|
65
65
|
fieldEmpty: '—',
|
|
66
66
|
redirectNotReported: 'não informado',
|
|
67
67
|
resolvedFrom:
|
|
68
|
-
'A aplicação deste ambiente é resolvida
|
|
68
|
+
'A aplicação deste ambiente é resolvida a partir destas variáveis. O client_id e o client_secret são estritos, sem fallback entre ambientes; as demais, como o token, caem para a variável sem sufixo quando a deste ambiente não existe:',
|
|
69
69
|
showConfig: 'Mostrar variáveis de ambiente',
|
|
70
70
|
hideConfig: 'Ocultar variáveis de ambiente',
|
|
71
|
-
extraKeys: 'Outros campos retornados',
|
|
71
|
+
extraKeys: 'Outros campos retornados (schema não documentado)',
|
|
72
|
+
environmentLabels: { SANDBOX: 'Sandbox', PRODUCTION: 'Produção' },
|
|
73
|
+
previewHeading: 'Primeiros 5 caracteres de cada variável',
|
|
74
|
+
previewUnset: 'não definida',
|
|
75
|
+
previewInherited: 'não definida — usa o valor da variável sem sufixo:',
|
|
76
|
+
credentialNames: { clientId: 'client_id', clientSecret: 'client_secret', accountToken: 'token da conta' },
|
|
77
|
+
pasted: {
|
|
78
|
+
heading: 'Testar outras credenciais',
|
|
79
|
+
lede:
|
|
80
|
+
'Cole as credenciais de uma aplicação para consultá-la no PagBank antes de alterar as variáveis de ambiente. Os valores são usados só nesta consulta e não são salvos.',
|
|
81
|
+
environment: 'Ambiente',
|
|
82
|
+
submit: 'Consultar com estas credenciais',
|
|
83
|
+
failed: 'Não foi possível consultar com as credenciais coladas.',
|
|
84
|
+
secretNotChecked:
|
|
85
|
+
'O PagBank não verifica o client_secret nesta consulta — só o client_id e o token. Um client_secret errado só aparece na primeira autorização de loja.',
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
evidence: {
|
|
89
|
+
heading: 'Evidências para homologação (sandbox)',
|
|
90
|
+
lede: 'Executa os cenários reais no sandbox do PagBank e gera o TXT para o formulário. Nada é cobrado.',
|
|
91
|
+
sellerHeading: 'Lojista de teste',
|
|
92
|
+
sellerConnected: (connectedAt, accountId) =>
|
|
93
|
+
`Conectado${connectedAt ? ` em ${new Date(connectedAt).toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}` : ''}` +
|
|
94
|
+
(accountId ? ` · conta ${accountId}` : ''),
|
|
95
|
+
sellerNotConnected:
|
|
96
|
+
'Nenhum lojista de teste conectado. Sem ele, os cenários de Connect, Pix e cartão ficam pendentes no arquivo.',
|
|
97
|
+
sellerReconnect: 'A conexão do lojista de teste precisa ser autorizada de novo.',
|
|
98
|
+
connectHint:
|
|
99
|
+
'Você fará login no sandbox do PagBank com uma conta de vendedor de teste e aprovará o consentimento. Somente sandbox.',
|
|
100
|
+
connect: 'Conectar lojista de teste',
|
|
101
|
+
reconnect: 'Reconectar lojista de teste',
|
|
102
|
+
connectFailed: 'Não foi possível iniciar a conexão do lojista de teste.',
|
|
103
|
+
returnedConnected:
|
|
104
|
+
'Lojista de teste conectado. O callback e a troca do código foram registrados nas evidências.',
|
|
105
|
+
returnedFailed:
|
|
106
|
+
'A conexão do lojista de teste não foi concluída. O motivo foi registrado no arquivo de evidências.',
|
|
107
|
+
generate: 'Gerar arquivo de evidências\u00a0(TXT)',
|
|
108
|
+
downloadLatest: 'Baixar de novo (sem executar)',
|
|
109
|
+
phases: {
|
|
110
|
+
preparing: 'Consultando a aplicação, renovando o token e gerando a chave pública…',
|
|
111
|
+
encrypting: 'Criptografando os cartões de teste no navegador…',
|
|
112
|
+
running: 'Criando os pedidos Pix e cartão no sandbox — pode levar até um minuto…',
|
|
113
|
+
downloading: 'Montando o arquivo…',
|
|
114
|
+
},
|
|
115
|
+
summary: ({ executed, failed, pending }) =>
|
|
116
|
+
`✓ ${executed} ${executed === 1 ? 'executado' : 'executados'} · ` +
|
|
117
|
+
`✗ ${failed} ${failed === 1 ? 'falhou' : 'falharam'} · ` +
|
|
118
|
+
`⧗ ${pending} ${pending === 1 ? 'pendente' : 'pendentes'}`,
|
|
119
|
+
incomplete:
|
|
120
|
+
'O arquivo lista o que falta na seção Pendências. Não o envie como completo. Notificações que chegarem depois aparecem ao baixar de novo.',
|
|
121
|
+
generateFailed: (phase) =>
|
|
122
|
+
`${
|
|
123
|
+
{
|
|
124
|
+
preparing: 'Falhou ao consultar a aplicação e preparar o lojista de teste.',
|
|
125
|
+
encrypting: 'Falhou ao criptografar os cartões de teste no navegador.',
|
|
126
|
+
running: 'Falhou ao criar os pedidos no sandbox.',
|
|
127
|
+
downloading: 'Falhou ao montar o arquivo.',
|
|
128
|
+
}[phase]
|
|
129
|
+
} Tente de novo; "Baixar de novo" ainda traz o que já foi registrado.`,
|
|
72
130
|
},
|
|
73
131
|
};
|
package/src/index.ts
CHANGED
|
@@ -337,6 +337,7 @@ export {
|
|
|
337
337
|
type ConnectApplicationPanelProps,
|
|
338
338
|
} from './components/platform/ConnectApplicationPanel';
|
|
339
339
|
export { PlatformHomologacao, type PlatformHomologacaoProps } from './components/platform/PlatformHomologacao';
|
|
340
|
+
export * from './components/platform/HomologacaoEvidenceCard';
|
|
340
341
|
export {
|
|
341
342
|
type HomologacaoSaveInput,
|
|
342
343
|
type HomologacaoSaveState,
|